From ad8d2ebe94fcdf467b9fc053ab481e694fddb879 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 14 Feb 2026 14:28:48 +1300 Subject: [PATCH 01/48] melies-u image handling --- src/clj/tasks/images.clj | 4 ++-- src/clj/tasks/nrdb.clj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/clj/tasks/images.clj b/src/clj/tasks/images.clj index 2376782eb3..0c9dc8c7f6 100644 --- a/src/clj/tasks/images.clj +++ b/src/clj/tasks/images.clj @@ -47,7 +47,7 @@ ;; so long as it either has front,back,or some numbers behind it ;; the excess dots are because the lookbehind needs to be fixed width ;; but this ensures we don't split on "front.", and instead split on "." for multi-faced cards -(def ^:cost image-select-regex #"(?<=(.tank|house|ewery|front|.back|....[0123456789]))[a-zA-Z]*\.") +(def ^:cost image-select-regex #"(?<=(.tank|house|ewery|front|posal|rface|enure|.back|....[0123456789]))[a-zA-Z]*\.") (defn- add-flip-card-image [db base-path lang resolution art-set filename] @@ -62,7 +62,7 @@ (mc/update db card-collection {:code code} {$addToSet {k path}}) (mc/update db card-collection {:previous-versions {$elemMatch {:code code}}} {$set {prev-k path}}))) -(def ^:const cards-to-skip #{"08012" "09001" "26066" "26120" "35023" "35057"}) +(def ^:const cards-to-skip #{"08012" "09001" "26066" "26120" "35023" "35057" "36036"}) (defn- add-card-image "Add an image to a card in the db" diff --git a/src/clj/tasks/nrdb.clj b/src/clj/tasks/nrdb.clj index e1dd3b4631..864447b32f 100644 --- a/src/clj/tasks/nrdb.clj +++ b/src/clj/tasks/nrdb.clj @@ -77,7 +77,7 @@ (reduce expand-card `() c))) ;; these are cards with multiple faces, so we can't download them directly -(def ^:const cards-to-skip #{"08012" "09001" "26066" "26120" "35023" "35057"}) +(def ^:const cards-to-skip #{"08012" "09001" "26066" "26120" "35023" "35057" "36036"}) (defn download-card-images "Download card images (if necessary) from NRDB" From a44a56c743cbcd8757b8eecd1c47c7be0af0ac3e Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 14 Feb 2026 14:29:35 +1300 Subject: [PATCH 02/48] Meleis U: Only the Brightest --- src/clj/game/cards/identities.clj | 58 +++++++++++++++++++++++++ test/clj/game/cards/identities_test.clj | 13 ++++++ 2 files changed, 71 insertions(+) diff --git a/src/clj/game/cards/identities.clj b/src/clj/game/cards/identities.clj index 4bbab0ff86..2772394f35 100644 --- a/src/clj/game/cards/identities.clj +++ b/src/clj/game/cards/identities.clj @@ -1481,6 +1481,64 @@ :events [(assoc ability :event :runner-turn-begins)] :abilities [ability]})) +(defcard "Méliès U: Only the Brightest" + {:events [;; At game start, you're on the front face + {:event :pre-first-turn + :req (req (= side :corp)) + :effect (effect (update! + (assoc card + :face :front + :melies-target (first (shuffle ["HQ" "R&D" "Archives"])))))} + ;; When your turn ends, you secretly choose a server + {:event :corp-turn-ends + :prompt "Choose a server" + :interactive (req true) + :choices ["HQ" "R&D" "Archives"] + :msg (msg "secretly choose a server") + :effect (req (update! state side (assoc card :melies-target target)))} + ;; When the runner discard phase ends while you're on the front, you gain 1c + {:event :runner-turn-ends + :req (req (= (:face card) :front)) + :msg "gain 1 [Credit]" + :async true + :effect (req (gain-credits state side eid 1))} + ;; when our turn begins and we are not on the front face, we flip + {:event :corp-turn-begins + :silent (req true) + :effect (effect (update! (assoc card :face :front)))} + ;; When the runner makes a successful run on a central + ;; while we're on a front face, we flip and maybe do something + {:event :successful-run + :req (req (and (= (:face card) :front) (is-central? (:server context)))) + :msg (msg "flip to " + (case (:melies-target card) + "HQ" "Tenure Floors: Méliès U" + "R&D" "Subsurface Labs: Méliès U" + "Archives" "Disposal Grounds: Méliès U" + "this shouldn't occur")) + :async true + :effect (req (let [[target-zone face] (case (:melies-target card) + "HQ" [:hq :tenure] + "R&D" [:rd :subsurface] + "Archives" [:archives :disposal] + [:hq :tenure])] + (update! state side (assoc card :face face)) + (if (and (-> context :server first (= target-zone)) + (seq (:deck corp))) + (continue-ability + state side + {:optional + {:prompt (msg "The top card of R&D is " (:title (first (:deck corp))) ". Trash it?") + :waiting-prompt true + :req (req (seq (:hand runner))) + :yes-ability {:cost [(->c :trash-from-deck 1)] + :once :per-turn + :msg "add 1 card from Archives to HQ" + :async true + :effect (effect (continue-ability (corp-recur) card nil))}}} + card nil) + (effect-completed state side eid))))}]}) + (defcard "Mercury: Chrome Libertador" {:events [{:event :breach-server :automatic :pre-breach diff --git a/test/clj/game/cards/identities_test.clj b/test/clj/game/cards/identities_test.clj index 95194b22ff..db693d4d85 100644 --- a/test/clj/game/cards/identities_test.clj +++ b/test/clj/game/cards/identities_test.clj @@ -3500,6 +3500,19 @@ (is (= 3 (:click (get-runner))) "Wyldside caused 1 click to be lost") (is (= 3 (count (:hand (get-runner)))) "3 cards drawn total")))) +(deftest meiles-u-only-the-brightest-basic + (doseq [[s sn] [[:hq "HQ"] [:rd "R&D"] ["Archives" :archives]]] + (do-game + (new-game {:corp {:id "Méliès U: Only the Brightest" + :hand ["IPO"] + :deck ["Snare!"] + :discard ["Beanstalk Royalties"]}}) + (take-credits state :corp) + (click-prompt state :corp "R&D") + (run-empty-server state :rd) + (click-prompts state :corp "Yes" "IPO" "Snare!") + (is (is-hand? state :corp ["IPO" "Snare!"]))))) + (deftest mercury-chrome-libertador (do-game (new-game {:corp {:deck [(qty "Hedge Fund" 5)] From 621e6d808a9ae8c035a47fd1a33d083369133444 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 15 Feb 2026 06:40:42 +1300 Subject: [PATCH 03/48] Virtual Intelligence, PI --- src/clj/game/cards/identities.clj | 13 +++++++++++++ test/clj/game/cards/identities_test.clj | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/clj/game/cards/identities.clj b/src/clj/game/cards/identities.clj index 2772394f35..d5ca54e836 100644 --- a/src/clj/game/cards/identities.clj +++ b/src/clj/game/cards/identities.clj @@ -2805,6 +2805,19 @@ ;; This doesn't use `gain-bad-publicity` to avoid the event :effect (effect (gain :corp :bad-publicity 1))}]}) +(defcard "Virtual Intelligence, P.I.: \"You Can Call Me Vic\"" + {:abilities [{:cost [(->c :click 1) (->c :credit 1)] + :action true + :once :per-turn + :label "Draw 1 card and remove 1 tag." + :msg (msg (if tagged "draw 1 card and remove 1 tag" "draw 1 card")) + :async true + :change-in-game-state {:req (req (or tagged (seq (:deck runner))))} + :effect (req (if tagged + (wait-for (draw state side 1 {:suppress-checkpoint true}) + (lose-tags state side eid 1)) + (draw state side eid 1)))}]}) + (defcard "Weyland Consortium: Because We Built It" {:recurring 1 :interactions {:pay-credits {:req (req (let [ab-target (:card (get-ability-targets eid))] diff --git a/test/clj/game/cards/identities_test.clj b/test/clj/game/cards/identities_test.clj index db693d4d85..3226923744 100644 --- a/test/clj/game/cards/identities_test.clj +++ b/test/clj/game/cards/identities_test.clj @@ -5764,6 +5764,16 @@ (click-prompt state :runner "Yes") (is (= 2 (count (:hand (get-runner)))) "Took damage, then drew up"))) +(deftest virtual-intelligence-p-i-you-can-call-me-vic + (doseq [tags [0 1]] + (do-game + (new-game {:runner {:id "Virtual Intelligence, P.I.: \"You Can Call Me Vic\"" :tags tags :deck [(qty "Ika" 15)]}}) + (take-credits state :corp) + (is (changed? [(count (:hand (get-runner))) 1] + (card-ability state :runner (get-in @state [:runner :identity]) 0)) + "Drew 1 card") + (is (= 0 (count-tags state)) "Untagged")))) + (deftest weyland-consortium-because-we-built-it-pay-credits-prompt ;; Pay-credits prompt (do-game From 1a8ebeb4f8645feb1cd00e9d52e83efb4e22a7c9 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 15 Feb 2026 17:37:58 +1300 Subject: [PATCH 04/48] remove unused import --- src/clj/game/cards/resources.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clj/game/cards/resources.clj b/src/clj/game/cards/resources.clj index aec36c3326..62a021d155 100644 --- a/src/clj/game/cards/resources.clj +++ b/src/clj/game/cards/resources.clj @@ -66,7 +66,7 @@ [game.core.revealing :refer [reveal reveal-loud]] [game.core.rezzing :refer [derez rez]] [game.core.runs :refer [active-encounter? bypass-ice can-run-server? get-runnable-zones - gain-run-credits get-current-encounter + get-current-encounter update-current-encounter make-run set-next-phase successful-run-replace-breach total-cards-accessed]] From 295ba132a7ddbf2acb0d7c29593cb6e94e905930 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 15 Feb 2026 17:51:00 +1300 Subject: [PATCH 05/48] functional rules change for bad publicity --- src/clj/game/core/bad_publicity.clj | 42 ++++++--- src/clj/game/core/costs.clj | 8 +- src/clj/game/core/diffs.clj | 4 + src/clj/game/core/pick_counters.clj | 110 +++++++++++++++--------- src/clj/game/core/player.clj | 1 + src/clj/game/core/prompts.clj | 40 ++++++--- src/clj/game/core/runs.clj | 4 +- src/cljs/nr/gameboard/player_stats.cljs | 5 +- 8 files changed, 144 insertions(+), 70 deletions(-) diff --git a/src/clj/game/core/bad_publicity.clj b/src/clj/game/core/bad_publicity.clj index 069554431e..f72d7e21ec 100644 --- a/src/clj/game/core/bad_publicity.clj +++ b/src/clj/game/core/bad_publicity.clj @@ -1,13 +1,21 @@ (ns game.core.bad-publicity (:require - [game.core.eid :refer [effect-completed make-eid make-result]] - [game.core.engine :refer [queue-event checkpoint trigger-event-sync]] - [game.core.gaining :refer [gain lose]] - [game.core.prompts :refer [clear-wait-prompt show-prompt show-wait-prompt]] - [game.core.prevention :refer [resolve-bad-pub-prevention]] - [game.core.say :refer [system-msg]] - [game.core.toasts :refer [toast]] - [game.macros :refer [wait-for]])) + [game.core.eid :refer [effect-completed make-eid make-result]] + [game.core.effects :refer [any-effects]] + [game.core.engine :refer [queue-event checkpoint trigger-event-sync]] + [game.core.gaining :refer [gain lose]] + [game.core.prompts :refer [clear-wait-prompt show-prompt show-wait-prompt]] + [game.core.prevention :refer [resolve-bad-pub-prevention]] + [game.core.say :refer [system-msg]] + [game.core.toasts :refer [toast]] + [game.macros :refer [wait-for]])) + +(defn bad-publicity-available + "The amount of bad publicity available for this run" + ([state side] + (if (= side :runner) + (get-in @state [:run :bad-publicity-available] 0) + 0))) (defn- resolve-bad-publicity [state side eid n {:keys [suppress-checkpoint] :as args}] @@ -30,9 +38,19 @@ (defn lose-bad-publicity ([state side n] (lose-bad-publicity state side (make-eid state) n)) - ([state side eid n] + ([state side eid n] (lose-bad-publicity state side eid n nil)) + ([state side eid n {:keys [no-event]}] (if (= n :all) (lose-bad-publicity state side eid (get-in @state [:corp :bad-publicity :base])) - (do (lose state :corp :bad-publicity n) - (trigger-event-sync state side eid :corp-lose-bad-publicity {:amount n - :side side}))))) + (let [n (min n (get-in @state [:corp :bad-publicity :base]))] + (do (lose state :corp :bad-publicity n) + (if no-event + (effect-completed state side eid) + (trigger-event-sync state side eid :corp-lose-bad-publicity {:amount n + :side side}))))))) + +(defn spend-bad-publicity + "Spend a bad pub" + [state side amt] + (when (and (= side :runner) (pos? (bad-publicity-available state side))) + (swap! state update-in [:run :bad-publicity-available] #(- % amt)))) diff --git a/src/clj/game/core/costs.clj b/src/clj/game/core/costs.clj index f52a951693..ff2a061234 100644 --- a/src/clj/game/core/costs.clj +++ b/src/clj/game/core/costs.clj @@ -1,6 +1,6 @@ (ns game.core.costs (:require - [game.core.bad-publicity :refer [gain-bad-publicity]] + [game.core.bad-publicity :refer [gain-bad-publicity bad-publicity-available]] [game.core.board :refer [all-active all-active-installed all-installed all-installed-runner-type]] [game.core.card :refer [active? agenda? corp? facedown? get-card get-counters get-zone hardware? has-subtype? ice? in-hand? installed? program? resource? rezzed? runner?]] [game.core.card-defs :refer [card-def]] @@ -139,6 +139,7 @@ [state side eid card] (if-not (any-effects state side :cannot-pay-credit) (+ (get-in @state [side :credit]) + (bad-publicity-available state side) (->> (concat (eligible-pay-credit-cards state side eid card) (eligible-reduce-credit-cards state side eid card)) (map #(+ (get-counters % :recurring) @@ -186,8 +187,9 @@ (let [updated-cost (max 0 (- (value cost) (or (:reduction async-result) 0)))] (cond (and (pos? updated-cost) - (pos? (count (provider-func)))) - (wait-for (resolve-ability state side (pick-credit-providing-cards provider-func eid updated-cost (stealth-value cost)) card nil) + (or (pos? (count (provider-func))) + (pos? (bad-publicity-available state side)))) + (wait-for (resolve-ability state side (pick-credit-providing-cards provider-func eid updated-cost (stealth-value cost) (hash-map) nil {} (bad-publicity-available state side)) card nil) (let [pay-async-result async-result] (queue-event state (if (= side :corp) :corp-spent-credits :runner-spent-credits) {:value updated-cost}) (swap! state update-in [:stats side :spent :credit] (fnil + 0) updated-cost) diff --git a/src/clj/game/core/diffs.clj b/src/clj/game/core/diffs.clj index 3d6d4e1305..a4c728936f 100644 --- a/src/clj/game/core/diffs.clj +++ b/src/clj/game/core/diffs.clj @@ -232,6 +232,8 @@ :show-discard :selectable :eid + ;; bad pub + :offer-bad-pub? ;; traces :player :base @@ -363,6 +365,7 @@ (def runner-keys [:rig :run-credit + :bad-pub-credit :link :tag :memory @@ -385,6 +388,7 @@ (update :deck deck-summary runner-player? runner) (update :hand hand-summary state runner-player? :runner runner) (update :discard prune-cards) + (assoc :bad-pub-credit (get-in @state [:run :bad-publicity-available] 0)) (assoc :deck-count (count (:deck runner)) :hand-count (count (:hand runner)) diff --git a/src/clj/game/core/pick_counters.clj b/src/clj/game/core/pick_counters.clj index bff7c1a1a0..ee9e79b68b 100644 --- a/src/clj/game/core/pick_counters.clj +++ b/src/clj/game/core/pick_counters.clj @@ -1,24 +1,26 @@ (ns game.core.pick-counters (:require - [game.core.card :refer [get-card get-counters has-subtype? installed? runner?]] - [game.core.card-defs :refer [card-def]] - [game.core.eid :refer [effect-completed make-eid complete-with-result]] - [game.core.engine :refer [resolve-ability queue-event]] - [game.core.gaining :refer [lose]] - [game.core.props :refer [add-counter]] - [game.core.update :refer [update!]] - [game.macros :refer [continue-ability req wait-for]] - [game.utils :refer [enumerate-str in-coll? quantify same-card?]])) + [game.core.bad-publicity :refer [spend-bad-publicity]] + [game.core.card :refer [get-card get-counters has-subtype? installed? runner?]] + [game.core.card-defs :refer [card-def]] + [game.core.eid :refer [effect-completed make-eid complete-with-result]] + [game.core.engine :refer [resolve-ability queue-event]] + [game.core.gaining :refer [lose]] + [game.core.props :refer [add-counter]] + [game.core.update :refer [update!]] + [game.macros :refer [continue-ability req wait-for]] + [game.utils :refer [enumerate-str in-coll? quantify same-card?]])) (defn- pick-counter-triggers - [state side eid current-cards selected-cards counter-type counter-count message] + [state side eid current-cards selected-cards counter-type counter-count message credits] (if-let [[_ selected] (first current-cards)] (if-let [{:keys [card number]} selected] (do (queue-event state :counter-added {:card (get-card state card) :counter-type counter-type :amount number}) - (pick-counter-triggers state side eid (rest current-cards) selected-cards counter-type counter-count message)) - (pick-counter-triggers state side eid (rest current-cards) selected-cards counter-type counter-count message)) + (pick-counter-triggers state side eid (rest current-cards) selected-cards counter-type counter-count message credits)) + (pick-counter-triggers state side eid (rest current-cards) selected-cards counter-type counter-count message credits)) (complete-with-result state side eid {:number counter-count :msg message + :credits-spent-from-pool credits :targets (keep #(:card (second %)) selected-cards)}))) (defn pick-virus-counters-to-spend @@ -56,7 +58,7 @@ title (:title card)] (str (quantify number "virus counter") " from " title)) (vals selected-cards)))] - (pick-counter-triggers state side eid selected-cards selected-cards :virus counter-count message))))) + (pick-counter-triggers state side eid selected-cards selected-cards :virus counter-count message 0))))) :cancel-effect (if target-count (req (doseq [{:keys [card number]} (vals selected-cards)] (update! state :runner (update-in (get-card state card) [:counter :virus] + number))) @@ -74,6 +76,11 @@ (trigger-spend-credits-from-cards state side eid (rest cards))) (effect-completed state side eid))) +(defn- queue-spend-from-bad-pub + [state side spent] + (when (and spent (pos? spent)) + (queue-event state :bad-publicity-spent {:value spent}))) + (defn- take-counters-of-type "This builds an effect to remove a single counter of the given type, including credits. This does not fire any events." [counter-type] @@ -133,8 +140,13 @@ ([provider-func outereid target-count stealth-target] (pick-credit-providing-cards provider-func outereid target-count stealth-target (hash-map))) ([provider-func outereid target-count stealth-target selected-cards] (pick-credit-providing-cards provider-func outereid target-count stealth-target selected-cards nil)) ([provider-func outereid target-count stealth-target selected-cards pre-chosen] (pick-credit-providing-cards provider-func outereid target-count stealth-target selected-cards pre-chosen {})) - ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses] - (let [counter-count (reduce + 0 (map #(:number (second %) 0) selected-cards)) + ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses] (pick-credit-providing-cards provider-func outereid target-count stealth-target selected-cards pre-chosen {} 0)) + ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses bad-pub-available] (pick-credit-providing-cards provider-func outereid target-count stealth-target selected-cards pre-chosen {} bad-pub-available 0)) + ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses bad-pub-available bad-pub-spent] + (prn "available: " bad-pub-available) + (prn "spent: " bad-pub-spent) + (let [counter-count (+ (reduce + 0 (map #(:number (second %) 0) selected-cards)) + (or bad-pub-spent 0)) selected-stealth (filter #(has-subtype? (:card (second %)) "Stealth") selected-cards) stealth-count (reduce + 0 (map #(:number (second %) 0) selected-stealth)) provider-cards (if (= (- counter-count target-count) (- stealth-count stealth-target)) @@ -145,6 +157,7 @@ provider-cards (filter #(not (get-in (card-def %) [:interactions :pay-credits :cost-reduction])) provider-cards) ;; note - this allows holding the shift key while clicking a card to keep picking that card while possible ;; ie: taking 5cr from miss bones with one click, instead of waiting for 5 server round-trips + can-use-bad-pub? (and (pos? bad-pub-available) (not= stealth-target target-count) true) should-auto-repeat? (fn [state side] (get-in @state [side :shift-key-select] nil)) pay-rest (req (if (and (<= (- target-count counter-count) (get-in @state [side :credit])) @@ -152,31 +165,37 @@ (let [remainder (max 0 (- target-count counter-count)) remainder-str (when (pos? remainder) (str remainder " [Credits]")) - card-strs (when (pos? (count selected-cards)) - (str (enumerate-str (map #(let [{:keys [card number]} % - title (:title card)] - (str number " [Credits] from " title)) - (vals selected-cards))))) + card-strs (when (or (pos? (count selected-cards)) (pos? bad-pub-spent)) + (enumerate-str (concat (mapv #(let [{:keys [card number]} % + title (:title card)] + (str number " [Credits] from " title)) + (vals selected-cards)) + (when (pos? bad-pub-spent) + [(str bad-pub-spent "[Credits] from bad publicity")])))) message (str card-strs (when (and card-strs remainder-str) " and ") remainder-str (when (and card-strs remainder-str) " from [their] credit pool"))] + (when (pos? bad-pub-spent) + (spend-bad-publicity state side bad-pub-spent)) (lose state side :credit remainder) (let [cards (->> (vals selected-cards) (map :card) (remove #(-> (card-def %) :interactions :pay-credits :cost-reduction)))] (wait-for (trigger-spend-credits-from-cards state side cards) - ; Now we trigger all of the :counter-added events we'd neglected previously - (pick-counter-triggers state side eid selected-cards selected-cards :credit target-count message)))) + (queue-spend-from-bad-pub state side bad-pub-spent) + ;; Now we trigger all of the :counter-added events we'd neglected previously + (pick-counter-triggers state side eid selected-cards selected-cards :credit target-count message remainder)))) (continue-ability state side - (pick-credit-providing-cards provider-func eid target-count stealth-target selected-cards uses) + (pick-credit-providing-cards provider-func eid target-count stealth-target selected-cards uses bad-pub-available bad-pub-spent) card nil)))] - (if (or (not (pos? target-count)) ; there is a limit - (<= target-count counter-count) ; paid everything - (zero? (count provider-cards))) ; no more additional credit sources found + (if (or (not (pos? target-count)) ;; there is a limit + (<= target-count counter-count) ;; paid everything + (and (zero? (count provider-cards)) ;; no more additional credit sources found + (not can-use-bad-pub?))) {:async true :effect pay-rest} (if (and pre-chosen (in-coll? (map :cid provider-cards) (:cid pre-chosen))) @@ -207,22 +226,33 @@ (str ", " (min stealth-count stealth-target) " of " stealth-target " stealth") "") ")") + :offer-bad-pub? (when can-use-bad-pub? bad-pub-available) :choices {:card #(in-coll? (map :cid provider-cards) (:cid %))} - :effect (req (let [pay-credits-type (-> target card-def :interactions :pay-credits :type) - pay-function (if (= :custom pay-credits-type) - (-> target card-def :interactions :pay-credits :custom) - (take-counters-of-type pay-credits-type)) - custom-ability ^:ignore-async-check {:async true - :effect pay-function} - neweid (make-eid state outereid) - providing-card target] - (wait-for (resolve-ability state side neweid custom-ability providing-card [card]) - (continue-ability state side - (pick-credit-providing-cards - provider-func eid target-count stealth-target + :effect (req (prn "target: " target) + (if (= target "Bad Publicity") + (do (prn "bad pub picked") + (continue-ability + state side + ;;[provider-func outereid target-count stealth-target selected-cards pre-chosen uses bad-pub-available] + (pick-credit-providing-cards + provider-func eid target-count stealth-target selected-cards (when (should-auto-repeat? state side) target) uses (dec bad-pub-available) (inc bad-pub-spent)) + card targets)) + (let [pay-credits-type (-> target card-def :interactions :pay-credits :type) + pay-function (if (= :custom pay-credits-type) + (-> target card-def :interactions :pay-credits :custom) + (take-counters-of-type pay-credits-type)) + custom-ability ^:ignore-async-check {:async true + :effect pay-function} + neweid (make-eid state outereid) + providing-card target] + (wait-for (resolve-ability state side neweid custom-ability providing-card [card]) + (continue-ability state side + (pick-credit-providing-cards + provider-func eid target-count stealth-target (update selected-cards (:cid providing-card) #(assoc % :card providing-card :number (+ (:number % 0) async-result))) (when (should-auto-repeat? state side) target) - (use-card uses providing-card async-result)) - card targets)))) + (use-card uses providing-card async-result) + bad-pub-available bad-pub-spent) + card targets))))) :cancel-effect pay-rest}))))) diff --git a/src/clj/game/core/player.clj b/src/clj/game/core/player.clj index 43f642a64a..7ba7f6eaea 100644 --- a/src/clj/game/core/player.clj +++ b/src/clj/game/core/player.clj @@ -85,6 +85,7 @@ click-per-turn credit run-credit + bad-pub-credit link tag properties diff --git a/src/clj/game/core/prompts.clj b/src/clj/game/core/prompts.clj index a99197ba12..eaddef9dac 100644 --- a/src/clj/game/core/prompts.clj +++ b/src/clj/game/core/prompts.clj @@ -1,6 +1,7 @@ (ns game.core.prompts (:require [clj-uuid :as uuid] + [clojure.string :as str] [game.core.board :refer [get-all-cards]] [game.core.eid :refer [effect-completed make-eid]] [game.core.prompt-state :refer [add-to-prompt-queue remove-from-prompt-queue]] @@ -32,7 +33,7 @@ ([state side card message choices f] (show-prompt state side (make-eid state) card message choices f nil)) ([state side card message choices f args] (show-prompt state side (make-eid state) card message choices f args)) ([state side eid card message choices f - {:keys [waiting-prompt prompt-type show-discard cancel-effect end-effect targets selectable]}] + {:keys [waiting-prompt prompt-type show-discard cancel-effect end-effect targets selectable offer-bad-pub?]}] (let [prompt (if (string? message) message (message state side eid card targets)) choices (choice-parser choices) selectable (update-selectable selectable choices) @@ -43,6 +44,7 @@ :effect f :card card :selectable selectable + :offer-bad-pub? offer-bad-pub? :prompt-type (or prompt-type :other) :show-discard show-discard :cancel-effect cancel-effect @@ -139,6 +141,18 @@ (cancel-effect nil) (effect-completed state side (:eid (:ability selected))))))) +(defn resolve-select-special! + "Resolves a selection prompt by invoking the prompt's ability with the targeted cards. + Called when the user clicks 'Done' or selects the :max number of cards." + [state side card args update! resolve-ability button] + (let [selected (get-in @state [side :selected 0]) + cards (map #(dissoc % :selected) (:cards selected)) + prompt (first (filter #(= :select (:prompt-type %)) (get-in @state [side :prompt])))] + (swap! state update-in [side :selected] #(vec (rest %))) + (when prompt + (remove-from-prompt-queue state side prompt)) + (resolve-ability state side (:ability selected) card [button]))) + (defn- compute-selectable [state side card ability req-fn card-fn] (let [valid (filter #(not= (:zone %) [:deck]) (get-all-cards state)) @@ -191,27 +205,31 @@ (str " " (pluralize "target" min-choices)) " a target")) " for " (:title card))) - (if all ["Hide"] ["Done"]) + (concat (when (:offer-bad-pub? ability) [(str "Bad Publicity (" (:offer-bad-pub? ability) " available)")]) + (if all ["Hide"] ["Done"])) (if all (fn [_] ; "Hide" was selected. Show toast and reapply select prompt. This allows players to access ; prompts that lie "beneath" the current select prompt. (toast state side (str "You must choose " max-choices " " (pluralize "card" max-choices))) (show-select state side card ability update! resolve-ability args)) - (fn [_] + (fn [s] (let [selected (or (first-selection-by-eid state side (:eid ability)) (get-in @state [side :selected 0])) cards (map #(dissoc % :selected) (:cards selected))] - ; check for :min. If not enough cards are selected, show toast and stay in select prompt - (if (and min-choices (< (count cards) min-choices)) - (do - (toast state side (str "You must choose at least " min-choices " " (pluralize "card" min-choices))) - (show-select state side card ability update! resolve-ability args)) - (resolve-select state side (:eid ability) card - (select-keys (wrap-function args :cancel-effect) [:cancel-effect]) - update! resolve-ability))))) + ;; check for :min. If not enough cards are selected, show toast and stay in select prompt + (if (and s (str/starts-with? (:value s) "Bad Publicity")) ;; this is an evil hack + (resolve-select-special! state side card ability update! resolve-ability "Bad Publicity") + (if (and min-choices (< (count cards) min-choices)) + (do + (toast state side (str "You must choose at least " min-choices " " (pluralize "card" min-choices))) + (show-select state side card ability update! resolve-ability args)) + (resolve-select state side (:eid ability) card + (select-keys (wrap-function args :cancel-effect) [:cancel-effect]) + update! resolve-ability)))))) (-> args (assoc :prompt-type :select + :offer-bad-pub? (:offer-bad-pub? ability) :selectable selectable-cards :show-discard (:show-discard ability)) (wrap-function :cancel-effect))))))) diff --git a/src/clj/game/core/runs.clj b/src/clj/game/core/runs.clj index c501461340..159121615e 100644 --- a/src/clj/game/core/runs.clj +++ b/src/clj/game/core/runs.clj @@ -159,8 +159,8 @@ (wait-for (gain-run-credits state side (make-eid state eid) - (+ (or (get-in @state [:runner :next-run-credit]) 0) - (count-bad-pub state))) + (get-in @state [:runner :next-run-credit] 0)) + (swap! state assoc-in [:run :bad-publicity-available] (count-bad-pub state)) (swap! state assoc-in [:runner :next-run-credit] 0) (swap! state update-in [:runner :register :made-run] conj (first s)) (swap! state update-in [:stats side :runs :started] (fnil inc 0)) diff --git a/src/cljs/nr/gameboard/player_stats.cljs b/src/cljs/nr/gameboard/player_stats.cljs index 291cdf19e7..10d45aa2f8 100644 --- a/src/cljs/nr/gameboard/player_stats.cljs +++ b/src/cljs/nr/gameboard/player_stats.cljs @@ -70,9 +70,10 @@ (defmethod stats-area "Runner" [runner] (let [ctrl (stat-controls-for-side :runner)] (fn [] - (let [{:keys [click credit run-credit memory link tag brain-damage]} @runner + (let [{:keys [click credit run-credit bad-pub-credit memory link tag brain-damage]} @runner base-credit (- credit run-credit) - plus-run-credit (when (pos? run-credit) (str "+" run-credit)) + plus-run-credit (when (or (pos? run-credit) (pos? bad-pub-credit)) + (str "+" (+ bad-pub-credit run-credit))) icons? (get-in @app-state [:options :player-stats-icons] true)] [:div.stats-area (if icons? From 9ba9173ce374944b5bc7ae72ab221644d8f9fd4c Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 15 Feb 2026 19:12:41 +1300 Subject: [PATCH 06/48] unit tests updated for the bad publicity rule change --- src/clj/game/core/pick_counters.clj | 17 ++++------- test/clj/game/cards/events_test.clj | 41 ++++++++++++++------------- test/clj/game/cards/hardware_test.clj | 3 +- test/clj/game/cards/ice_test.clj | 1 + test/clj/game/cards/upgrades_test.clj | 12 ++++---- test/clj/game/core/rules_test.clj | 3 ++ test/clj/game/test_framework.clj | 4 +++ 7 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/clj/game/core/pick_counters.clj b/src/clj/game/core/pick_counters.clj index ee9e79b68b..6be7b6dc54 100644 --- a/src/clj/game/core/pick_counters.clj +++ b/src/clj/game/core/pick_counters.clj @@ -143,8 +143,6 @@ ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses] (pick-credit-providing-cards provider-func outereid target-count stealth-target selected-cards pre-chosen {} 0)) ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses bad-pub-available] (pick-credit-providing-cards provider-func outereid target-count stealth-target selected-cards pre-chosen {} bad-pub-available 0)) ([provider-func outereid target-count stealth-target selected-cards pre-chosen uses bad-pub-available bad-pub-spent] - (prn "available: " bad-pub-available) - (prn "spent: " bad-pub-spent) (let [counter-count (+ (reduce + 0 (map #(:number (second %) 0) selected-cards)) (or bad-pub-spent 0)) selected-stealth (filter #(has-subtype? (:card (second %)) "Stealth") selected-cards) @@ -228,15 +226,12 @@ ")") :offer-bad-pub? (when can-use-bad-pub? bad-pub-available) :choices {:card #(in-coll? (map :cid provider-cards) (:cid %))} - :effect (req (prn "target: " target) - (if (= target "Bad Publicity") - (do (prn "bad pub picked") - (continue-ability - state side - ;;[provider-func outereid target-count stealth-target selected-cards pre-chosen uses bad-pub-available] - (pick-credit-providing-cards - provider-func eid target-count stealth-target selected-cards (when (should-auto-repeat? state side) target) uses (dec bad-pub-available) (inc bad-pub-spent)) - card targets)) + :effect (req (if (= target "Bad Publicity") + (continue-ability + state side + (pick-credit-providing-cards + provider-func eid target-count stealth-target selected-cards (when (should-auto-repeat? state side) target) uses (dec bad-pub-available) (inc bad-pub-spent)) + card targets) (let [pay-credits-type (-> target card-def :interactions :pay-credits :type) pay-function (if (= :custom pay-credits-type) (-> target card-def :interactions :pay-credits :custom) diff --git a/test/clj/game/cards/events_test.clj b/test/clj/game/cards/events_test.clj index a779984ec8..214947d9c2 100644 --- a/test/clj/game/cards/events_test.clj +++ b/test/clj/game/cards/events_test.clj @@ -39,23 +39,21 @@ ;; New Angeles City Hall interaction (do-game (new-game {:runner {:deck ["Account Siphon" - "New Angeles City Hall"]}}) - (core/gain state :corp :bad-publicity 1) - (is (= 1 (count-bad-pub state)) "Corp has 1 bad publicity") - (core/lose state :runner :credit 1) - (is (= 4 (:credit (get-runner))) "Runner has 4 credits") - (take-credits state :corp) ; pass to runner's turn by taking credits - (is (= 8 (:credit (get-corp))) "Corp has 8 credits") + "New Angeles City Hall"] + :credits 4} + :corp {:bad-pub 1}}) + (take-credits state :corp) (play-from-hand state :runner "New Angeles City Hall") (is (= 3 (:credit (get-runner))) "Runner has 3 credits") - (let [nach (get-resource state 0)] - (play-run-event state "Account Siphon" :hq) - (click-prompt state :runner "Account Siphon") - (is (= 4 (:credit (get-runner))) "Runner still has 4 credits due to BP") - (click-prompt state :runner "New Angeles City Hall") - (click-prompt state :runner "Yes") - (is (= 2 (:credit (get-runner))) "Runner has 2 credits left") - (click-prompt state :runner "Yes")) + (play-run-event state "Account Siphon" :hq) + (click-prompt state :runner "Account Siphon") + (is (changed? [(:credit (get-runner)) -1] + (click-prompt state :runner "New Angeles City Hall") + (click-prompt state :runner "Yes") + (select-bad-pub state 1)) + "Spent 1 + 1 from bad pub") + (is (= 2 (:credit (get-runner))) "Runner has 2 credits left") + (click-prompt state :runner "Yes") (is (zero? (count-tags state)) "Runner did not take any tags") (is (= 10 (:credit (get-runner))) "Runner gained 10 credits") (is (= 3 (:credit (get-corp))) "Corp lost 5 credits"))) @@ -1681,7 +1679,7 @@ (play-from-hand state :runner "Investigative Journalism") (is (= "Investigative Journalism" (:title (get-resource state 1))) "IJ able to be installed") (run-on state "HQ") - (is (= 1 (:run-credit (get-runner))) "1 run credit from bad publicity") + (is (= 1 (:bad-publicity-available (:run @state))) "1 run credit from bad publicity") (run-jack-out state) (play-from-hand state :runner "Activist Support") (take-credits state :runner) @@ -6500,13 +6498,14 @@ (deftest rumor-mill-full-test ;; Full test (do-game - (new-game {:corp {:deck [(qty "Project Atlas" 2) + (new-game {:corp {:hand [(qty "Project Atlas" 2) "Caprice Nisei" "Chairman Hiro" "Cybernetics Court" "Elizabeth Mills" "Ibrahim Salem" - "Housekeeping" "Director Haas" "Oberth Protocol"]} + "Housekeeping" "Director Haas" "Oberth Protocol"] + :credits 100 + :bad-pub 1} :runner {:deck ["Rumor Mill"]}}) - (core/gain state :corp :credit 100 :click 100 :bad-publicity 1) - (draw state :corp 100) + (core/gain state :corp :click 100) (play-from-hand state :corp "Caprice Nisei" "New remote") (play-from-hand state :corp "Chairman Hiro" "New remote") (play-from-hand state :corp "Cybernetics Court" "New remote") @@ -6542,9 +6541,11 @@ ;; Trashable execs (run-empty-server state :remote2) (click-prompt state :runner "Pay 6 [Credits] to trash") + (select-bad-pub state 1) (is (empty? (:scored (get-runner))) "Chairman Hiro not added to runner's score area") (run-empty-server state "R&D") (click-prompt state :runner "Pay 5 [Credits] to trash") + (select-bad-pub state 1) (is (empty? (:scored (get-runner))) "Director Haas not added to runner's score area") (take-credits state :runner) ;; Trash RM, make sure everything works again diff --git a/test/clj/game/cards/hardware_test.clj b/test/clj/game/cards/hardware_test.clj index 8202d07f26..f6bc916b66 100644 --- a/test/clj/game/cards/hardware_test.clj +++ b/test/clj/game/cards/hardware_test.clj @@ -1314,6 +1314,7 @@ (click-card state :corp "Hostile Takeover") (run-continue state) (card-ability state :runner (get-program state 0) 2) + (select-bad-pub state 1) (card-ability state :runner (get-program state 0) 2) (card-ability state :runner (get-program state 0) 0) (click-prompt state :runner "Gain 2 [Credits]") @@ -1932,7 +1933,7 @@ (is (:run @state) "New run started") (run-continue state) (is (= [:rd] (:server (:run @state))) "Running on R&D") - (is (= 1 (:run-credit (get-runner))) "Runner has 1 BP credit"))) + (is (= 1 (:bad-publicity-available (:run @state))) "Runner has 1 BP credit"))) (deftest doppelganger-makers-eye-interaction ;; Makers eye interaction diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index 9577016231..e452b821d4 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -7948,6 +7948,7 @@ (run-continue state) (card-ability state :runner (get-program state 0) 0) (click-prompt state :runner "End the run") + (select-bad-pub state 1) (run-continue state) (click-prompt state :corp "Yes") (click-card state :corp "Ice Wall") diff --git a/test/clj/game/cards/upgrades_test.clj b/test/clj/game/cards/upgrades_test.clj index b8d3680f1a..a12273a110 100644 --- a/test/clj/game/cards/upgrades_test.clj +++ b/test/clj/game/cards/upgrades_test.clj @@ -1803,7 +1803,7 @@ (deftest giordano-memorial-field ;; Giordano Memorial Field (do-game - (new-game {:corp {:deck ["Giordano Memorial Field" "Hostile Takeover"]} + (new-game {:corp {:deck ["Giordano Memorial Field" "Greenmail"]} :runner {:deck [(qty "Fan Site" 3)]}}) (play-from-hand state :corp "Giordano Memorial Field" "New remote") (rez state :corp (get-content state :remote1 0)) @@ -1812,7 +1812,7 @@ (play-from-hand state :runner "Fan Site") (play-from-hand state :runner "Fan Site") (take-credits state :runner) - (play-and-score state "Hostile Takeover") + (play-and-score state "Greenmail") (take-credits state :corp) (run-empty-server state "Server 1") (let [credits (:credit (get-runner))] @@ -1827,8 +1827,9 @@ (deftest giordano-memorial-field-payable-with-net-mercur ;; Payable with net mercur (do-game - (new-game {:corp {:deck ["Giordano Memorial Field" "Hostile Takeover"]} - :runner {:deck [(qty "Fan Site" 3) "Net Mercur"]}}) + (new-game {:corp {:deck ["Giordano Memorial Field" "Greenmail"]} + :runner {:deck [(qty "Fan Site" 3) "Net Mercur"] + :credits 6}}) (play-from-hand state :corp "Giordano Memorial Field" "New remote") (rez state :corp (get-content state :remote1 0)) (take-credits state :corp) @@ -1837,7 +1838,7 @@ (play-from-hand state :runner "Fan Site") (play-from-hand state :runner "Net Mercur") (take-credits state :runner) - (play-and-score state "Hostile Takeover") + (play-and-score state "Greenmail") (take-credits state :corp) (let [nm (get-resource state 0)] (core/command-counter state :runner '("c" "3")) @@ -2039,6 +2040,7 @@ (click-prompt state :runner "Take 1 tag") (is (= 1 (count-tags state)) "Runner takes 1 tag to prevent Corp from removing 1 BP") (click-prompt state :runner "Pay 2 [Credits] to trash") ; trash + (select-bad-pub state 1) (run-empty-server state "Archives") (is (= 1 (count-bad-pub state))) (click-prompt state :runner "The Corp removes 1 bad publicity") diff --git a/test/clj/game/core/rules_test.clj b/test/clj/game/core/rules_test.clj index a4dbf48b49..b97aab617a 100644 --- a/test/clj/game/core/rules_test.clj +++ b/test/clj/game/core/rules_test.clj @@ -378,14 +378,17 @@ (run-empty-server state :remote1) (click-prompt state :corp "No") (click-prompt state :runner "Pay 1 [Credits] to trash") + (select-bad-pub state 1) (is (= 5 (:credit (get-runner))) "1 BP credit spent to trash CVS") (run-empty-server state :hq) (click-prompt state :corp "No") (click-prompt state :runner "Pay 1 [Credits] to trash") + (select-bad-pub state 1) (is (= 5 (:credit (get-runner))) "1 BP credit spent to trash CVS") (run-empty-server state :rd) (click-prompt state :corp "No") (click-prompt state :runner "Pay 1 [Credits] to trash") + (select-bad-pub state 1) (is (= 5 (:credit (get-runner))) "1 BP credit spent to trash CVS"))) (deftest run-psi-bad-publicity-credits diff --git a/test/clj/game/test_framework.clj b/test/clj/game/test_framework.clj index 08c626c6a8..5b1128c57d 100644 --- a/test/clj/game/test_framework.clj +++ b/test/clj/game/test_framework.clj @@ -230,6 +230,10 @@ [state cost] (click-prompt state :runner (str "Pay " cost " [Credits] to trash"))) +(defn select-bad-pub + [state expected] + (click-prompt state :runner (str "Bad Publicity (" expected " available)"))) + ;; General utilities necessary for starting a new game (defn find-card "Copied from core so we can check printed title too" From ce3d1e204412b6287c6dd73db813d01d57c5c661 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 16 Feb 2026 07:05:22 +1300 Subject: [PATCH 07/48] Editorial Division, Nihilo Agent, Vulture Fund --- src/clj/game/cards/assets.clj | 21 ++++++++++++++++++++- src/clj/game/cards/identities.clj | 15 +++++++++++++++ src/clj/game/cards/operations.clj | 6 ++++++ test/clj/game/cards/assets_test.clj | 18 ++++++++++++++++++ test/clj/game/cards/identities_test.clj | 9 +++++++++ test/clj/game/cards/operations_test.clj | 7 +++++++ 6 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/clj/game/cards/assets.clj b/src/clj/game/cards/assets.clj index cbc757123c..38be1207c8 100644 --- a/src/clj/game/cards/assets.clj +++ b/src/clj/game/cards/assets.clj @@ -54,7 +54,7 @@ [game.core.set-aside :refer [swap-set-aside-cards]] [game.core.shuffling :refer [shuffle! shuffle-into-deck shuffle-into-rd-effect]] - [game.core.tags :refer [gain-tags]] + [game.core.tags :refer [gain-tags lose-tags]] [game.core.threat :refer [threat-level]] [game.core.to-string :refer [card-str]] [game.core.toasts :refer [toast]] @@ -2239,6 +2239,25 @@ (do (as-agenda state :runner card -1) (effect-completed state side eid))))}}) +(defcard "Nihilo Agent" + {:data {:counter {:power 3}} + :events [(trash-on-empty :power) + {:event :corp-turn-ends + :msg "take 1 bad publicity and give the Runner 1 tag" + :async true + :effect (req (wait-for + (gain-bad-publicity state :corp 1 {:suppress-checkpoint true}) + (wait-for + (add-counter state side card :power -1 {:suppress-checkpoint true}) + (gain-tags state side eid 1))))} + {:event :corp-turn-begins + :change-in-game-state {:silent true + :req (req (or tagged (pos? (count-bad-pub state))))} + :msg "remove 1 bad publicity and 1 tag" + :async true + :effect (req (wait-for (lose-bad-publicity state :corp 1 {:suppress-checkpoint true}) + (lose-tags state side eid 1)))}]}) + (defcard "Open Forum" {:events [{:event :corp-mandatory-draw :interactive (req true) diff --git a/src/clj/game/cards/identities.clj b/src/clj/game/cards/identities.clj index d5ca54e836..6c67e5d7d7 100644 --- a/src/clj/game/cards/identities.clj +++ b/src/clj/game/cards/identities.clj @@ -755,6 +755,21 @@ :req (req (:flipped card)) :effect flip-effect}]})) +(defcard "Editorial Division: Ad Nihilum" + {:events [{:event :corp-gain-bad-publicity + :optional {:req (req (let [valid-ctx? (fn [[ctx]] (pos? (:amount ctx)))] + (and (valid-ctx? targets) + (first-event? state side :corp-gain-bad-publicity valid-ctx?)))) + :prompt "Search for a card?" + :waiting-prompt true + :yes-ability {:prompt "Choose a card" + :msg (msg "add " (:title target) " to HQ from R&D") ;; TODO - once the illicit->liability change is through, we can adjust this (or just leave it) + :choices (req (cancellable (filter #(has-any-subtype? % ["Illicit" "Black Ops" "Gray Ops" "Liability"]) (filter (complement agenda?) (:deck corp))) :sorted)) + :cancel-effect (effect (system-msg (str "shuffles R&D")) + (shuffle! :deck)) + :effect (effect (shuffle! :deck) + (move target :hand))}}}]}) + (defcard "Edward Kim: Humanity's Hammer" {:events [{:event :access :req (req (and (operation? target) diff --git a/src/clj/game/cards/operations.clj b/src/clj/game/cards/operations.clj index d93f7a1251..b2c3878608 100644 --- a/src/clj/game/cards/operations.clj +++ b/src/clj/game/cards/operations.clj @@ -3319,6 +3319,12 @@ {:psi {:req (req (seq (:scored runner))) :not-equal (trash-type "resource" resource? :loud)}}}) +(defcard "Vulture Fund" + {:on-play {:msg "gain 14 [Credits] and take 1 bad publicity" + :async true + :effect (req (wait-for (gain-credits state side 14 {:suppress-checkpoint true}) + (gain-bad-publicity state side eid 1)))}}) + (defcard "Wake Up Call" {:on-play {:rfg-instead-of-trashing true diff --git a/test/clj/game/cards/assets_test.clj b/test/clj/game/cards/assets_test.clj index a318f0403d..cc52a2e2c8 100644 --- a/test/clj/game/cards/assets_test.clj +++ b/test/clj/game/cards/assets_test.clj @@ -4100,6 +4100,24 @@ (take-credits state :runner)) "Drew 2 cards -> mandatory + nico trash effect")))) +(deftest nihilo-agent + (do-game + (new-game {:corp {:hand ["Nihilo Agent"]}}) + (play-from-hand state :corp "Nihilo Agent" "New remote") + (rez state :corp (get-content state :remote1 0)) + (dotimes [n 3] + (is (not (jinteki.utils/is-tagged? state)) "Not tagged") + (take-credits state :corp) + (start-turn state :runner) + (is (= 1 (count-bad-pub state)) "Took 1 bad pub") + (is (jinteki.utils/is-tagged? state) "tagged") + (take-credits state :runner) + (when-not (= n 2) + (is (= 0 (count-bad-pub state)) "lost 1 bad pub") + (is (not (jinteki.utils/is-tagged? state)) "untagged again"))) + (is (= 1 (count-bad-pub state)) "Took 1 bad pub") + (is (jinteki.utils/is-tagged? state) "tagged"))) + (deftest open-forum ;; Open Forum (do-game diff --git a/test/clj/game/cards/identities_test.clj b/test/clj/game/cards/identities_test.clj index 3226923744..3bb079a00e 100644 --- a/test/clj/game/cards/identities_test.clj +++ b/test/clj/game/cards/identities_test.clj @@ -1441,6 +1441,15 @@ (click-card state :corp "NASX") (is (= "NASX" (:title (first (:hosted (get-content state :remote1 0)))))))) +(deftest editorial-division-ad-nihilum + (do-game + (new-game {:corp {:id "Editorial Division: Ad Nihilum" + :hand ["Too Big to Fail"] + :deck ["Closed Accounts"]}}) + (play-from-hand state :corp "Too Big to Fail") + (click-prompts state :corp "Yes" "Closed Accounts") + (is-hand? state :corp ["Closed Accounts"]))) + (deftest edward-kim-humanity-s-hammer-trash-first-operation-accessed-each-turn-but-not-if-first-one-was-in-archives ;; Trash first operation accessed each turn, but not if first one was in Archives (do-game diff --git a/test/clj/game/cards/operations_test.clj b/test/clj/game/cards/operations_test.clj index 7097e85f7c..a62f284102 100644 --- a/test/clj/game/cards/operations_test.clj +++ b/test/clj/game/cards/operations_test.clj @@ -5622,6 +5622,13 @@ (click-card state :corp "Kati Jones") (is (not (get-resource state 0)) "Kati Jones is trashed"))) +(deftest vulture-fund-test + (do-game + (new-game {:corp {:hand ["Vulture Fund"] :credits 7}}) + (is (changed? [(:credit (get-corp)) 7 + (count-bad-pub state) 1] + (play-from-hand state :corp "Vulture Fund"))))) + (deftest wake-up-call-should-fire-after-using-en-passant-to-trash-ice ;; should fire after using En Passant to trash ice (do-game From 50d979c25614460a1a7da42b73614e161169557a Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Tue, 17 Feb 2026 06:54:23 +1300 Subject: [PATCH 08/48] hiram 0mission svensson --- src/clj/game/cards/identities.clj | 12 ++++++++++++ test/clj/game/cards/identities_test.clj | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/clj/game/cards/identities.clj b/src/clj/game/cards/identities.clj index 6c67e5d7d7..9914715407 100644 --- a/src/clj/game/cards/identities.clj +++ b/src/clj/game/cards/identities.clj @@ -1054,6 +1054,18 @@ :prompt-type :bogus})) card nil))}]}) +(defcard "Hiram \"0mission\" Svensson: Shadow of the Past" + (let [scry {:change-in-game-state {:silent (req true) + :req (req (seq (:deck corp)))} + :msg "look at the top card of R&D" + :interactive (req true) + :skippable true + :waiting-prompt true + :prompt (msg "The top card of R&D is " (:title (first (:deck corp)))) + :choices ["Noted"]}] + {:events [(assoc scry :event :runner-install :req (req (hardware? (:card context)))) + (assoc scry :event :runner-trash :req (req (some hardware? (map :card targets))))]})) + (defcard "Hoshiko Shiro: Untold Protagonist" (let [flip-effect (req (update! state side (if (:flipped card) (assoc card diff --git a/test/clj/game/cards/identities_test.clj b/test/clj/game/cards/identities_test.clj index 3bb079a00e..a2150edff8 100644 --- a/test/clj/game/cards/identities_test.clj +++ b/test/clj/game/cards/identities_test.clj @@ -2379,6 +2379,19 @@ (card-ability state :runner (get-resource state 0) 0) (is (no-prompt? state :corp) "No Hayley wait prompt for facedown installs."))) +(deftest hiram-0mission-svensson-shadow-of-the-past + (do-game + (new-game {:corp {:deck ["IPO"] :hand ["Beanstalk Royalties"]} + :runner {:id "Hiram \"0mission\" Svensson: Shadow of the Past" + :hand ["Sports Hopper"]}}) + (play-from-hand state :corp "Beanstalk Royalties") + (is (no-prompt? state :runner)) + (take-credits state :corp) + (play-from-hand state :runner "Sports Hopper") + (click-prompt state :runner "Noted") + (card-ability state :runner (get-hardware state 0) 0) + (click-prompt state :runner "Noted"))) + (deftest hoshiko-shiro-untold-protagonist-id-ability ;; ID ability (do-game From dcd0065999a6648953675b91bd9f82e9a71795b8 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Tue, 17 Feb 2026 06:59:34 +1300 Subject: [PATCH 09/48] qol for meiles u --- src/clj/game/cards/identities.clj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/clj/game/cards/identities.clj b/src/clj/game/cards/identities.clj index 9914715407..7a1003e672 100644 --- a/src/clj/game/cards/identities.clj +++ b/src/clj/game/cards/identities.clj @@ -1515,11 +1515,13 @@ :effect (effect (update! (assoc card :face :front - :melies-target (first (shuffle ["HQ" "R&D" "Archives"])))))} + :melies-target (first (shuffle ["HQ" "R&D" "Archives"])))) + (system-msg "reveals that the three hidden faces of Méliès U: Only the Brightest are: Tenure Floors: Méliès U, Subsurface Labs: Méliès U, and Disposal Grounds: Méliès U"))} ;; When your turn ends, you secretly choose a server {:event :corp-turn-ends :prompt "Choose a server" :interactive (req true) + :waiting-prompt true :choices ["HQ" "R&D" "Archives"] :msg (msg "secretly choose a server") :effect (req (update! state side (assoc card :melies-target target)))} From 23dac31668eb2448cc7f4724b85bb921da2f525a Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Tue, 17 Feb 2026 18:40:16 +1300 Subject: [PATCH 10/48] side doing the damage trashes the cards --- src/clj/game/core/damage.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clj/game/core/damage.clj b/src/clj/game/core/damage.clj index fb42258c19..0fb4063cdb 100644 --- a/src/clj/game/core/damage.clj +++ b/src/clj/game/core/damage.clj @@ -77,7 +77,7 @@ (when (= dmg-type :brain) (swap! state update-in [:runner :brain-damage] #(+ % n))) (when-let [trashed-msg (enumerate-cards cards-trashed :sorted)] - (system-msg state :runner (str "trashes " trashed-msg " due to " (damage-name dmg-type) " damage")) + (system-msg state side (str "trashes " trashed-msg " due to " (damage-name dmg-type) " damage")) (swap! state update-in [:stats :corp :damage :all] (fnil + 0) n) (swap! state update-in [:stats :corp :damage dmg-type] (fnil + 0) n) (if (< (count hand) n) From 81e034aa599e66873bbf5c2ae8a079247ca72016 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 18 Feb 2026 14:48:36 +1300 Subject: [PATCH 11/48] let them dream, melies city luxury line --- src/clj/game/cards/agendas.clj | 39 +++++++++++++++++++++++++++- src/clj/game/cards/identities.clj | 5 ++-- test/clj/game/cards/agendas_test.clj | 39 ++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/clj/game/cards/agendas.clj b/src/clj/game/cards/agendas.clj index 880d886c3c..28974a63e1 100644 --- a/src/clj/game/cards/agendas.clj +++ b/src/clj/game/cards/agendas.clj @@ -43,7 +43,7 @@ [game.core.prompts :refer [cancellable clear-wait-prompt show-wait-prompt]] [game.core.props :refer [add-counter add-prop]] [game.core.purging :refer [purge]] - [game.core.revealing :refer [reveal]] + [game.core.revealing :refer [reveal reveal-loud]] [game.core.rezzing :refer [derez rez rez-multiple-cards]] [game.core.runs :refer [clear-encounter end-run get-current-encounter force-ice-encounter redirect-run start-next-phase]] [game.core.say :refer [play-sfx system-msg]] @@ -1304,6 +1304,38 @@ :duration :end-of-run}) (effect-completed state side eid)))}}]}) +(defcard "Let Them Dream" + (letfn [(move-to [c from] + (choose-one-helper + (let [and-then (fn [s] (str (if (= from :rd) ", shuffle R&D, and then " " and ") s))] + {:prompt (str "Move " (:title c) " where?")} + [{:option "HQ" + :ability {:async true + :effect (req (when (= from :rd) (shuffle! state side :deck)) + (move state side c :hand) + (reveal-loud state side eid card {:and-then (and-then "add it to HQ")} c))}} + {:option "Bottom of R&D" + :ability {:async true + :effect (req (when (= from :rd) (shuffle! state side :deck)) + (move state side c :deck) + (reveal-loud state side eid card {:and-then (and-then "add it to the bottom of R&D")} c))}}]))) + (find-ab [zone] + {:prompt "Choose an agenda" + :show-discard (= zone :archives) + :choices (if (= zone :rd) + (req (cancellable (filter agenda? (:deck corp)) :sorted)) + {:card #(and (agenda? %) (if (= zone :hq) (in-hand? %) (in-discard? %)))}) + :effect (req (continue-ability state side (move-to target zone) card nil)) + :async true + :cancel-effect shuffle-my-deck!})] + {:on-score (choose-one-helper + {:optional true + :prompt "Search for an Agenda from where?"} + [{:option "HQ" :ability (find-ab :hq)} + {:option "R&D" :ability (find-ab :rd)} + {:option "Archives" :ability (find-ab :archives)}]) + :agendapoints-runner (req 1)})) + (defcard "License Acquisition" {:on-score {:interactive (req true) :prompt "Choose an asset or upgrade to install from Archives or HQ" @@ -1432,6 +1464,11 @@ :req (req (= (:title target) "Medical Breakthrough")) :value -1}]}) +(defcard "Méliès City Luxury Line" + {:steal-cost-bonus (req [(->c :click 1)]) + :on-score {:msg "gain [Click]" + :effect (req (gain-clicks state :corp 1))}}) + (defcard "Megaprix Qualifier" {:on-score {:silent (req true) :req (req (< 1 (count (filter #(= (:title %) "Megaprix Qualifier") diff --git a/src/clj/game/cards/identities.clj b/src/clj/game/cards/identities.clj index 7a1003e672..6f550d56ab 100644 --- a/src/clj/game/cards/identities.clj +++ b/src/clj/game/cards/identities.clj @@ -52,7 +52,7 @@ [game.core.say :refer [system-msg]] [game.core.servers :refer [central->name is-central? is-remote? name-zone target-server zone->name]] - [game.core.shuffling :refer [shuffle! shuffle-into-deck shuffle-cards-into-deck!]] + [game.core.shuffling :refer [shuffle! shuffle-into-deck shuffle-cards-into-deck! shuffle-my-deck!]] [game.core.tags :refer [gain-tags lose-tags]] [game.core.to-string :refer [card-str]] [game.core.toasts :refer [toast]] @@ -765,8 +765,7 @@ :yes-ability {:prompt "Choose a card" :msg (msg "add " (:title target) " to HQ from R&D") ;; TODO - once the illicit->liability change is through, we can adjust this (or just leave it) :choices (req (cancellable (filter #(has-any-subtype? % ["Illicit" "Black Ops" "Gray Ops" "Liability"]) (filter (complement agenda?) (:deck corp))) :sorted)) - :cancel-effect (effect (system-msg (str "shuffles R&D")) - (shuffle! :deck)) + :cancel shuffle-my-deck! :effect (effect (shuffle! :deck) (move target :hand))}}}]}) diff --git a/test/clj/game/cards/agendas_test.clj b/test/clj/game/cards/agendas_test.clj index 9bff1a4d2a..72f2938463 100644 --- a/test/clj/game/cards/agendas_test.clj +++ b/test/clj/game/cards/agendas_test.clj @@ -2272,6 +2272,35 @@ (click-prompt state :corp "Done")) (str "Corp drew " n " cards"))))) +(deftest let-them-dream + (doseq [[from agenda] [["HQ" "Project Atlas"] ["R&D" "Ikawah Project"] ["Archives" "Project Kusanagi"]] + to ["HQ" "Bottom of R&D"]] + (do-game + (new-game {:corp {:hand ["Let Them Dream" "Project Atlas"] + :deck [(qty "IPO" 15) "Ikawah Project"] + :discard ["Project Kusanagi"]}}) + (play-and-score state "Let Them Dream") + (click-prompts state :corp "Yes" from agenda to) + (case to + :hand (is-hand? state :corp [agenda]) + :deck (is (= (:title (last (:deck (get-corp)))) agenda)))))) + +(deftest let-them-dream-points + ;; Global Food Initiative + (do-game + (new-game {:corp {:deck [(qty "Let Them Dream" 2)]}}) + (testing "Corp scores" + (is (zero? (:agenda-point (get-runner))) "Runner should start with 0 agenda points") + (is (zero? (:agenda-point (get-corp))) "Corp should start with 0 agenda points") + (play-and-score state "Let Them Dream") + (click-prompt state :corp "No") + (is (= 2 (:agenda-point (get-corp))) "Corp should gain 2 agenda points")) + (testing "Runner steals" + (play-from-hand state :corp "Let Them Dream" "New remote") + (take-credits state :corp) + (run-empty-server state :remote2) + (click-prompt state :runner "Steal") + (is (= 1 (:agenda-point (get-runner))) "Runner should gain 1 agenda points, not 2")))) (deftest license-acquisition ;; License Acquisition @@ -2592,6 +2621,16 @@ (is (= 7 (:agenda-point (get-corp))) "Corp at 7 points") (is (= :corp (:winner @state)) "Corp has won"))) +(deftest melies-city-luxury-line + (do-game + (new-game {:corp {:hand [(qty "Méliès City Luxury Line" 2)]}}) + ;; play and score spends 1 click + (is (changed? [(:click (get-corp)) 0] + (play-and-score state "Méliès City Luxury Line"))) + (take-credits state :corp) + (run-empty-server state :hq) + (click-prompt state :runner "Pay to steal"))) + (deftest merger ;; Merger (do-game From 4b688d1425ec756249f84d4aa002e06221705937 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 18 Feb 2026 14:51:21 +1300 Subject: [PATCH 12/48] startup validation is three 3+pointers nows --- src/cljc/jinteki/validator.cljc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cljc/jinteki/validator.cljc b/src/cljc/jinteki/validator.cljc index 3c3cb79734..0e4de60b07 100644 --- a/src/cljc/jinteki/validator.cljc +++ b/src/cljc/jinteki/validator.cljc @@ -292,14 +292,14 @@ :reason (str "Illegal identity: " id)}))) (defn startup-agenda-restriction - "As of 25.04, startup decks may only have 4 agendas worth 3 or more points" + "As of 25.04, startup decks may only have 3 agendas worth 3 or more points" [fmt {:keys [cards] :as deck}] (if (= :startup fmt) (let [relevant-agenda (fn [c] (and (= (:type (:card c)) "Agenda") (>= (:agendapoints (:card c)) 3))) relevant-agendas (filter relevant-agenda cards) ct (reduce + 0 (map :qty relevant-agendas))] - (if (> ct 4) + (if (> ct 3) {:reason "Too many agendas worth 3 or more points (startup restriction)"} {:legal true})) {:legal true})) From 676265066b9d9c3ae6e39b1fdc664894e765020d Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 18 Feb 2026 17:34:31 +1300 Subject: [PATCH 13/48] fixed tests --- test/clj/game/cards/agendas_test.clj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/clj/game/cards/agendas_test.clj b/test/clj/game/cards/agendas_test.clj index 72f2938463..272d82e7cd 100644 --- a/test/clj/game/cards/agendas_test.clj +++ b/test/clj/game/cards/agendas_test.clj @@ -2280,10 +2280,10 @@ :deck [(qty "IPO" 15) "Ikawah Project"] :discard ["Project Kusanagi"]}}) (play-and-score state "Let Them Dream") - (click-prompts state :corp "Yes" from agenda to) + (click-prompts state :corp from agenda to) (case to - :hand (is-hand? state :corp [agenda]) - :deck (is (= (:title (last (:deck (get-corp)))) agenda)))))) + "HQ" (some #(= (:title %) agenda) (:hand (get-corp))) + "Bottom of R&D" (is (= (:title (last (:deck (get-corp)))) agenda)))))) (deftest let-them-dream-points ;; Global Food Initiative @@ -2293,7 +2293,7 @@ (is (zero? (:agenda-point (get-runner))) "Runner should start with 0 agenda points") (is (zero? (:agenda-point (get-corp))) "Corp should start with 0 agenda points") (play-and-score state "Let Them Dream") - (click-prompt state :corp "No") + (click-prompt state :corp "Done") (is (= 2 (:agenda-point (get-corp))) "Corp should gain 2 agenda points")) (testing "Runner steals" (play-from-hand state :corp "Let Them Dream" "New remote") From edc766577b21ee4fcce19ee5f6fc6af61a0feadb Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 18 Feb 2026 17:39:25 +1300 Subject: [PATCH 14/48] less hacky bad pub routing --- src/clj/game/core/actions.clj | 16 ++++++++++++++++ src/clj/game/core/pick_counters.clj | 2 +- src/clj/game/core/process_actions.clj | 3 ++- src/clj/game/core/prompts.clj | 17 ++++++++--------- src/cljs/nr/gameboard/board.cljs | 27 ++++++++++++++++----------- 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/clj/game/core/actions.clj b/src/clj/game/core/actions.clj index de98350e79..bcdf97507a 100644 --- a/src/clj/game/core/actions.clj +++ b/src/clj/game/core/actions.clj @@ -4,6 +4,7 @@ [clojure.stacktrace :refer [print-stack-trace]] [clojure.string :as string] [game.core.agendas :refer [update-advancement-requirement update-all-advancement-requirements update-all-agenda-points]] + [game.core.bad-publicity :refer [bad-publicity-available]] [game.core.board :refer [installable-servers]] [game.core.card :refer [get-advancement-requirement get-agenda-points get-card get-counters]] [game.core.card-defs :refer [card-def]] @@ -239,6 +240,21 @@ (pay state side eid card (->c :credit (min choice (get-in @state [side :credit])))) (effect-completed state side eid))) +(defn resolve-bad-pub-choice + [state side {:keys [eid] :as args}] + (if (pos? (bad-publicity-available state side)) + (let [prompt (or (first-prompt-by-eid state side eid) + (first (get-in @state [side :prompt]))) + card (:card prompt) + prompt-eid eid + effect (:effect prompt)] + (if (:offer-bad-pub? prompt) + (do (remove-from-prompt-queue state side prompt) + (when effect (effect :bad-publicity)) + (finish-prompt state side prompt card)) + (toast state side (str "You cannot choose Bad Publicity for this effect.") "warning"))) + (toast state side (str "You cannot choose Bad Publicity for this effect.") "warning"))) + ;; TODO - resolve-prompt does some evil things with eids, maybe we can fix it later - nbk, 2025 (defn resolve-prompt "Resolves a prompt by invoking its effect function with the selected target of the prompt. diff --git a/src/clj/game/core/pick_counters.clj b/src/clj/game/core/pick_counters.clj index 6be7b6dc54..edf9a042c1 100644 --- a/src/clj/game/core/pick_counters.clj +++ b/src/clj/game/core/pick_counters.clj @@ -226,7 +226,7 @@ ")") :offer-bad-pub? (when can-use-bad-pub? bad-pub-available) :choices {:card #(in-coll? (map :cid provider-cards) (:cid %))} - :effect (req (if (= target "Bad Publicity") + :effect (req (if (= target :bad-publicity) (continue-ability state side (pick-credit-providing-cards diff --git a/src/clj/game/core/process_actions.clj b/src/clj/game/core/process_actions.clj index 26af3c7ba2..79f699b780 100644 --- a/src/clj/game/core/process_actions.clj +++ b/src/clj/game/core/process_actions.clj @@ -6,7 +6,7 @@ generate-runnable-zones move-card expend-ability play play-ability play-corp-ability play-dynamic-ability play-runner-ability play-subroutine play-unbroken-subroutines remove-tag - resolve-prompt score select trash-button trash-resource view-deck]] + resolve-bad-pub-choice resolve-prompt score select trash-button trash-resource view-deck]] [game.core.card :refer [get-card]] [game.core.change-vals :refer [change]] [game.core.checkpoint :refer [fake-checkpoint]] @@ -67,6 +67,7 @@ (def commands {"ability" #'play-ability "advance" #'click-advance + "bad-pub-choice" #'resolve-bad-pub-choice "change" #'change "choice" #'resolve-prompt "close-deck" #'close-deck diff --git a/src/clj/game/core/prompts.clj b/src/clj/game/core/prompts.clj index eaddef9dac..9ff2d7e538 100644 --- a/src/clj/game/core/prompts.clj +++ b/src/clj/game/core/prompts.clj @@ -141,7 +141,7 @@ (cancel-effect nil) (effect-completed state side (:eid (:ability selected))))))) -(defn resolve-select-special! +(defn resolve-select-bad-publicity! "Resolves a selection prompt by invoking the prompt's ability with the targeted cards. Called when the user clicks 'Done' or selects the :max number of cards." [state side card args update! resolve-ability button] @@ -205,8 +205,7 @@ (str " " (pluralize "target" min-choices)) " a target")) " for " (:title card))) - (concat (when (:offer-bad-pub? ability) [(str "Bad Publicity (" (:offer-bad-pub? ability) " available)")]) - (if all ["Hide"] ["Done"])) + (if all ["Hide"] ["Done"]) (if all (fn [_] ; "Hide" was selected. Show toast and reapply select prompt. This allows players to access @@ -214,12 +213,12 @@ (toast state side (str "You must choose " max-choices " " (pluralize "card" max-choices))) (show-select state side card ability update! resolve-ability args)) (fn [s] - (let [selected (or (first-selection-by-eid state side (:eid ability)) - (get-in @state [side :selected 0])) - cards (map #(dissoc % :selected) (:cards selected))] - ;; check for :min. If not enough cards are selected, show toast and stay in select prompt - (if (and s (str/starts-with? (:value s) "Bad Publicity")) ;; this is an evil hack - (resolve-select-special! state side card ability update! resolve-ability "Bad Publicity") + (if (= s :bad-publicity) + (resolve-select-bad-publicity! state side card ability update! resolve-ability :bad-publicity) + (let [selected (or (first-selection-by-eid state side (:eid ability)) + (get-in @state [side :selected 0])) + cards (map #(dissoc % :selected) (:cards selected))] + ;; check for :min. If not enough cards are selected, show toast and stay in select prompt (if (and min-choices (< (count cards) min-choices)) (do (toast state side (str "You must choose at least " min-choices " " (pluralize "card" min-choices))) diff --git a/src/cljs/nr/gameboard/board.cljs b/src/cljs/nr/gameboard/board.cljs index 4c01df3b8d..e3f5859a4a 100644 --- a/src/cljs/nr/gameboard/board.cljs +++ b/src/cljs/nr/gameboard/board.cljs @@ -1782,7 +1782,7 @@ [tr-span [:game_ok "OK"]]]])) (defn prompt-div - [me {:keys [card msg prompt-type choices] :as prompt-state}] + [me {:keys [card msg prompt-type choices offer-bad-pub?] :as prompt-state}] (let [id (atom 0)] [:div.panel.blue-shade (when (and card (not= "Basic Action" (:type card))) @@ -1870,16 +1870,21 @@ ;; otherwise choice of all present choices :else - (doall (for [{:keys [idx uuid value]} choices - :when (not= value "Hide")] - [:button {:key idx - :on-click #(do (send-command "choice" {:eid (prompt-eid (:side @game-state)) :choice {:uuid uuid}}) - (card-highlight-mouse-out % value button-channel)) - :on-mouse-over - #(card-highlight-mouse-over % value button-channel) - :on-mouse-out - #(card-highlight-mouse-out % value button-channel)} - (render-message (or (not-empty (get-title value)) value))])))])) + (concat [(when offer-bad-pub? + ;; TODO - translate this + [:button {:key "Bad Pub" + :on-click #(send-command "bad-pub-choice" {:eid (prompt-eid (:side @game-state))})} + (str "Bad Publicity (" offer-bad-pub? " available)")])] + (doall (for [{:keys [idx uuid value]} choices + :when (not= value "Hide")] + [:button {:key idx + :on-click #(do (send-command "choice" {:eid (prompt-eid (:side @game-state)) :choice {:uuid uuid}}) + (card-highlight-mouse-out % value button-channel)) + :on-mouse-over + #(card-highlight-mouse-over % value button-channel) + :on-mouse-out + #(card-highlight-mouse-out % value button-channel)} + (render-message (or (not-empty (get-title value)) value))]))))])) (defn basic-actions [{:keys [side active-player end-turn runner-phase-12 corp-phase-12 me runner-post-discard corp-post-discard]}] (let [phase-12 (or @runner-phase-12 @corp-phase-12) From e08644465dd3666dae907ad1d188d3f7228eb010 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Thu, 19 Feb 2026 07:15:59 +1300 Subject: [PATCH 15/48] stick and poke --- src/clj/game/cards/resources.clj | 19 +++++++++++++++++++ test/clj/game/cards/resources_test.clj | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/clj/game/cards/resources.clj b/src/clj/game/cards/resources.clj index 62a021d155..4ec246d2df 100644 --- a/src/clj/game/cards/resources.clj +++ b/src/clj/game/cards/resources.clj @@ -3265,6 +3265,25 @@ (swap! state assoc-in [:runner :register :double-ignore-additional] true))}] :leave-play (req (swap! state update-in [:runner :register] dissoc :double-ignore-additional))}) +(defcard "Stick and Poke" + {:events [{:event :encounter-ice + :req (req (first-event? state side :encounter-ice)) + :interactive (req true) + :effect (req (register-lingering-effect + state side card + (let [ice (:ice context)] + {:duration :end-of-encounter + :type :additional-subroutines + :req (req (and (rezzed? target) (same-card? target ice))) + :value {:position :front + :subroutines + [{:label "[Stick] Do 1 net damage. The Runner draws 1 card." + :msg "Do 1 net damage" + :async true + :effect (req (wait-for + (damage state side :net 1) + (draw-loud state :runner eid card 1)))}]}})))}]}) + (defcard "Stim Dealer" {:events [{:event :runner-turn-begins :async true diff --git a/test/clj/game/cards/resources_test.clj b/test/clj/game/cards/resources_test.clj index 4916f90f78..f1279be1ba 100644 --- a/test/clj/game/cards/resources_test.clj +++ b/test/clj/game/cards/resources_test.clj @@ -6184,6 +6184,25 @@ (is (no-prompt? state :runner) "Runner has no Friday Chip prompt")) "Friday Chip shouldn't gain counters from Spoilers"))) +(deftest stick-and-poke + (do-game + (new-game {:corp {:hand ["Vanilla"]} + :runner {:hand ["Stick and Poke" "Stick and Poke"] :deck ["Ika"]}}) + (play-from-hand state :corp "Vanilla" "HQ") + (rez state :corp (get-ice state :hq 0)) + (take-credits state :corp) + (is (changed? [(count (:subroutines (get-ice state :hq 0))) 0] + (play-from-hand state :runner "Stick and Poke") + (core/fake-checkpoint state)) + "Gained no sub") + (run-on state :hq) + (is (changed? [(count (:subroutines (get-ice state :hq 0))) 1] + (run-continue-until state :encounter-ice)) + "gained sub") + (card-subroutine state :corp (get-ice state :hq 0) 0) + (is-discard? state :runner ["Stick and Poke"]) + (is-hand? state :runner ["Ika"]))) + (deftest stim-dealer ;; Stim Dealer - Take 1 brain damage when it accumulates 2 power counters (do-game From 4329a80fabf3b97ccfdc7c24a7da97f5eb9137d3 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Fri, 20 Feb 2026 07:00:02 +1300 Subject: [PATCH 16/48] magistrate revontulet --- src/clj/game/cards/assets.clj | 10 ++++++++++ test/clj/game/cards/assets_test.clj | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/clj/game/cards/assets.clj b/src/clj/game/cards/assets.clj index 38be1207c8..e51d8a2970 100644 --- a/src/clj/game/cards/assets.clj +++ b/src/clj/game/cards/assets.clj @@ -1842,6 +1842,16 @@ :effect (req (access-bonus state :runner target -1))} card targets))}]}) +(defcard "Magistrate Revontulet" + {:static-abilities [{:type :steal-additional-cost + :req (req (agenda? target)) + :value (req [(->c :credit 3)])}] + :events [{:event :agenda-scored + :async true + :interactive (req true) + :msg "force the Runner to lose 3 [Credits]" + :effect (req (lose-credits state :runner eid 3))}]}) + (defcard "Malia Z0L0K4" (let [unmark (req (when-let [malia-target (get-in card [:special :malia-target])] diff --git a/test/clj/game/cards/assets_test.clj b/test/clj/game/cards/assets_test.clj index cc52a2e2c8..91a79673a7 100644 --- a/test/clj/game/cards/assets_test.clj +++ b/test/clj/game/cards/assets_test.clj @@ -3392,6 +3392,29 @@ (is (no-prompt? state :runner) "No prompt") (is (not (:run @state)) "Access ended after 1 card seen - todachine did his work"))) +(deftest magistrate-revontuler + (do-game + (new-game {:corp {:hand ["Magistrate Revontulet" "Greenmail" "Project Beale" "Project Atlas"]} + :runner {:credits 20}}) + (play-from-hand state :corp "Magistrate Revontulet" "New remote") + (play-from-hand state :corp "Project Atlas" "New remote") + (rez state :corp (get-content state :remote1 0)) + (is (rezzed? (get-content state :remote1 0))) + (play-and-score state "Greenmail") + (is (changed? [(:credit (get-runner)) -3] + (click-prompt state :corp "Greenmail")) + "Taxed on score") + (take-credits state :corp) + (run-empty-server state :hq) + (is (changed? [(:credit (get-runner)) -3] + (click-prompt state :runner "Pay to steal")) + "paid 3 to steal") + (is (no-prompt? state :runner)) + (run-empty-server state :remote2) + (is (changed? [(:credit (get-runner)) -3] + (click-prompt state :runner "Pay to steal")) + "paid 3 to steal"))) + (deftest malia-icon-goes-away-with-cupellation (do-game (new-game {:corp {:hand ["Malia Z0L0K4"]} From 0e359737b2bb9da8ff0bcae40db82a626069fae8 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Fri, 20 Feb 2026 07:01:20 +1300 Subject: [PATCH 17/48] flagship, access updates --- src/clj/game/cards/upgrades.clj | 41 ++++++++++++++++++++- src/clj/game/core/access.clj | 8 +++-- test/clj/game/cards/upgrades_test.clj | 51 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/clj/game/cards/upgrades.clj b/src/clj/game/cards/upgrades.clj index 5f0363a105..9adf64c168 100644 --- a/src/clj/game/cards/upgrades.clj +++ b/src/clj/game/cards/upgrades.clj @@ -22,7 +22,7 @@ [game.core.eid :refer [effect-completed get-ability-targets is-basic-advance-action? make-eid]] [game.core.engine :refer [dissoc-req pay register-default-events register-events resolve-ability unregister-events]] - [game.core.events :refer [first-event? first-run-event? no-event? turn-events]] + [game.core.events :refer [first-event? first-run-event? no-event? turn-events run-event-count]] [game.core.finding :refer [find-cid find-latest]] [game.core.flags :refer [clear-persistent-flag! is-scored? register-persistent-flag! register-run-flag!]] @@ -726,6 +726,45 @@ :base 3 :successful (give-tags 2)}}}) +(defcard "Flagship" + (let [lockdown (fn [state side card] + (register-lingering-effect + state side card + {:type :disable-random-accesses + :value true + :duration :end-of-run}) + (register-lingering-effect + state side card + {:type :disable-access-candidacy + :req (req (not (same-card? card target))) + :duration :end-of-run + :value true})) + ev {:event :post-access-card + :once :per-run + :msg "prevent the Runner from accessing other cards this run" + :req (req (and run this-server + (not (same-card? card target)))) + :effect (req ;; random accesses get disabled after your first access that is not this card + (lockdown state side card))}] + ;; NOTE: Based on the CR, this invalidates all other cards as access candidates + ;; when you touch a card other than this card. + ;; This means that if you cupellate this, you dodge it, + ;; but if you access another card first, then cupellate this, you do not + {:static-abilities [{:type :block-successful-run + :req (req this-server) + :value true}] + :events [ev] + :on-trash {:req (req (and run (= :runner side))) + :effect (req (if (>= (run-event-count state side :access) 2) + (lockdown state side card) + (register-events + state side card + [{:duration :end-of-run + :event :access + :req (req run) + :effect (req (lockdown state side card))}])))}})) + + (defcard "Fractal Threat Matrix" {:events [{:event :subroutines-broken :req (req (and (:all-subs-broken context) diff --git a/src/clj/game/core/access.clj b/src/clj/game/core/access.clj index 2cb8984e3f..7766857784 100644 --- a/src/clj/game/core/access.clj +++ b/src/clj/game/core/access.clj @@ -509,7 +509,8 @@ ([state server] (->> (get-in @state [:corp :servers server :content]) get-all-content - (filter #(can-access? state :runner %)))) + (filter #(can-access? state :runner %)) + (filter #(not (any-effects state :runner :disable-access-candidacy true? % [%]))))) ([state server already-accessed-fn] (remove already-accessed-fn (root-content state server)))) @@ -626,7 +627,7 @@ (defn access-helper-rd [state {:keys [chosen random-access-limit] :as access-amount} already-accessed {:keys [no-root] :as args}] - (let [current-available (set (concat (map :cid (get-in @state [:corp :deck])) + (let [current-available (set (concat (if-not (any-effects state :runner :disable-random-accesses true? {:server :rd}) (map :cid (get-in @state [:corp :deck])) []) (map :cid (root-content state :rd)))) already-accessed (clj-set/intersection already-accessed current-available) already-accessed-fn (fn [card] (contains? already-accessed (:cid card))) @@ -814,7 +815,8 @@ (defn access-helper-hq [state {:keys [chosen random-access-limit] :as access-amount} already-accessed {:keys [no-root access-first] :as args}] - (let [hand (when (not (:prevent-hand-access (:run @state))) + (let [hand (when (not (or (:prevent-hand-access (:run @state)) + (any-effects state :runner :disable-random-accesses true? {:server :hq}))) (get-in @state [:corp :hand])) current-available (set (concat (map :cid hand) (map :cid (root-content state :hq)))) diff --git a/test/clj/game/cards/upgrades_test.clj b/test/clj/game/cards/upgrades_test.clj index a12273a110..537d6885f9 100644 --- a/test/clj/game/cards/upgrades_test.clj +++ b/test/clj/game/cards/upgrades_test.clj @@ -1589,6 +1589,57 @@ (take-credits state :runner) (is (= (+ 3 total-corp-credits) (:credit (get-corp))) "Corp does not gain any extra c with agenda"))))) +(letfn [(setup-state [] + (let [state (new-game {:runner {:hand ["Cupellation" "HQ Interface"] + :credits 10} + :corp {:hand ["Flagship" "Research Station" "Hedge Fund" "Hedge Fund"] + :credits 10}})] + (play-from-hand state :corp "Flagship" "HQ") + (play-from-hand state :corp "Research Station" "HQ") + (rez state :corp (get-content state :hq 0)) + (rez state :corp (get-content state :hq 1)) + (take-credits state :corp) + (play-from-hand state :runner "HQ Interface") + state))] + ;; access both upgrades, cannot access anything else + (deftest flagship-normal-case-two-upgrades + (do-game + (setup-state) + (run-empty-server state :hq) + (click-prompts state :runner "Research Station" "No action" "No action") + (is (no-prompt? state :runner))) + (do-game + (setup-state) + (run-empty-server state :hq) + (click-prompts state :runner "Flagship" "No action" "Research Station" "No action") + (is (no-prompt? state :runner)))) + ;; upgrade and a card from hand + (deftest flagship-normal-case-one-upgrade + (do-game + (setup-state) + (run-empty-server state :hq) + (click-prompts state :runner "Card from hand" "No action" "No action") + (is (no-prompt? state :runner))) + (do-game + (setup-state) + (run-empty-server state :hq) + (click-prompts state :runner "Flagship" "No action" "Card from hand" "No action") + (is (no-prompt? state :runner)))) + (deftest flagship-normal-case-trash-station + (do-game + (setup-state) + (run-empty-server state :hq) + (click-prompt state :runner "Flagship") + (do-trash-prompt state 4) + (click-prompts state :runner "Card from hand" "No action") + (is (no-prompt? state :runner))) + (do-game + (setup-state) + (run-empty-server state :hq) + (click-prompts state :runner "Card from hand" "No action") + (do-trash-prompt state 4) + (is (no-prompt? state :runner))))) + (deftest forced-connection ;; Forced Connection - ambush, trace(3) give the runner 2 tags (do-game From 7edb010bbad3a7e44394b008acaf31fddd31ad02 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 21 Feb 2026 15:04:08 +1300 Subject: [PATCH 18/48] compiles --- src/clj/game/cards/resources.clj | 2 +- src/clj/game/core/pick_counters.clj | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/clj/game/cards/resources.clj b/src/clj/game/cards/resources.clj index c925887090..f8dc8aedea 100644 --- a/src/clj/game/cards/resources.clj +++ b/src/clj/game/cards/resources.clj @@ -22,7 +22,7 @@ trash-cost]] [game.core.costs :refer [total-available-credits]] [game.core.damage :refer [damage]] - [game.core.def-helpers :refer [all-cards-in-hand* in-hand*? breach-access-bonus defcard draw-abi offer-jack-out + [game.core.def-helpers :refer [all-cards-in-hand* in-hand*? breach-access-bonus defcard draw-abi draw-loud offer-jack-out reorder-choice spend-credits take-credits take-n-credits-ability take-all-credits-ability trash-on-empty do-net-damage play-tiered-sfx run-any-server-ability run-server-ability make-icon]] diff --git a/src/clj/game/core/pick_counters.clj b/src/clj/game/core/pick_counters.clj index 7879e372fd..062398e63f 100644 --- a/src/clj/game/core/pick_counters.clj +++ b/src/clj/game/core/pick_counters.clj @@ -130,9 +130,9 @@ #(assoc % :card providing-card :number (+ (:number % 0) async-result))) (use-card uses providing-card async-result)) card targets)))) - :cancel{:async true - :effect (req (complete-with-result state side eid {:reduction counter-count - :targets (keep #(:card (second %)) selected-cards)}))}})))) + :cancel {:async true + :effect (req (complete-with-result state side eid {:reduction counter-count + :targets (keep #(:card (second %)) selected-cards)}))}})))) (defn pick-credit-providing-cards "Similar to pick-virus-counters-to-spend. Works on :recurring and normal credits." From 09f097471a30967a529be5720cd52d1176dd51cb Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 21 Feb 2026 15:55:48 +1300 Subject: [PATCH 19/48] Async --- src/clj/game/core/pick_counters.clj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/clj/game/core/pick_counters.clj b/src/clj/game/core/pick_counters.clj index 062398e63f..777374853b 100644 --- a/src/clj/game/core/pick_counters.clj +++ b/src/clj/game/core/pick_counters.clj @@ -59,7 +59,8 @@ (str (quantify number "virus counter") " from " title)) (vals selected-cards)))] (pick-counter-triggers state side eid selected-cards selected-cards :virus counter-count message 0))))) - :cancel {:effect (if target-count + :cancel {:async true + :effect (if target-count (req (doseq [{:keys [card number]} (vals selected-cards)] (update! state :runner (update-in (get-card state card) [:counter :virus] + number))) (complete-with-result state side eid :cancel)) From c85c9ce790c1e209423b3d8fc044070d70cd7a52 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 21 Feb 2026 15:56:03 +1300 Subject: [PATCH 20/48] tailgate, kompromat --- src/clj/game/cards/events.clj | 50 ++++++++++++++++++++++++++ test/clj/game/cards/events_test.clj | 56 +++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/clj/game/cards/events.clj b/src/clj/game/cards/events.clj index b7def7e97f..f224d2b379 100644 --- a/src/clj/game/cards/events.clj +++ b/src/clj/game/cards/events.clj @@ -2180,6 +2180,45 @@ (defcard "Knifed" (cutlery "Barrier")) +(defcard "Kompromat" + (letfn [(iced-servers [state side eid card] + (filter #(-> (get-in @state (cons :corp (server->zone state %))) :ices count pos?) + (zones->sorted-names (get-runnable-zones state side eid card nil))))] + {:makes-run true + :on-play {:async true + :rfg-instead-of-trashing true + :change-in-game-state {:req (req (seq (iced-servers state side eid card)))} + :prompt "Choose an iced server" + :choices (req (iced-servers state side eid card)) + :effect (req (make-run state side eid target card))} + :events [{:event :run-ends + :req (req (and this-card-run (:successful context))) + :async true + :interactive (req true) + :effect (req (let [valid-ice (filter #(and (ice? %) + (rezzed? %) + (= (first (:server context)) (second (get-zone %)))) + (all-installed state :corp))] + (continue-ability + state side + (if (seq valid-ice) + {:prompt "Derez an ice? (if you click done, you take a bad publicity)" + :player :corp + :waiting-prompt true + :choices {:req (req (some #(same-card? % target) valid-ice))} + :cancel {:display-side :runner + :msg "give the Corp 1 bad publicity" + :async true + :effect (req (gain-bad-publicity state :runner eid 1))} + :msg (msg "derez " (card-str state target)) + :display-side :corp + :async true + :effect (req (derez state side eid target {:no-msg true}))} + {:msg "give the Corp 1 bad publicity" + :async true + :effect (req (gain-bad-publicity state :runner eid 1))}) + card nil)))}]})) + (defcard "Kraken" {:on-play {:req (req (:stole-agenda runner-reg)) @@ -3761,6 +3800,17 @@ (assoc ability :event :corp-turn-ends) (assoc ability :event :runner-turn-ends)]})) +(defcard "Tailgate" + {:makes-run true + :on-play (run-server-ability + :hq + {:play-cost-bonus (req (- (count (get-in @state [:corp :servers :hq :ices]))))}) + :events [{:event :successful-run + :silent (req true) + :req (req (and (= :hq (target-server context)) this-card-run)) + :effect (effect (register-events + card [(breach-access-bonus :hq 2 {:duration :end-of-run})]))}]}) + (defcard "Test Run" {:on-play {:prompt (req (if (not (zone-locked? state :runner :discard)) diff --git a/test/clj/game/cards/events_test.clj b/test/clj/game/cards/events_test.clj index 214947d9c2..e09ec8f74d 100644 --- a/test/clj/game/cards/events_test.clj +++ b/test/clj/game/cards/events_test.clj @@ -4429,6 +4429,44 @@ (run-continue state) (is (get-ice state :hq 0) "Second Ice Wall is not trashed"))) +(deftest kompromat-test + (testing "Kompromat" + (do-game + (new-game {:corp {:hand ["Ice Wall"]} + :runner {:hand ["Kompromat"]}}) + (play-from-hand state :corp "Ice Wall" "HQ") + (take-credits state :corp) + (play-from-hand state :runner "Kompromat") + (click-prompt state :runner "HQ") + (rez state :corp (get-ice state :hq 0)) + (run-continue-until state :encounter-ice) + (fire-subs state (get-ice state :hq 0)) + (is (no-prompt? state :corp)))) + (testing "Kompromat successful" + (do-game + (new-game {:corp {:hand ["Ice Wall"]} + :runner {:hand ["Kompromat"]}}) + (play-from-hand state :corp "Ice Wall" "HQ") + (take-credits state :corp) + (play-from-hand state :runner "Kompromat") + (click-prompt state :runner "HQ") + (rez state :corp (get-ice state :hq 0)) + (run-continue-until state :success) + (click-card state :corp "Ice Wall")))) + +(deftest kompromat-test-take-bp + (do-game + (new-game {:corp {:hand ["Ice Wall"]} + :runner {:hand ["Kompromat"]}}) + (play-from-hand state :corp "Ice Wall" "HQ") + (take-credits state :corp) + (play-from-hand state :runner "Kompromat") + (click-prompt state :runner "HQ") + (rez state :corp (get-ice state :hq 0)) + (run-continue-until state :success) + (click-prompt state :corp "Done") + (is (= 1 (count-bad-pub state))))) + (deftest kraken ;; Kraken (do-game @@ -7308,6 +7346,24 @@ (is (= 2 (core/breaker-strength state :runner (refresh c1))) "Corroder 1 has 2 strength") (is (= 2 (core/breaker-strength state :runner (refresh c2))) "Corroder 2 has 2 strength")))) +(deftest tailgate-test + (dotimes [ices 4] + (do-game + (new-game {:corp {:hand (vec (take (+ 4 ices) ["Hedge Fund" "Hedge Fund" "Hedge Fund" + "Hedge Fund" + "Vanilla" "Vanilla" "Vanilla"]))} + :runner {:hand ["Tailgate"]}}) + (dotimes [_ ices] + (play-from-hand state :corp "Vanilla" "HQ")) + (take-credits state :corp) + (is (changed? [(:credit (get-runner)) (min 0 (- ices 3))] + (play-from-hand state :runner "Tailgate")) + "discounted") + (run-continue-until state :success) + (dotimes [n 3] + (click-prompt state :runner "No action")) + (is (no-prompt? state :runner))))) + (deftest test-run-programs-hosted-after-install-get-returned-to-stack-issue-1081 ;; Programs hosted after install get returned to Stack. Issue #1081 (do-game From efdd9e28b918b97b4384375db8927c0d05d44a5b Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 21 Feb 2026 15:56:22 +1300 Subject: [PATCH 21/48] witch hunt test --- src/clj/game/cards/agendas.clj | 21 ++++++++++++++++++++- test/clj/game/cards/agendas_test.clj | 12 ++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/clj/game/cards/agendas.clj b/src/clj/game/cards/agendas.clj index 331909ec21..80313c1d74 100644 --- a/src/clj/game/cards/agendas.clj +++ b/src/clj/game/cards/agendas.clj @@ -51,7 +51,7 @@ [game.core.set-aside :refer [set-aside-for-me]] [game.core.shuffling :refer [shuffle! shuffle-into-deck shuffle-my-deck! shuffle-into-rd-effect]] - [game.core.tags :refer [gain-tags]] + [game.core.tags :refer [gain-tags lose-tags]] [game.core.to-string :refer [card-str]] [game.core.toasts :refer [toast]] [game.core.update :refer [update!]] @@ -2582,3 +2582,22 @@ (not (has-subtype? target "Virtual")) (not (:facedown (second targets))))) :value 1}]}) + +(defcard "Witch Hunt" + (let [bp {:msg "take 1 bad publicity" + :async true + :effect (effect (gain-bad-publicity :corp eid 1))}] + {:stolen bp + :on-score bp + :events [{:unregister-once-resolved true + :event :corp-action-phase-ends + :duration :end-of-turn + :req (effect (first-event? :agenda-scored #(same-card? card (:card (first %))))) + :msg (msg (if tagged + "Remove all tags, and then give the Runner 3 tags" + "give the Runner 3 tags")) + :async true + :effect (req (if tagged + (wait-for (lose-tags state side :all {:suppress-checkpoint true}) + (gain-tags state side eid 3)) + (gain-tags state side eid 3)))}]})) diff --git a/test/clj/game/cards/agendas_test.clj b/test/clj/game/cards/agendas_test.clj index 104680127b..648cab9336 100644 --- a/test/clj/game/cards/agendas_test.clj +++ b/test/clj/game/cards/agendas_test.clj @@ -5336,3 +5336,15 @@ (play-from-hand state :runner "Hunting Grounds") (card-ability state :runner (get-resource state 0) 0) (is (= 3 (:credit (get-runner))) "Shouldn't lose any credits"))) + +(deftest witch-hunt-correct-tags + (doseq [t [0 1 2 3 4 5 6]] + (do-game + (new-game {:corp {:hand ["Witch Hunt"]}}) + (play-and-score state "Witch Hunt") + (take-credits state :corp) + (is (= 3 (count-tags state))) + (take-credits state :runner) + (is (changed? [(count-tags state) 0 + (count-bad-pub state) 0] + (take-credits state :corp)))))) From 116f06dabe3778587fe9b026c354dfa64ed90d72 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 21 Feb 2026 15:56:40 +1300 Subject: [PATCH 22/48] paywall --- src/clj/game/cards/ice.clj | 4 ++++ test/clj/game/cards/ice_test.clj | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index 9dfba9781c..de864aaad0 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -3480,6 +3480,10 @@ :effect (effect (trash :corp eid card {:cause-card card :cause :effect}))}] :subroutines [end-the-run]}) +(defcard "Paywall" + {:on-encounter (runner-loses-credits 1) + :subroutines [(end-the-run-unless-runner-pays (->c :credit 1))]}) + (defcard "Peeping Tom" (let [sub (end-the-run-unless-runner "takes 1 tag" diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index 41f5235495..c8a2f54221 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -6230,6 +6230,15 @@ (auto-pump-and-break state corroder) (is (nil? (get-ice state :hq 0)) "Paper Wall was trashed")))) +(deftest paywall-test + (do-game + (run-and-encounter-ice-test "Paywall") + (is (= 4 (:credit (get-runner))) "lose 1 credit on encounter") + (fire-subs state (get-ice state :hq 0)) + (click-prompt state :runner "Pay 1 [Credits]") + (is (:run @state) "run not ended") + (is (= 3 (:credit (get-runner))) "paid 1 to not etr"))) + (deftest peeping-tom ;;Peeping Tom - Counts # of chosen card type in Runner grip (do-game From ee23fa573ef4eacf06c1e48d6c1a3461188c3dc0 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sat, 21 Feb 2026 15:56:52 +1300 Subject: [PATCH 23/48] bad pub redo now works with unit tests --- test/clj/game/test_framework.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/clj/game/test_framework.clj b/test/clj/game/test_framework.clj index 5b1128c57d..f529477b0d 100644 --- a/test/clj/game/test_framework.clj +++ b/test/clj/game/test_framework.clj @@ -232,7 +232,7 @@ (defn select-bad-pub [state expected] - (click-prompt state :runner (str "Bad Publicity (" expected " available)"))) + (core/process-action "bad-pub-choice" state :runner {:eid (:eid (get-prompt state :runner))})) ;; General utilities necessary for starting a new game (defn find-card From 54bf28308bb8e091f2a2c0c9c30803f796ab3a71 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 22 Feb 2026 07:49:43 +1300 Subject: [PATCH 24/48] flywheel, event horizon --- src/clj/game/cards/ice.clj | 25 +++++++++++++++++++++ test/clj/game/cards/ice_test.clj | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index de864aaad0..5c0ad23bb6 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -1769,6 +1769,22 @@ sub sub]})) +(defcard "Event Horizon" + {:subroutines [(choose-one-helper + {:label "Trash 1 program unless runner pays 3 [Credits]" + :player :runner} + [(cost-option [(->c :credit 3)] :runner) + {:option "The Corp trashes a Program" + :ability {:async true + :effect (req (continue-ability state :corp trash-program-sub card nil))}}]) + (end-the-run-unless-runner-pays (->c :credit 3))] + :abilities [{:label "End the run" + :msg "end the run" + :async true + :req (req this-server run) + :cost [(->c :trash-can)] + :effect (req (end-run state side eid card))}]}) + (defcard "Excalibur" {:subroutines [prevent-runs-this-turn]}) @@ -1920,6 +1936,15 @@ (purge state side eid))} :subroutines [end-the-run]}) +(defcard "Flywheel" + (let [sub {:label "Gain 1 [Credit]. You may draw 1 card" + :async true + :msg "gain 1 [Credit]" + :effect (req (wait-for + (gain-credits state side 1) + (maybe-draw state side eid card 1)))}] + {:subroutines [sub sub]})) + (defcard "Formicary" {:derezzed-events [{:event :approach-server diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index c8a2f54221..2417f16d2b 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -2379,6 +2379,30 @@ (is (= 1 (count (:subroutines (get-ice state :hq 0))))) (is (= 0 (:index (first (:subroutines (get-ice state :hq 0)))))))) +(deftest event-horizon-subs + (doseq [opt [:pay :resolve]] + (do-game + (subroutine-test "Event Horizon" 0 nil {:rig ["Rezeki"]}) + (case opt + :resolve (do (click-prompt state :runner "The Corp trashes a Program") + (click-card state :corp "Rezeki") + (is (= 1 (count (:discard (get-runner)))))) + :pay (click-prompt state :runner "Pay 3 [Credits]"))) + (do-game + (subroutine-test "Event Horizon" 1) + (case opt + :resolve (do (click-prompt state :runner "End the run") + (is (not (:run @state)))) + :pay (click-prompt state :runner "Pay 3 [Credits]"))))) + +(deftest event-horizon-ability-end-the-run + (do-game + (run-and-encounter-ice-test "Event Horizon") + (is (:run @state) "Running") + (card-ability state :corp (get-ice state :hq 0) 0) + (is (not (:run @state)) "Run ended") + (is (= "Event Horizon" (-> (get-corp) :discard first :title)) "Event Horizon trashed"))) + (deftest excalibur ;; Excalibur - Prevent Runner from making another run this turn (do-game @@ -2587,6 +2611,20 @@ (deftest flyswatter-etr-sub (do-game (etr-sub "Flyswatter" 0))) +(deftest flywheel-subs + (doseq [sub [0 1] + opt [:draw :no-draw]] + (do-game + (subroutine-test "Flywheel" sub {:corp {:deck [(qty "IPO" 10)]}}) + (is (= 6 (:credit (get-corp))) "Gained 1 credit") + (case opt + :draw (is (changed? [(count (:hand (get-corp))) 1] + (click-prompt state :corp "Yes")) + "Drew and gained a cred") + :no-draw (is (changed? [(count (:hand (get-corp))) 0] + (click-prompt state :corp "No")) + "Did not draw, just gained a cred"))))) + (deftest formicary-verifies-basic-functionality ;; Verifies basic functionality (do-game From b90e2a5ceb545048439d7e8178e23d7f7d2fa7e5 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 22 Feb 2026 07:50:42 +1300 Subject: [PATCH 25/48] stowaway --- src/clj/game/cards/programs.clj | 10 ++++++++++ test/clj/game/cards/programs_test.clj | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/clj/game/cards/programs.clj b/src/clj/game/cards/programs.clj index 389a7480e6..c3793a011a 100644 --- a/src/clj/game/cards/programs.clj +++ b/src/clj/game/cards/programs.clj @@ -3189,6 +3189,16 @@ :once :per-turn :events [ability]})]})) +(defcard "Stowaway" + trojan + {:events [{:event :successful-run + :req (req (= (second (get-zone (get-card state (:host card)))) + (target-server context))) + :async true + :msg "gain 2 [Credits]" + :automatic :gain-credits + :effect (req (gain-credits state side eid 2))}]}) + (defcard "Study Guide" (auto-icebreaker {:abilities [(break-sub 1 1 "Code Gate") {:cost [(->c :credit 2)] diff --git a/test/clj/game/cards/programs_test.clj b/test/clj/game/cards/programs_test.clj index a8953629ba..b00363af79 100644 --- a/test/clj/game/cards/programs_test.clj +++ b/test/clj/game/cards/programs_test.clj @@ -8468,6 +8468,19 @@ (is (= "Troll" (-> (get-corp) :discard first :title)) "Troll was trashed") (is (= "Herald" (-> (get-corp) :deck first :title)) "Herald now on top of R&D")))) +(deftest stowaway-test + (do-game + (new-game {:runner {:hand ["Stowaway"]} + :corp {:hand ["Ice Wall"]}}) + (play-from-hand state :corp "Ice Wall" "HQ") + (take-credits state :corp) + (play-from-hand state :runner "Stowaway") + (click-card state :runner "Ice Wall") + (run-on state :hq) + (is (changed? [(:credit (get-runner)) 2] + (run-continue-until state :success)) + "Gained 2c for a successful run on stowaway server"))) + (deftest study-guide ;; Study Guide - 2c to add a power counter; +1 strength per counter (do-game From f063a4d502215f47baa3ee1b166d59b0a2f8ccbf Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Sun, 22 Feb 2026 07:51:06 +1300 Subject: [PATCH 26/48] myoshu and tests --- src/clj/game/cards/operations.clj | 5 +++++ src/clj/game/core/actions.clj | 1 + test/clj/game/cards/operations_test.clj | 28 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/clj/game/cards/operations.clj b/src/clj/game/cards/operations.clj index c0e5dc86f9..95ae8bc83e 100644 --- a/src/clj/game/cards/operations.clj +++ b/src/clj/game/cards/operations.clj @@ -1906,6 +1906,11 @@ :effect (req (wait-for (trash-cards state side targets {:cause-card card}) (gain-tags state :corp eid (count targets))))}}) +(defcard "Myōshu" + {:on-play {:req (req (not (no-event? state side :agenda-scored #(->> % first :scored-card :installed (not= :this-turn))))) + :msg "add itself to [their] score area as an Agenda worth 2 points" + :effect (req (as-agenda state side card 2))}}) + (defcard "Nanomanagement" {:on-play (gain-n-clicks 2)}) diff --git a/src/clj/game/core/actions.clj b/src/clj/game/core/actions.clj index 9135bd456d..a7fbf9e595 100644 --- a/src/clj/game/core/actions.clj +++ b/src/clj/game/core/actions.clj @@ -758,6 +758,7 @@ (when-let [on-score (:on-score (card-def c))] (register-pending-event state :agenda-scored c on-score)) (queue-event state :agenda-scored {:card c + :scored-card card :advancement-requirement advancement-requirement :advancement-tokens advancement-tokens :points points}) diff --git a/test/clj/game/cards/operations_test.clj b/test/clj/game/cards/operations_test.clj index a62f284102..23632dad09 100644 --- a/test/clj/game/cards/operations_test.clj +++ b/test/clj/game/cards/operations_test.clj @@ -3121,6 +3121,34 @@ (is (= 2 (count-tags state)) "Runner should have two tags from MAD") (is (= 3 (count (:discard (get-corp)))) "MAD + 2 cards in discard"))) +(deftest myoshu + (do-game + (new-game {:corp {:credits 50 + :hand ["Greenmail" "Myōshu"]}}) + (play-from-hand state :corp "Greenmail" "New remote") + (dotimes [_ 2] + (click-advance state :corp (get-content state :remote1 0))) + (take-credits state :corp) + (take-credits state :runner) + (score state :corp (get-content state :remote1 0)) + (is (changed? [(:credit (get-corp)) -10 + (:agenda-point (get-corp)) 2] + (play-from-hand state :corp "Myōshu")) + "Traded 10c for 2 agenda points"))) + +(deftest myoshu-doesnt-work-if-installed-this-turn + (do-game + (new-game {:corp {:credits 50 + :hand ["Greenmail" "Myōshu"]}}) + (core/gain state :corp :click 4) + (play-from-hand state :corp "Greenmail" "New remote") + (dotimes [_ 2] + (click-advance state :corp (get-content state :remote1 0))) + (score state :corp (get-content state :remote1 0)) + (is (changed? [(:credit (get-corp)) 0] + (play-from-hand state :corp "Myōshu")) + "Could not play, installed the greenmail this turn"))) + (deftest nanomanagement ;; Biotic Labor - Gain 2 clicks (do-game From dd03d1a2cb534871490841331f7b0b55830cb7af Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 23 Feb 2026 06:26:59 +1300 Subject: [PATCH 27/48] beta build --- src/clj/game/cards/events.clj | 27 +++++++++++++++++++++++++++ src/clj/game/core/def_helpers.clj | 1 + test/clj/game/cards/events_test.clj | 24 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/clj/game/cards/events.clj b/src/clj/game/cards/events.clj index f224d2b379..de9a157772 100644 --- a/src/clj/game/cards/events.clj +++ b/src/clj/game/cards/events.clj @@ -264,6 +264,33 @@ (move state :corp c :deck)) (shuffle! state :corp :deck))}})]}) +(defcard "Beta Build" + {:makes-run true + :on-play {:async true + :effect (req (wait-for + (resolve-ability + state side + {:prompt "Install a non-virus program" + :choices (req (cancellable (filter #(and (program? %) + (runner-can-install? state side eid % {:no-toast true})) + (:deck runner)))) + :async true + :effect (req (wait-for + (runner-install state side target {:ignore-all-cost :true :msg-keys {:display-origin true :source-card card}}) + (complete-with-result state side eid async-result)))} + card nil) + (let [installed-card async-result] + (resolve-ability state side eid + (run-any-server-ability {:events [{:event :run-ends + :unregister-once-resolved true + :duration :end-of-run + :change-in-game-state {:silent true + :req (req (get-card state installed-card))} + :async true + :msg (msg "add " (:title installed-card) " to the top of the stack") + :effect (req (move state side installed-card :deck {:front true}))}]}) + card nil))))}}) + (defcard "Black Hat" {:on-play {:trace diff --git a/src/clj/game/core/def_helpers.clj b/src/clj/game/core/def_helpers.clj index d602af5a1c..6ee82565cb 100644 --- a/src/clj/game/core/def_helpers.clj +++ b/src/clj/game/core/def_helpers.clj @@ -233,6 +233,7 @@ (merge {:async true :prompt "Choose a server" :choices (req runnable-servers) + :req (req (seq runnable-servers)) :label "Run a server" :makes-run true :msg (msg "make a run on " target) diff --git a/test/clj/game/cards/events_test.clj b/test/clj/game/cards/events_test.clj index e09ec8f74d..f805b4ed48 100644 --- a/test/clj/game/cards/events_test.clj +++ b/test/clj/game/cards/events_test.clj @@ -505,6 +505,30 @@ (is (= (inc n) (count (get-in @state [:corp :deck]))) "1 card was shuffled into R&D") (is (zero? (count (get-in @state [:corp :servers :remote2 :content]))) "No cards left in server 3")))) +(deftest beta-build + (do-game + (new-game {:runner {:hand ["Beta Build"] :deck ["Orca"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Beta Build") + (click-prompt state :runner "Orca") + (is (= "Orca" (:title (get-program state 0)))) + (click-prompt state :runner "HQ") + (run-continue-until state :success) + (click-prompt state :runner "No action") + (is-deck? state :runner ["Orca"]))) + +#_(deftest ^:kaocha/pending beta-build-cannot-run + ;; note that peace in our time needs to be updated currently, it forbids + ;; run events when it should not + (do-game + (new-game {:runner {:hand ["Beta Build" "Peace in Our Time"] + :deck ["Orca"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Peace in Our Time") + (play-from-hand state :runner "Beta Build") + (click-prompt state :runner "Orca") + (is (no-prompt? state :runner)))) + (deftest black-hat ;; Black Hat (do-game From db91d80f6fa229a51841a58d21bbe42c5ebcfffb Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 23 Feb 2026 06:59:56 +1300 Subject: [PATCH 28/48] borrowed goods --- src/clj/game/cards/hardware.clj | 8 ++++++++ test/clj/game/cards/hardware_test.clj | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/clj/game/cards/hardware.clj b/src/clj/game/cards/hardware.clj index e0a4a4f17e..5bbfd04306 100644 --- a/src/clj/game/cards/hardware.clj +++ b/src/clj/game/cards/hardware.clj @@ -444,6 +444,14 @@ :deck) (shuffle! :deck))}}}])))}]})) +(defcard "Borrowed Goods" + {:on-install {:change-in-game-state {:req (req (not tagged)) :silent true} + :msg "take 1 tag" + :interactive (req true) + :async true + :effect (req (gain-tags state side eid 1))} + :static-abilities [(mu+ 1)]}) + (defcard "Box-E" {:static-abilities [(mu+ 2) (runner-hand-size+ 2)]}) diff --git a/test/clj/game/cards/hardware_test.clj b/test/clj/game/cards/hardware_test.clj index f6bc916b66..1f5bb0bce5 100644 --- a/test/clj/game/cards/hardware_test.clj +++ b/test/clj/game/cards/hardware_test.clj @@ -1044,6 +1044,18 @@ (click-prompt state :runner "End the run") (is (:broken (first (:subroutines (refresh iw)))) "Ice Wall has been broken")))) +(deftest borrowed-goods-test + (do-game + (new-game {:runner {:hand [(qty "Borrowed Goods" 4)]}}) + (take-credits state :corp) + (is (changed? [(count-tags state) 1] + (play-from-hand state :runner "Borrowed Goods")) + "Took a tag") + (dotimes [_ 3] + (is (changed? [(count-tags state) 0] + (play-from-hand state :runner "Borrowed Goods")) + "Did not take a tag")))) + (deftest box-e ;; Box-E - +2 MU, +2 max hand size (do-game From 4fba0844e2c1a578f2c3bc91275d1735aa8ae6d7 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 23 Feb 2026 17:39:07 +1300 Subject: [PATCH 29/48] rules says there will be an upadte to make flagship not silly with cupellation --- src/clj/game/cards/upgrades.clj | 67 ++++++++++++++++----------------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/src/clj/game/cards/upgrades.clj b/src/clj/game/cards/upgrades.clj index 42bf08ce9a..71eb654a75 100644 --- a/src/clj/game/cards/upgrades.clj +++ b/src/clj/game/cards/upgrades.clj @@ -22,7 +22,7 @@ [game.core.eid :refer [effect-completed get-ability-targets is-basic-advance-action? make-eid]] [game.core.engine :refer [dissoc-req pay register-default-events register-events resolve-ability unregister-events]] - [game.core.events :refer [first-event? first-run-event? no-event? turn-events run-event-count]] + [game.core.events :refer [first-event? first-run-event? no-event? turn-events run-event-count run-events]] [game.core.finding :refer [find-cid find-latest]] [game.core.flags :refer [clear-persistent-flag! is-scored? register-persistent-flag! register-run-flag!]] @@ -726,43 +726,40 @@ :successful (give-tags 2)}}}) (defcard "Flagship" - (let [lockdown (fn [state side card] - (register-lingering-effect - state side card - {:type :disable-random-accesses - :value true - :duration :end-of-run}) - (register-lingering-effect - state side card - {:type :disable-access-candidacy - :req (req (not (same-card? card target))) - :duration :end-of-run - :value true})) - ev {:event :post-access-card - :once :per-run - :msg "prevent the Runner from accessing other cards this run" - :req (req (and run this-server - (not (same-card? card target)))) - :effect (req ;; random accesses get disabled after your first access that is not this card - (lockdown state side card))}] - ;; NOTE: Based on the CR, this invalidates all other cards as access candidates - ;; when you touch a card other than this card. - ;; This means that if you cupellate this, you dodge it, - ;; but if you access another card first, then cupellate this, you do not + (let [other-cards-accessed (fn [state card] (map :cid (filter #(not= (:cid %) (:cid card)) (apply concat (run-events state :runner :access))))) + prevent-random {:type :disable-random-accesses + :value true + :req (req (and run this-server (seq (other-cards-accessed state card))))} + prevent-installed {:type :disable-access-candidacy + :value true + :req (req (and run this-server + (not (same-card? card target)) + (seq (other-cards-accessed state card))))}] {:static-abilities [{:type :block-successful-run :req (req this-server) - :value true}] - :events [ev] + :value true} + prevent-random + prevent-installed] :on-trash {:req (req (and run (= :runner side))) - :effect (req (if (>= (run-event-count state side :access) 2) - (lockdown state side card) - (register-events - state side card - [{:duration :end-of-run - :event :access - :req (req run) - :effect (req (lockdown state side card))}])))}})) - + :effect (req + (let [c (:card context)] + (register-lingering-effect + state side (:card context) + {:type :disable-random-accesses + :value true + :duration :end-of-run + :req (req + (and run + (= (:server run) [(second (get-zone c))]) + (seq (other-cards-accessed state c))))}) + (register-lingering-effect + state side (:card context) + {:type :disable-access-candidacy + :value true + :duration :end-of-run + :req (req (and run + (= (:server run) [(second (get-zone c))]) + (seq (other-cards-accessed state c))))})))}})) (defcard "Fractal Threat Matrix" {:events [{:event :subroutines-broken From 13be4c37c8847d99a9c10755a6e7eb063761da4c Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 23 Feb 2026 17:40:34 +1300 Subject: [PATCH 30/48] dont prune cards from the set of already accessed cards --- src/clj/game/cards/events.clj | 1 - src/clj/game/core/access.clj | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/clj/game/cards/events.clj b/src/clj/game/cards/events.clj index de9a157772..e257c5f19a 100644 --- a/src/clj/game/cards/events.clj +++ b/src/clj/game/cards/events.clj @@ -286,7 +286,6 @@ :duration :end-of-run :change-in-game-state {:silent true :req (req (get-card state installed-card))} - :async true :msg (msg "add " (:title installed-card) " to the top of the stack") :effect (req (move state side installed-card :deck {:front true}))}]}) card nil))))}}) diff --git a/src/clj/game/core/access.clj b/src/clj/game/core/access.clj index e6c1a5d356..fd67e574d6 100644 --- a/src/clj/game/core/access.clj +++ b/src/clj/game/core/access.clj @@ -629,7 +629,6 @@ [state {:keys [chosen random-access-limit] :as access-amount} already-accessed {:keys [no-root] :as args}] (let [current-available (set (concat (if-not (any-effects state :runner :disable-random-accesses true? {:server :rd}) (map :cid (get-in @state [:corp :deck])) []) (map :cid (root-content state :rd)))) - already-accessed (clj-set/intersection already-accessed current-available) already-accessed-fn (fn [card] (contains? already-accessed (:cid card))) deck (access-cards-from-rd state) @@ -820,7 +819,6 @@ (get-in @state [:corp :hand])) current-available (set (concat (map :cid hand) (map :cid (root-content state :hq)))) - already-accessed (clj-set/intersection already-accessed current-available) already-accessed-fn (fn [card] (contains? already-accessed (:cid card))) From df7610445846df41a9f2475f163f144eb5bb968a Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 23 Feb 2026 18:12:51 +1300 Subject: [PATCH 31/48] touchstone, rotary --- src/clj/game/cards/hardware.clj | 27 +++++++++++++++++++++++++- test/clj/game/cards/hardware_test.clj | 28 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/clj/game/cards/hardware.clj b/src/clj/game/cards/hardware.clj index 5bbfd04306..5f0b79ab44 100644 --- a/src/clj/game/cards/hardware.clj +++ b/src/clj/game/cards/hardware.clj @@ -52,7 +52,7 @@ get-current-encounter jack-out make-run successful-run-replace-breach total-cards-accessed]] [game.core.say :refer [play-sfx system-msg]] - [game.core.servers :refer [target-server is-central?]] + [game.core.servers :refer [target-server is-central? zone->name]] [game.core.shuffling :refer [shuffle!]] [game.core.tags :refer [gain-tags lose-tags]] [game.core.threat :refer [threat-level]] @@ -2215,6 +2215,23 @@ :effect (effect (continue-ability ability card nil))}] :abilities [ability]})) +(defcard "Rotary" + {:static-abilities [(mu+ 1)] + :events [{:event :breach-server + :automatic :pre-breach + :optional {:req (req (or (= target :rd) (= target :hq))) + :prompt "Tag 1 tag to see an additional card?" + :yes-ability {:cost [(->c :gain-tag 1)] + :msg (msg "access 1 additional card from " (zone->name target)) + :effect (effect (access-bonus target 1))}}}] + :corp-abilities [{:action true + :label "Trash Rotary" + :async true + :cost [(->c :click 1) (->c :credit 2)] + :req (req (and tagged (= :corp side))) + :effect (effect (system-msg :corp "spends [Click] and 2 [Credits] to trash Rotary") + (trash :corp eid card {:cause-card card}))}]}) + (defcard "Rubicon Switch" {:abilities [{:action true :cost [(->c :click 1)(->c :x-credits)] @@ -2656,6 +2673,14 @@ (effect-completed state nil eid) (access-card state side eid (nth (:deck corp) (dec (str->int target))) "an unseen card")))}})]}) +(defcard "Touchstone" + {:events [{:event :play-event + :req (req (first-event? state side :play-event)) + :async true + :effect (req (add-counter state side eid card :credit 1))}] + :interactions {:pay-credits {:req (req run) + :type :credit}}}) + (defcard "Turntable" {:static-abilities [(mu+ 1)] :events [{:event :agenda-stolen diff --git a/test/clj/game/cards/hardware_test.clj b/test/clj/game/cards/hardware_test.clj index 1f5bb0bce5..617f1df5e9 100644 --- a/test/clj/game/cards/hardware_test.clj +++ b/test/clj/game/cards/hardware_test.clj @@ -4866,6 +4866,20 @@ (is (= 0 (count (:hand (get-runner))))) (is (= ["Easy Mark" "Ika"] (map :title (:discard (get-runner))))))) +(deftest rotary-test + (do-game + (new-game {:runner {:hand ["Rotary"]} + :corp {:hand [(qty "IPO" 4)] + :deck ["IPO" "IPO" "IPO"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Rotary") + (run-empty-server state :rd) + (is (changed? [(count-tags state) 1] + (click-prompt state :runner "Yes")) + "Tag on") + (click-prompt state :runner "No action") + (click-prompt state :runner "No action"))) + (deftest rubicon-switch ;; Rubicon Switch (do-game @@ -5823,6 +5837,20 @@ (is (= 3 (:agenda-point (get-runner))) "Runner got 3 points") (is (= 2 (count (:scored (get-runner)))) "Runner got 2 cards in score area"))) +(deftest touchstone-test + (do-game + (new-game {:runner {:hand ["Touchstone" "Clean Getaway"]} + :corp {:hand ["PAD Campaign"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Touchstone") + (play-from-hand state :runner "Clean Getaway") + (click-prompt state :runner "HQ") + (run-continue-until state :success) + (is (changed? [(:credit (get-runner)) -3] + (do-trash-prompt state 4) + (click-card state :runner "Touchstone")) + "3 + 1 for touchstone"))) + (deftest turntable ;; Turntable - Swap a stolen agenda for a scored agenda (do-game From 24c515857cfbda6cc6781c1ecc2b07ab5db29fbf Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Mon, 23 Feb 2026 18:46:52 +1300 Subject: [PATCH 32/48] knowledge seeker --- src/clj/game/cards/ice.clj | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index 5c0ad23bb6..159ffbf22a 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -2697,6 +2697,27 @@ :effect (effect (continue-ability on-rez-ability card nil))} :no-ability {:effect (effect (system-msg :corp (str "declines to use " (:title card))))}}}})) +(defcard "Knowledge Seeker" + {:events [{:event :end-of-encounter + :req (req (and (= (:ice context) card) + (>= (get-counters card :virus) 3))) + :interactive (req true) + :async true + :msg "purge virus counters and derez itself" + :effect (req (wait-for (derez state side card) + (play-sfx state side "virus-purge") + (purge state side eid)))}] + :subroutines [{:label "Place 1 virus counter on this card" + :msg "place 1 virus counter on itself" + :effect (req (add-counter state side eid card :virus 1)) + :async true} + {:label "Rearrange the top 4 cards of R&D" + :async true + :waiting-prompt true + :change-in-game-state {:silent true :req (req (seq (:deck corp)))} + :effect (req (resolve-ability state side eid (reorder-choice :corp (take 4 (:deck corp))) card targets))} + end-the-run]}) + (defcard "Komainu" {:on-encounter {:interactive (req true) :effect (req (let [sub-count (count (:hand runner))] From 9bda6ed03d5853b59f8787c82f55cef6521c9f39 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Tue, 24 Feb 2026 07:15:22 +1300 Subject: [PATCH 33/48] Ansel 2.0, baker --- src/clj/game/cards/ice.clj | 15 +++++++++ src/clj/game/cards/programs.clj | 21 ++++++++++++ test/clj/game/cards/ice_test.clj | 20 +++++++++++ test/clj/game/cards/programs_test.clj | 48 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index 159ffbf22a..ac0a99dc1c 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -743,6 +743,21 @@ cannot-steal-or-trash-sub] :runner-abilities [(bioroid-break 1 1)]}) +(defcard "Ansel 2.0" + {:runner-abilities [(bioroid-break 2 2)] + :subroutines [trash-installed-sub + {:label "Remove 1 card in the Heap from the game" + :change-in-game-state {:req (req (and (seq (:discard runner)) + (not (zone-locked? state :runner :discard))))} + :prompt "Choose a card in the heap to remove from the game" + :show-other-player-discard true + :waiting-prompt true + :choices {:card (every-pred runner? in-discard?)} + :msg (msg "remove " (:title target) " from the game") + :effect (req (move state :runner target :rfg))} + (install-from-hq-or-archives-sub) + end-the-run]}) + (defcard "Anvil" (letfn [(encounter-ab [] {:optional {:prompt "Trash another card?" diff --git a/src/clj/game/cards/programs.clj b/src/clj/game/cards/programs.clj index c3793a011a..bb53d64385 100644 --- a/src/clj/game/cards/programs.clj +++ b/src/clj/game/cards/programs.clj @@ -559,6 +559,27 @@ :hosted-gained gain-abis :hosted-lost gain-abis})) +(defcard "Baker" + (letfn [(switch-server [key serv] + {:option (str "Switch to " serv) + :cost [(->c :credit 1 {:stealth :all-stealth})] + :ability {:msg (str "change the attacked server to " serv) + :effect (req (swap! state assoc-in [:run :server] [key]))}})] + {:abilities [(run-server-ability + :archives + {:action true + :cost [(->c :click 1)] + :once :per-turn + :events [(choose-one-helper + {:event :pre-approach-server + :req (req (= :archives (-> run :server first))) + :duration :end-of-run + :unregister-once-resolved true + :interactive (req true) + :optional true} + [(switch-server :hq "HQ") + (switch-server :rd "R&D")])]})]})) + (defcard "Bankroll" {:special {:auto-place-credit :always} :events [{:event :successful-run diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index 2417f16d2b..f5b703044d 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -608,6 +608,26 @@ (is (waiting? state :runner) "Runner has prompt to wait for Corp to use Ganked!")))) +(deftest ansel-2.0-subs-test + (testing "trash 1 installed card" + (do-game + (subroutine-test "Ansel 2.0" 0 nil {:rig ["Fermenter"]}) + (click-card state :corp "Fermenter") + (is (= "Fermenter" (->> (get-runner) :discard first :title)) "Trashed fermenter"))) + (testing "remove a card in the heap from the game" + (do-game + (subroutine-test "Ansel 2.0" 1 {:runner {:discard ["Fermenter" "Ika" "Rezeki"]}}) + (click-card state :corp "Fermenter") + (is (= ["Ika" "Rezeki"] (->> (get-runner) :discard (mapv :title))) "Only ika/zeki in bin"))) + (testing "install a card from HQ or Archives" + (doseq [zone [:hand :discard]] + (do-game + (subroutine-test "Ansel 2.0" 2 {:corp {zone ["PAD Campaign"]}}) + (click-card state :corp "PAD Campaign") + (click-prompt state :corp "New remote")))) + (testing "end the run" + (do-game (etr-sub "Ansel 2.0" 3)))) + (deftest anvil (do-game (new-game {:corp {:hand ["Anvil" "Ice Wall"]} diff --git a/test/clj/game/cards/programs_test.clj b/test/clj/game/cards/programs_test.clj index b00363af79..fec3bcae8f 100644 --- a/test/clj/game/cards/programs_test.clj +++ b/test/clj/game/cards/programs_test.clj @@ -1003,6 +1003,54 @@ (card-ability state :runner (refresh baba) 2)) "Spent 1c to boost baba yaga")))) +(deftest baker-stealth-hq + (do-game + (new-game {:runner {:hand ["Baker" "Mantle"]} + :corp {:hand [(qty "Rashida Jaheem" 3)] + :deck ["Hostile Takeover"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Baker") + (play-from-hand state :runner "Mantle") + (card-ability state :runner (get-program state 0) 0) + (run-continue-until state :success) + (click-prompt state :runner "Pay 1 [Credits]: Switch to HQ") + (click-card state :runner "Mantle") + (do-trash-prompt state 1) + (run-empty-server state :archives) + (is (no-prompt? state :runner)))) + +(deftest baker-stealth-rd + (do-game + (new-game {:runner {:hand ["Baker" "Mantle"]} + :corp {:hand [(qty "Rashida Jaheem" 3)] + :deck ["Hostile Takeover"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Baker") + (play-from-hand state :runner "Mantle") + (card-ability state :runner (get-program state 0) 0) + (run-continue-until state :success) + (click-prompt state :runner "Pay 1 [Credits]: Switch to R&D") + (click-card state :runner "Mantle") + (click-prompt state :runner "Steal") + (run-empty-server state :archives) + (is (no-prompt? state :runner)))) + +(deftest baker-vs-skunkworks + (do-game + (new-game {:runner {:hand ["Baker" "Mantle"]} + :corp {:hand [(qty "Rashida Jaheem" 3) "Manegarm Skunkworks"] + :deck ["Hostile Takeover"]}}) + (play-cards state :corp ["Manegarm Skunkworks" "HQ" :rezzed]) + (take-credits state :corp) + (play-from-hand state :runner "Baker") + (play-from-hand state :runner "Mantle") + (card-ability state :runner (get-program state 0) 0) + (run-continue-until state :success) + (click-prompt state :runner "Pay 1 [Credits]: Switch to HQ") + (click-card state :runner "Mantle") + (click-prompt state :runner "End the run") + (is (not (:run @state)) "Skunkworks fired on HQ approach"))) + (deftest bankroll ;; Bankroll - Includes check for Issue #4334 (do-game From f05934bf4471df88447155f07d37341bf137797c Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Tue, 24 Feb 2026 18:47:11 +1300 Subject: [PATCH 34/48] tocsin, viksek, vertigo --- src/clj/game/cards/ice.clj | 74 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index ac0a99dc1c..558be0b504 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -4315,6 +4315,44 @@ :effect (effect (derez eid card))}}} :subroutines [end-the-run]}) +(defcard "Tocsin" + (letfn [(next-t [t] (when (= t "Barrier") "Sentry")) + (search-for-type [t chosen] + (if t + {:prompt (str "Pick a " t " to add to HQ") + :choices (req (cancellable (filter #(has-subtype? % t) (:deck corp)) :sorted)) + :async true + :effect (req (continue-ability state side (search-for-type (next-t t) (conj chosen target)) card nil)) + :cancel {:async true + :effect (req (continue-ability + state side + (search-for-type (next-t t) chosen) + card nil))}} + (choose-one-helper + {:prompt (if (seq chosen) + (str "You will tutor " (enumerate-cards chosen)) + "You will shuffle R&D")} + [{:option "OK" + :ability (if (seq chosen) + {:async true + :effect (req (wait-for + (reveal-loud state side card {:and-then ", and add [them] to HQ"} (vec chosen)) + (doseq [c chosen] (move state :corp c :hand)) + (shuffle! state :corp :deck) + (effect-completed state side eid)))} + {:msg "shuffle R&D" + :effect (req (shuffle! state side :deck))})} + {:option "I want to start over" + :ability (search-for-type "Barrier" #{})}])))] + {:subroutines [(runner-loses-credits 2) + end-the-run + end-the-run] + :expend {:change-in-game-state {:req (req (seq (:deck corp)))} + :cost [(->c :credit 1)] + :msg "search R&D for up to 1 barrier and up to 1 sentry" + :async true + :effect (req (continue-ability state side (search-for-type "Barrier" #{}) card nil))}})) + (defcard "Tollbooth" {:on-encounter {:async true :effect (req (wait-for (pay state :runner (make-eid state eid) card [(->c :credit 3)]) @@ -4535,6 +4573,42 @@ (runner-loses-credits 2) (trace-ability 2 (give-tags 1))]}) +(defcard "Vertigo" + ;; "When the Runner passes this ice, if they have no remaining {click}, they + ;; cannot steal or trash cards for the remainder of this run. + ;; {sub} The Runner loses {click}." + {:events [{:event :pass-ice + :req (req (same-card? (:ice context) card)) + :change-in-game-state {:silent true :req (req (zero? (:click runner)))} + :msg "prevent the Runner from stealing or trashing Corp cards for the remainder of the run" + :effect (effect (register-run-flag! + card :can-steal + (fn [state _side _card] + ((constantly false) + (toast state :runner "Cannot steal due to Vertigo." "warning")))) + (register-run-flag! + card :can-trash + (fn [state _side card] + ((constantly (not (corp? card))) + (toast state :runner "Cannot trash due to Vertigo." "warning")))))}] + :subroutines [runner-loses-click]}) + +(defcard "Vicsek" + {:subroutines [{:label "Do X damage and give the Runner X tags." + :async true + :change-in-game-state {:silent true :req (req tagged)} + :effect (req (let [x (count-tags state)] + (wait-for (gain-tags state :side x {:suppress-checkpoint true}) + (damage state side eid :net x))))} + {:label "Give the Runner 1 tag. Trash this ice." + :async true + :msg (msg "give the runner 1 tag") + :effect (req (wait-for + (gain-tags state side 1) + (wait-for + (trash state side card {:cause :subroutine}) + (encounter-ends state side eid))))}]}) + (defcard "Vikram 1.0" {:implementation "Program prevention is not implemented" :subroutines [{:msg "prevent the Runner from using programs for the remainder of this run"} From 78305e4629d0118663c5e115ab792ee19d63e619 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Tue, 24 Feb 2026 18:47:19 +1300 Subject: [PATCH 35/48] unit tests for the ice --- test/clj/game/cards/ice_test.clj | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index f5b703044d..28b4abe640 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -8154,6 +8154,31 @@ (click-prompt state :runner "2") (is (not (rezzed? (refresh tmi))))))) +(deftest tocsin-sub-0-lose-2-creds + (do-game + (subroutine-test "Tocsin" 0 {:runner {:credits 3}}) + (is (= 1 (:credit (get-runner))) "lost 2 credits"))) + +(deftest tocsin-sub-1-etr (do-game (new-game (etr-sub "Tocsin" 1)))) +(deftest tocsin-sub-2-etr (do-game (new-game (etr-sub "Tocsin" 2)))) + +(deftest tocsin-expend-ability + (do-game + (new-game {:corp {:hand ["Tocsin"] + :deck ["Guard" "Ice Wall"]}}) + (expend state :corp (first (:hand (get-corp)))) + (click-prompts state :corp "Ice Wall" "Guard" "OK") + (is-hand? state :corp ["Guard" "Ice Wall"]))) + +(deftest tocsin-expend-ability-with-cancel + (do-game + (new-game {:corp {:hand ["Tocsin"] + :deck ["Guard" "Ice Wall"]}}) + (expend state :corp (first (:hand (get-corp)))) + (click-prompts state :corp "Ice Wall" "Guard" "I want to start over" "Ice Wall" "Cancel" "OK") + (is-hand? state :corp ["Ice Wall"]))) + + (deftest tour-guide-rez-before-other-assets ;; Rez before other assets (do-game @@ -8718,6 +8743,35 @@ (card-subroutine state :corp (refresh vas) 0) (is (= 1 (count-tags state)) "Runner took 1 tag")))) +(deftest vertigo-sub-0-lose-click + (do-game + (subroutine-test "Vertigo" 0) + (is (= 2 (:click (get-runner))) "Lost a click"))) + +(deftest vertigo-skill-issue + (doseq [target ["Rashida Jaheem" "Project Atlas"]] + (do-game + (new-game {:corp {:hand [target "Vertigo"]}}) + (play-from-hand state :corp "Vertigo" "HQ") + (take-credits state :corp) + (rez state :corp (get-ice state :hq 0)) + (core/lose state :runner :click 3) + (run-on state :hq) + (run-continue-until state :success) + (is (= ["No action"] (prompt-titles :runner)) "Cannot trash/steal due to Vertigo")))) + +(deftest vicsek-test-x-damage-and-x-tags + (dotimes [x 10] + (do-game (subroutine-test "Vicsek" 0 {:runner {:tags x :hand (inc x)}}) + (is (= x (count (:discard (get-runner))))) + (is (= (* 2 x) (count-tags state)))))) + +(deftest viksek-give-a-tag-and-trash-itself + (dotimes [x 10] + (do-game (subroutine-test "Vicsek" 1 {:runner {:tags x}}) + (is (= (inc x) (count-tags state))) + (is (= "Vicsek" (:title (first (:discard (get-corp))))) "Trashed itself")))) + (deftest virtual-service-agent (do-game (new-game {:corp {:hand ["Virtual Service Agent"]} From fa92c9f1651f65e7a7113f68ead6a8ef8f4f0c14 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 25 Feb 2026 05:47:02 +1300 Subject: [PATCH 36/48] esca --- src/clj/game/cards/assets.clj | 14 ++++++++++++++ test/clj/game/cards/assets_test.clj | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/clj/game/cards/assets.clj b/src/clj/game/cards/assets.clj index b9742fa647..56720abda1 100644 --- a/src/clj/game/cards/assets.clj +++ b/src/clj/game/cards/assets.clj @@ -1071,6 +1071,20 @@ :req (req (installed? target)) :value 1}]}) +(defcard "Esca" + {:flags {:rd-reveal (req true)} + :poison true + :on-access {:msg "force the Runner to lose 1 [Credits]" + :async true + :effect (req (wait-for (lose-credits state :runner 1) + (continue-ability + state side + {:req (req tagged) + :msg "do 1 net damage" + :async true + :effect (req (damage state side eid :net 1))} + card nil)))}}) + (defcard "Estelle Moon" {:events [{:event :corp-install :req (req (and (or (asset? (:card context)) diff --git a/test/clj/game/cards/assets_test.clj b/test/clj/game/cards/assets_test.clj index 91a79673a7..d4d3bbf723 100644 --- a/test/clj/game/cards/assets_test.clj +++ b/test/clj/game/cards/assets_test.clj @@ -1904,6 +1904,18 @@ (is (= 3 (core/trash-cost state :runner (refresh ep2))) "Trash cost increased to 3 by one active Encryption Protocol")))) +(deftest esca + (doseq [[tags damage] [[0 0] [1 1] [15 1]]] + (do-game + (new-game {:corp {:discard ["Esca"]} + :runner {:hand ["Ika" "Ika"] + :tags tags}}) + (take-credits state :corp) + (is (changed? [(:credit (get-runner)) -1 + (count (:hand (get-runner))) (- damage)] + (run-empty-server state :archives)) + "Tanked it")))) + (deftest estelle-moon ;; Estelle Moon (letfn [(estelle-test [number] From 046c8625d76137541786dad25e57837261eeafb1 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 25 Feb 2026 05:59:49 +1300 Subject: [PATCH 37/48] unleash and tests --- src/clj/game/cards/operations.clj | 23 ++++++++++++++++++++++- test/clj/game/cards/operations_test.clj | 11 +++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/clj/game/cards/operations.clj b/src/clj/game/cards/operations.clj index 95ae8bc83e..a8f3096766 100644 --- a/src/clj/game/cards/operations.clj +++ b/src/clj/game/cards/operations.clj @@ -29,7 +29,7 @@ [game.core.gaining :refer [gain-clicks gain-credits lose-clicks lose-credits]] [game.core.hand-size :refer [runner-hand-size+]] - [game.core.ice :refer [update-all-ice]] + [game.core.ice :refer [resolve-subroutine! unbroken-subroutines-choice update-all-ice]] [game.core.identities :refer [disable-identity enable-identity]] [game.core.initializing :refer [ability-init card-init]] [game.core.installing :refer [corp-install corp-install-msg install-as-condition-counter]] @@ -3307,6 +3307,27 @@ :async true :effect (req (gain-bad-publicity state side eid 1))}}}) +(defcard "Unleash" + {:on-play {:additional-cost [(->c :tag 1)] + :change-in-game-state {:req (req (some (every-pred ice? (complement rezzed?)) (all-installed state :corp)))} + :choices {:card (every-pred ice? installed? (complement rezzed?))} + :async true + :effect (req (wait-for + (rez state side target {:ignore-cost :all-costs}) + (let [rezzed-card (get-card state target)] + (if (and rezzed-card (rezzed? rezzed-card) (seq (:subroutines rezzed-card))) + (continue-ability + state side + {:prompt "Choose a subroutine to resolve" + :choices (req (unbroken-subroutines-choice rezzed-card)) + :msg (msg "resolve the subroutine (\"[subroutine] " + target "\") from " (:title rezzed-card)) + :async true + :effect (req (let [sub (first (filter #(= target (make-label (:sub-effect %))) (:subroutines rezzed-card)))] + (resolve-subroutine! state side eid rezzed-card (assoc sub :external-trigger true))))} + card nil) + (effect-completed state side eid)))))}}) + (defcard "Violet Level Clearance" {:on-play (clearance 8 4)}) diff --git a/test/clj/game/cards/operations_test.clj b/test/clj/game/cards/operations_test.clj index 23632dad09..5ff2ce3669 100644 --- a/test/clj/game/cards/operations_test.clj +++ b/test/clj/game/cards/operations_test.clj @@ -5622,6 +5622,17 @@ (is (= 2 (count (:discard (get-runner)))) "Runner has 2 trashed cards") (is (= 1 (count-bad-pub state)) "Corp takes 1 bad pub"))) +(deftest unleash-test + (do-game + (new-game {:corp {:hand ["Unleash" "Neural Katana"]} + :runner {:hand [(qty "Ika" 5)] :tags 1}}) + (play-from-hand state :corp "Neural Katana" "HQ") + (is (changed? [(count (:hand (get-runner))) -3 + (count-tags state) -1] + (play-from-hand state :corp "Unleash") + (click-prompts state :corp "Neural Katana" "Do 3 net damage")) + "Neural katana was unleashed"))) + (deftest violet-level-clearance ;; Violet Level Clearance (do-game From e3306de5e38fed931e5628ff8e5d95838b24a74d Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 25 Feb 2026 06:00:25 +1300 Subject: [PATCH 38/48] ezaM and tests --- src/clj/game/cards/ice.clj | 28 +++++++++++++++++++++++++++ test/clj/game/cards/ice_test.clj | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index 558be0b504..a3cefcf74e 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -1806,6 +1806,34 @@ (defcard "Executive Functioning" {:subroutines [(trace-ability 4 (do-brain-damage 1))]}) +(defcard "ezaM" + {:subroutines [{:label "Look at the top card of R&D" + :change-in-game-state {:silent true :req (req (seq (:deck corp)))} + :waiting-prompt true + :prompt (msg "The top card of R&D is " (get-in @state [:corp :deck 0 :title])) + :choices (req [(when (not= (count (:deck corp)) 1) + "Place it on the bottom of R&D") + "Done"]) + :msg (msg "look at the top card of R&D" (when-not (= target "Done") " and add it to the bottom of R&D")) + :effect (req (when-not (= target "Done") (move state side (first (:deck corp)) :deck)))} + {:label "Each piece of ice gets +1 strength for the remainder of this run." + :msg "give +1 strength to all ice for the remainder of the run" + :effect (effect (register-lingering-effect + card + (let [c-ice card] + {:type :ice-strength + :duration :end-of-run + :value 1})) + (update-all-ice))}] + :abilities [{:cost [(->c :click 1)] + :action true + :label "Swap this ice with another installed ice." + :choices {:req (req (and (ice? target) + (installed? target) + (not (same-card? card target))))} + :msg (msg "swap itself with " (card-str state target)) + :effect (req (swap-ice state side card target))}]}) + (defcard "F2P" {:subroutines [add-runner-card-to-grip (give-tags 1)] diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index 28b4abe640..f8e2b2ec6b 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -2451,6 +2451,39 @@ (run-on state "HQ") (is (:run @state) "Run initiated ok")))) +(deftest ezam-subroutines-test + (testing "Look at the top card of R&D. Place it on the bottom." + (do-game + (subroutine-test "ezaM" 0 {:corp {:deck ["PAD Campaign" "IPO"]}}) + (let [d (map :title (:deck (get-corp)))] + (click-prompt state :corp "Place it on the bottom of R&D") + (is-deck? state :corp (reverse d))))) + (testing "Look at the top card of R&D. Leave it there." + (do-game + (subroutine-test "ezaM" 0 {:corp {:deck ["PAD Campaign" "IPO"]}}) + (let [d (map :title (:deck (get-corp)))] + (click-prompt state :corp "Done") + (is-deck? state :corp d)))) + (testing "Give ice +1 strength." + (do-game + (subroutine-test "ezaM" 1) + (is (= (get-strength (get-ice state :hq 0)) (+ 1 (:strength (get-ice state :hq 0)))) + "Ice strength boosted") + (run-continue-until state :success) + (is (= (get-strength (get-ice state :hq 0)) (:strength (get-ice state :hq 0))) + "Ice strength reset")))) + +(deftest ezam-swaps-with-other-ice + (do-game + (new-game {:corp {:hand ["ezaM" "Vanilla"]}}) + (play-from-hand state :corp "ezaM" "HQ") + (play-from-hand state :corp "Vanilla" "Archives") + (rez state :corp (get-ice state :hq 0)) + (card-ability state :corp (get-ice state :hq 0) 0) + (click-card state :corp "Vanilla") + (is (= "Vanilla" (:title (get-ice state :hq 0)))) + (is (= "ezaM" (:title (get-ice state :archives 0)))))) + (deftest f2p ;; F2P (do-game From d88fb111475cc3c0b400e78174929df90869c58a Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 25 Feb 2026 06:00:41 +1300 Subject: [PATCH 39/48] The Red Room and tests --- src/clj/game/cards/upgrades.clj | 18 ++++++++++++++++++ test/clj/game/cards/upgrades_test.clj | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/clj/game/cards/upgrades.clj b/src/clj/game/cards/upgrades.clj index 71eb654a75..89d42cee4c 100644 --- a/src/clj/game/cards/upgrades.clj +++ b/src/clj/game/cards/upgrades.clj @@ -1840,6 +1840,24 @@ {:abilities [abi] :events [(mobile-sysop-event :corp-turn-begins)]})) +(defcard "The Red Room" + {:install-req (req (filter #{"R&D" "HQ" "Archives"} targets)) + :events [{:event :agenda-stolen + :async true + :effect (req (add-counter state side eid card :power 1)) + :req (req (and (first-event? state side :agenda-stolen) + (no-event? state side :agenda-scored)))} + {:event :agenda-scored + :async true + :effect (req (add-counter state side eid card :power 1)) + :req (req (and (first-event? state side :agenda-scored) + (no-event? state side :agenda-stolen)))}] + :abilities [{:cost [(->c :power 1)] + :req (req (and run (not this-server))) + :async true + :effect (req (end-run state side eid card)) + :msg "End the run"}]}) + (defcard "The Twins" {:events [{:event :pass-ice :optional diff --git a/test/clj/game/cards/upgrades_test.clj b/test/clj/game/cards/upgrades_test.clj index 43c948a220..d88435229f 100644 --- a/test/clj/game/cards/upgrades_test.clj +++ b/test/clj/game/cards/upgrades_test.clj @@ -4280,6 +4280,18 @@ (click-card state :corp van)) "Corp placed 2 advancement counters on Vanilla"))))) +(deftest the-red-room-test + (do-game + (new-game {:corp {:hand ["The Red Room" "Hostile Takeover"]}}) + (play-from-hand state :corp "The Red Room" "R&D") + (rez state :corp (get-content state :rd 0)) + (play-and-score state "Hostile Takeover") + (click-prompt state :corp "Hostile Takeover") + (take-credits state :corp) + (run-on state :hq) + (card-ability state :corp (get-content state :rd 0) 0) + (is (not (:run @state)) "Ended the run"))) + (deftest the-twins ;; The Twins (do-game From 2e0875030f808613a5ae696eb26fc2debe005741 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Wed, 25 Feb 2026 07:32:13 +1300 Subject: [PATCH 40/48] show other player discard option for prompts --- src/clj/game/cards/ice.clj | 5 +++-- src/clj/game/core/diffs.clj | 1 + src/clj/game/core/prompts.clj | 4 +++- src/cljs/nr/gameboard/board.cljs | 15 +++++++++++---- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index a3cefcf74e..6335a0c851 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -747,10 +747,11 @@ {:runner-abilities [(bioroid-break 2 2)] :subroutines [trash-installed-sub {:label "Remove 1 card in the Heap from the game" - :change-in-game-state {:req (req (and (seq (:discard runner)) + :change-in-game-state {:silent true + :req (req (and (seq (:discard runner)) (not (zone-locked? state :runner :discard))))} :prompt "Choose a card in the heap to remove from the game" - :show-other-player-discard true + :show-opponent-discard true :waiting-prompt true :choices {:card (every-pred runner? in-discard?)} :msg (msg "remove " (:title target) " from the game") diff --git a/src/clj/game/core/diffs.clj b/src/clj/game/core/diffs.clj index a4c728936f..2ca7f51911 100644 --- a/src/clj/game/core/diffs.clj +++ b/src/clj/game/core/diffs.clj @@ -230,6 +230,7 @@ :card :prompt-type :show-discard + :show-opponent-discard :selectable :eid ;; bad pub diff --git a/src/clj/game/core/prompts.clj b/src/clj/game/core/prompts.clj index 9f0f574089..22ae0e2d53 100644 --- a/src/clj/game/core/prompts.clj +++ b/src/clj/game/core/prompts.clj @@ -33,7 +33,7 @@ ([state side card message choices f] (show-prompt state side (make-eid state) card message choices f nil)) ([state side card message choices f args] (show-prompt state side (make-eid state) card message choices f args)) ([state side eid card message choices f - {:keys [waiting-prompt prompt-type show-discard cancel end-effect targets selectable offer-bad-pub?]}] + {:keys [waiting-prompt prompt-type show-discard show-opponent-discard cancel end-effect targets selectable offer-bad-pub?]}] (let [prompt (if (string? message) message (message state side eid card targets)) choices (choice-parser choices) selectable (update-selectable selectable choices) @@ -47,6 +47,7 @@ :offer-bad-pub? offer-bad-pub? :prompt-type (or prompt-type :other) :show-discard show-discard + :show-opponent-discard show-opponent-discard :cancel cancel :end-effect end-effect}] (when (or (#{:waiting :run} prompt-type) @@ -230,6 +231,7 @@ (assoc :prompt-type :select :offer-bad-pub? (:offer-bad-pub? ability) :selectable selectable-cards + :show-opponent-discard (:show-opponent-discard ability) :show-discard (:show-discard ability)) (wrap-function :cancel))))))) diff --git a/src/cljs/nr/gameboard/board.cljs b/src/cljs/nr/gameboard/board.cljs index 76d7a6e55f..b3bd971374 100644 --- a/src/cljs/nr/gameboard/board.cljs +++ b/src/cljs/nr/gameboard/board.cljs @@ -1981,7 +1981,9 @@ (let [autocomp (r/track (fn [] (get-in @prompt-state [:choices :autocomplete]))) show-discard? (r/track (fn [] (get-in @prompt-state [:show-discard]))) prompt-type (r/track (fn [] (get-in @prompt-state [:prompt-type]))) - opened-by-system (r/atom false)] + discard-opened-by-system (r/atom false) + show-opponent-discard? (r/track (fn [] (get-in @prompt-state [:show-opponent-discard]))) + opponent-discard-opened-by-system (r/atom false)] (r/create-class {:display-name "button-pane" @@ -1990,9 +1992,14 @@ (when (pos? (count @autocomp)) (-> "#card-title" js/$ (.autocomplete (clj->js {"source" @autocomp})))) (cond @show-discard? (do (-> ".me .discard-container .popup" js/$ .fadeIn) - (reset! opened-by-system true)) - @opened-by-system (do (-> ".me .discard-container .popup" js/$ .fadeOut) - (reset! opened-by-system false))) + (reset! discard-opened-by-system true)) + @discard-opened-by-system (do (-> ".me .discard-container .popup" js/$ .fadeOut) + (reset! discard-opened-by-system false))) + (cond @show-opponent-discard? (do (-> ".opponent .discard-container .popup" js/$ .fadeIn) + (reset! opponent-discard-opened-by-system true)) + @opponent-discard-opened-by-system (do (-> ".opponent .discard-container .popup" js/$ .fadeOut) + (reset! opponent-discard-opened-by-system false))) + (if (= "select" @prompt-type) (set! (.-cursor (.-style (.-body js/document))) "url('/img/gold_crosshair.png') 12 12, crosshair") (set! (.-cursor (.-style (.-body js/document))) "default")) From 98bc6bd1e68699bc696ea821ec48a17dce3eb2f9 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Thu, 26 Feb 2026 19:22:10 +1300 Subject: [PATCH 41/48] Methuselah test --- src/clj/game/cards/hardware.clj | 14 ++++++++++++++ test/clj/game/cards/hardware_test.clj | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/clj/game/cards/hardware.clj b/src/clj/game/cards/hardware.clj index 5f0b79ab44..455152a1a6 100644 --- a/src/clj/game/cards/hardware.clj +++ b/src/clj/game/cards/hardware.clj @@ -1612,6 +1612,20 @@ (defcard "MemStrips" {:static-abilities [(virus-mu+ 3)]}) +(defcard "Methuselah" + {:interactions {:pay-credits {:req (req run) + :type :credit}} + :events [{:event :run + :change-in-game-state {:req (req (seq (:hand runner))) :silent true} + :prompt "Trash a hardware from the Grip?" + :choices {:card (every-pred hardware? in-hand?)} + :async true + :waiting-prompt true + :msg (msg "trash " (:title target) " and place 2 [Credits] on itself") + :effect (req (wait-for (trash state side target {:unpreventable true}) + (add-counter state side eid card :credit 2)))}] + :static-abilities [(mu+ 1)]}) + (defcard "Mind's Eye" {:implementation "Power counters added automatically" :static-abilities [(mu+ 1)] diff --git a/test/clj/game/cards/hardware_test.clj b/test/clj/game/cards/hardware_test.clj index 617f1df5e9..c0bba785e0 100644 --- a/test/clj/game/cards/hardware_test.clj +++ b/test/clj/game/cards/hardware_test.clj @@ -3711,6 +3711,22 @@ (is (no-prompt? state :runner) "No more prompts for runner") (is (not (:run @state)) "Run is ended"))) +(deftest methuselah-test + (do-game + (new-game {:corp {:deck [(qty "PAD Campaign" 20)] :hand ["IPO"]} + :runner {:hand ["Mantle" "Methuselah" "DZMZ Optimizer"] + :credits 10}}) + (take-credits state :corp) + (play-from-hand state :runner "Mantle") + (play-from-hand state :runner "Methuselah") + (run-on state :rd) + (click-card state :runner "DZMZ Optimizer") + (run-continue-until state :success) + (click-prompts state :runner "Pay 4 [Credits] to trash") + (dotimes [_ 2] + (click-card state :runner "Methuselah")) + (is (no-prompt? state :runner) "Paid 2 with methuselah"))) + (deftest mind-s-eye-interaction-with-rdi-aeneas ;; Interaction with RDI + Aeneas (do-game From 157b1afba083617e7c3fe8462f377cf204acc582 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Thu, 26 Feb 2026 19:22:28 +1300 Subject: [PATCH 42/48] hype machine and test --- src/clj/game/cards/upgrades.clj | 13 +++++++++++++ test/clj/game/cards/upgrades_test.clj | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/clj/game/cards/upgrades.clj b/src/clj/game/cards/upgrades.clj index 89d42cee4c..8d8cc4bd41 100644 --- a/src/clj/game/cards/upgrades.clj +++ b/src/clj/game/cards/upgrades.clj @@ -924,6 +924,19 @@ :event :successful-run :req (req this-server))]}) +(defcard "Hype Machine" + {:rez-cost-bonus (req (if-not (and (no-event? state side :agenda-scored) + (no-event? state side :agenda-stolen)) + -6 + 0)) + :abilities [{:label "Place 1 advancement token on a card in this server" + :async true + :prompt "Choose a card in this server" + :choices {:req (req (in-same-server? card target))} + :msg (msg "place an advancement token on " (card-str state target)) + :cost [(->c :trash-can)] + :effect (effect (add-prop eid target :advance-counter 1 {:placed true}))}]}) + (defcard "Increased Drop Rates" {:flags {:rd-reveal (req true)} :poison true diff --git a/test/clj/game/cards/upgrades_test.clj b/test/clj/game/cards/upgrades_test.clj index d88435229f..59ef5d73da 100644 --- a/test/clj/game/cards/upgrades_test.clj +++ b/test/clj/game/cards/upgrades_test.clj @@ -2078,6 +2078,18 @@ (run-empty-server state :hq) (is (= 1 (count (:discard (get-runner)))) "1 net damage done for successful run on HQ"))) +(deftest hype-machine-test + (do-game + (new-game {:corp {:hand ["Hype Machine" (qty "Hostile Takeover" 2)]}}) + (play-from-hand state :corp "Hype Machine" "New remote") + (play-and-score state "Hostile Takeover") + (is (changed? [(:credit (get-corp)) 0] + (rez state :corp (get-content state :remote1 0)))) + (play-from-hand state :corp "Hostile Takeover" "Server 1") + (card-ability state :corp (get-content state :remote1 0) 0) + (click-card state :corp (get-content state :remote1 1)) + (is (= 1 (get-counters (get-content state :remote1 0) :advancement)) "1 adv"))) + (deftest increased-drop-rates ;; Increased Drop Rates (do-game From 6fe895187302b3e1be7cf88db4ba9e393d92adc3 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Thu, 26 Feb 2026 19:26:20 +1300 Subject: [PATCH 43/48] read-write share --- src/clj/game/cards/programs.clj | 42 +++++++++++++++++++++++++++ test/clj/game/cards/programs_test.clj | 14 +++++++++ 2 files changed, 56 insertions(+) diff --git a/src/clj/game/cards/programs.clj b/src/clj/game/cards/programs.clj index bb53d64385..f371b3893b 100644 --- a/src/clj/game/cards/programs.clj +++ b/src/clj/game/cards/programs.clj @@ -55,6 +55,7 @@ [game.core.say :refer [play-sfx system-msg]] [game.core.servers :refer [central->name is-central? is-remote? protecting-same-server? remote->name target-server unknown->kw zone->name]] + [game.core.set-aside :refer [get-set-aside set-aside-for-me]] [game.core.shuffling :refer [shuffle! shuffle-my-deck!]] [game.core.tags :refer [gain-tags lose-tags]] [game.core.to-string :refer [card-str]] @@ -2855,6 +2856,47 @@ :duration :while-active :value (req (get-counters card :power))}]})) +(defcard "Read-Write Share" + (let [ab {:interactive (req true) + :prompt "Host a card from your grip to draw a card?" + :choices {:req (req (and (runner? target) + (in-hand? target)))} + :msg "host a card facedown from the Grip and draw a card" + :async true + :effect (req (host state side (get-card state card) target {:facedown true}) + (wait-for (draw state side 1) + (if (>= (count (:hosted (get-card state card))) 5) + (continue-ability + state side + {:msg "trash itself" + :async true + :effect (req (trash state side eid card))} + card nil) + (effect-completed state side eid))))}] + {:on-install ab + :events [(assoc ab :event :runner-turn-begins)] + :abilities [{:trash-icon true + :label "Shuffle all hosted cards into the stack" + :interactive (req true) + :async true + :effect (req (if (seq (:hosted (get-card state card))) + (let [set-aside-cards (set-aside-for-me state side eid (:hosted (get-card state card))) + set-aside-cards (get-set-aside state side eid)] + (continue-ability + state side + {:cost [(->c :trash-can)] + :msg (str "shuffle " (quantify (count set-aside-cards) "hosted card") " into the Stack") + :effect (req (doseq [c set-aside-cards] + (move state side c :deck)) + (shuffle! state side :deck))} + (get-card state card) nil)) + (continue-ability + state side + {:cost [(->c :trash-can)] + :msg "shuffle the Stack" + :effect (req (shuffle! state side :deck))} + card nil)))}]})) + (defcard "Reaver" {:events [{:event :runner-trash :async true diff --git a/test/clj/game/cards/programs_test.clj b/test/clj/game/cards/programs_test.clj index fec3bcae8f..255f588478 100644 --- a/test/clj/game/cards/programs_test.clj +++ b/test/clj/game/cards/programs_test.clj @@ -7466,6 +7466,20 @@ (click-prompt state :runner "End the run")) "Spent 1 credit to break")))) +(deftest read-write-share + (do-game + (new-game {:runner {:hand ["Read-Write Share" "Rezeki" "Corroder"] + :deck [(qty "Ika" 2)]}}) + (take-credits state :corp) + (play-from-hand state :runner "Read-Write Share") + (click-card state :runner "Rezeki") + (take-credits state :runner) + (take-credits state :corp) + (start-turn state :runner) + (click-card state :runner "Corroder") + (card-ability state :runner (get-program state 0) 0) + (is-deck? state :runner ["Corroder" "Rezeki"]))) + (deftest reaver ;; Reaver - Draw a card the first time you trash an installed card each turn (do-game From 20fd10dc175d8efba1c4a3a29081987265c03d05 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Thu, 26 Feb 2026 19:37:22 +1300 Subject: [PATCH 44/48] grubber and unit etsts --- src/clj/game/cards/ice.clj | 8 ++++++ test/clj/game/cards/ice_test.clj | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/clj/game/cards/ice.clj b/src/clj/game/cards/ice.clj index 6335a0c851..426088bef9 100644 --- a/src/clj/game/cards/ice.clj +++ b/src/clj/game/cards/ice.clj @@ -2089,6 +2089,14 @@ {:on-rez take-bad-pub :subroutines [trash-program-sub]}) +(defcard "Grubber" + {:on-rez {:change-in-game-state {:silent true :req (req (protecting-a-central? card))} + :async true + :msg "take 1 bad publicity" + :effect (req (gain-bad-publicity state side eid 1))} + :subroutines [(end-the-run-unless-runner-pays (->c :credit 3)) + (end-the-run-unless-runner-pays (->c :credit 3))]}) + (defcard "Guard" {:static-abilities [{:type :bypass-ice :req (req (same-card? card target)) diff --git a/test/clj/game/cards/ice_test.clj b/test/clj/game/cards/ice_test.clj index f8e2b2ec6b..40cb0e3fad 100644 --- a/test/clj/game/cards/ice_test.clj +++ b/test/clj/game/cards/ice_test.clj @@ -3344,6 +3344,50 @@ (click-prompt state :runner "End the run unless the Runner pays 3 [Credits]")) "Get taxed 1c for breaking with Grappling Hook")))) +(deftest grubber-subroutine-test + (doseq [sub [0 1] + option ["Pay 3 [Credits]" "End the run"]] + (do-game + (subroutine-test "Grubber" sub) + (click-prompt state :runner option) + (case option + "End the run" (is (not (:run @state)) "Run ended") + "Pay 3 [Credits]" (is (changed? [(:credit (get-runner)) -3] + (click-prompt state :runner "Done") + (is (:run @state) "still running") + (is (no-prompt? state :runner) "No prompt")) + "Paid 3 (not using bad pub)"))))) + +(deftest grubber-subroutine-test + (doseq [sub [0 1] + option ["Pay 3 [Credits]" "End the run"]] + (do-game + (subroutine-test "Grubber" sub) + (click-prompt state :runner option) + (case option + "End the run" (is (not (:run @state)) "Run ended") + "Pay 3 [Credits]" (is (changed? [(:credit (get-runner)) -3] + (click-prompt state :runner "Done") + (is (:run @state) "still running") + (is (no-prompt? state :runner) "No prompt")) + "Paid 3 (not using bad pub)"))))) + +(deftest grubber-bad-pub-on-centrals + (doseq [[server s-key] [["HQ" :hq] ["R&D" :rd] ["Archives" :archives]]] + (testing (str "bad publicity on " server) + (do-game + (new-game {:corp {:hand ["Grubber"] :credits 10}}) + (play-from-hand state :corp "Grubber" server) + (rez state :corp (get-ice state s-key 0)) + (is (= 1 (count-bad-pub state)) "Gained a bad pub"))))) + +(deftest grubber-no-bad-pub-on-remotes + (do-game + (new-game {:corp {:hand ["Grubber"] :credits 10}}) + (play-from-hand state :corp "Grubber" "New remote") + (rez state :corp (get-ice state :remote1 0)) + (is (= 0 (count-bad-pub state)) "Gained no bad pub"))) + (deftest gyri-labyrinth ;; Gyri Labyrinth - reduce runner handsize by 2 until beginning of corp's next turn (do-game From 75864f57424edc33d8661b1c5e51201ddcdedc8b Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Fri, 27 Feb 2026 07:13:42 +1300 Subject: [PATCH 45/48] support for cards with hosting discounts to work for cost computation --- src/clj/game/core/installing.clj | 60 ++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/src/clj/game/core/installing.clj b/src/clj/game/core/installing.clj index aa3914223f..25ece73618 100644 --- a/src/clj/game/core/installing.clj +++ b/src/clj/game/core/installing.clj @@ -580,15 +580,46 @@ (->c :credit cost)) additional-costs]))])) +(defn- some-hosting-effect + [state card] + "Gets the first (only) host effect of a card, if it exists and is not disabled" + (when (and card (not (is-disabled-reg? state card))) + (first (filter #(= (:type %) :can-host) (:static-abilities (card-def card)))))) + +(defn runner-can-host + [state side eid card {:keys [host-card facedown] :as args}] + "Gets a list of all cards that the runner can host the install target on" + (when-not (or host-card facedown) + (let [all-hosts (filter #(some-hosting-effect state %) (all-installed state :runner)) + relevant (filter #(let [ab (some-hosting-effect state %)] + (or (nil? (:req ab)) + ((:req ab) state side eid % [card]))) + all-hosts)] + (seq relevant)))) + (defn runner-can-pay-and-install? ([state side eid card] (runner-can-pay-and-install? state side eid card nil)) - ([state side eid card {:keys [facedown] :as args}] + ([state side eid card {:keys [facedown host-card no-host?] :as args}] (let [eid (assoc eid :source-type :runner-install) - costs (runner-install-cost state side (assoc card :facedown facedown) args)] - (and (runner-can-install? state side eid card (assoc args :no-toast true)) - (can-pay? state side eid card nil costs) - ;; explicitly return true - true)))) + host-abi (some-hosting-effect state host-card) + old-cost-bonus (or (:cost-bonus args) 0) + new-cost-bonus (or (:cost-bonus host-abi) 0) + combined-cost-bonus (+ old-cost-bonus new-cost-bonus) + cost-bonus (if (zero? combined-cost-bonus) nil combined-cost-bonus) + costs (runner-install-cost state side + (assoc card :facedown facedown) + (assoc args :cost-bonus cost-bonus))] + (or (and (runner-can-install? state side eid card (assoc args :no-toast true)) + (can-pay? state side eid card nil costs) + ;; explicitly return true + true) + ;; note: Some cards (hackerspace, dhegder) provide a discount to installing cards + ;; so long as they are installed hosted on themselves, so we need to check for that. + ;; -nbkelly, 2026.02 + (and (not host-card) + (not no-host?) + (some #(runner-can-pay-and-install? state side eid card (assoc args :host-card %)) + (runner-can-host state side eid card args))))))) (defn runner-install-pay [state side eid card {:keys [no-mu facedown host-card resolved-optional-trash] :as args}] @@ -643,23 +674,6 @@ :previous-zone (:previous-zone card))) (effect-completed state side eid))))))))) -(defn- some-hosting-effect - [state card] - "Gets the first (only) host effect of a card, if it exists and is not disabled" - (when-not (is-disabled-reg? state card) - (first (filter #(= (:type %) :can-host) (:static-abilities (card-def card)))))) - -(defn runner-can-host - [state side eid card {:keys [host-card facedown] :as args}] - "Gets a list of all cards that the runner can host the install target on" - (when-not (or host-card facedown) - (let [all-hosts (filter #(some-hosting-effect state %) (all-installed state :runner)) - relevant (filter #(let [ab (some-hosting-effect state %)] - (or (nil? (:req ab)) - ((:req ab) state side eid % [card]))) - all-hosts)] - (seq relevant)))) - (defn runner-host-enforce-specific-memory [state side eid card potential-host args] "enforces limits on the total MU a host can support during install" From 2519f3a13ce410d7a40dd5ceb8f3fe2580cf618c Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Fri, 27 Feb 2026 07:14:12 +1300 Subject: [PATCH 46/48] chain reaction and test --- src/clj/game/cards/events.clj | 33 +++++++++++++++++++++++++++++ test/clj/game/cards/events_test.clj | 23 ++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/clj/game/cards/events.clj b/src/clj/game/cards/events.clj index e257c5f19a..47ccfd0081 100644 --- a/src/clj/game/cards/events.clj +++ b/src/clj/game/cards/events.clj @@ -587,6 +587,39 @@ (cbi-choice from '() (count from) from))) card nil))}})]})) +(defcard "Chain Reaction" + (let [corp-choice {:player :corp + :prompt "Choose a Runner card to trash" + :async true + :req (req (seq (all-installed state :runner))) + :choices {:card (every-pred runner? installed?)} + :waiting-prompt true + :display-side :corp + :msg (msg "trash " (:title target)) + :effect (req (trash state :corp eid target))} + cards-to-trash (fn [state] (min 2 (count (all-installed state :corp)))) + runner-choice {:prompt (msg "choose " (quantify (cards-to-trash state) "card") " to trash") + :async true + :choices {:card (every-pred corp? installed?) + :max (req (cards-to-trash state)) + :all true} + :waiting-prompt true + :msg (msg "trash " (enumerate-str (map #(card-str state %) targets))) + :effect (req (wait-for (trash-cards state side targets) + (continue-ability + state :corp + corp-choice + card nil)))}] + {:on-play {:async true + :change-in-game-state {:req (req (or (seq (all-installed state :corp)) + (seq (all-installed state :runner))))} + :req (req (and (some #{:hq} (:successful-run runner-reg)) + (some #{:rd} (:successful-run runner-reg)) + (some #{:archives} (:successful-run runner-reg)))) + :effect (req (if (seq (all-installed state :corp)) + (continue-ability state side runner-choice card nil) + (continue-ability state :corp corp-choice card nil)))}})) + (defcard "Charm Offensive" (letfn [(trash-x-opt [t] {:option (str "Trash a rezzed copy of " t) diff --git a/test/clj/game/cards/events_test.clj b/test/clj/game/cards/events_test.clj index f805b4ed48..35de8f5f27 100644 --- a/test/clj/game/cards/events_test.clj +++ b/test/clj/game/cards/events_test.clj @@ -1181,6 +1181,29 @@ (is (= "Jackson Howard" (:title (second (rest (rest (:deck (get-corp)))))))) (is (= "Global Food Initiative" (:title (second (rest (rest (rest (:deck (get-corp))))))))))) +(deftest chain-reaction-test + (do-game + (new-game {:corp {:hand ["Vanilla" "Enigma" "PAD Campaign"]} + :runner {:hand ["Chain Reaction" "Chain Reaction" "Ika"]}}) + (play-from-hand state :corp "PAD Campaign" "New remote") + (play-from-hand state :corp "Vanilla" "Server 1") + (play-from-hand state :corp "Enigma" "Server 1") + (take-credits state :corp) + (core/gain state :runner :click 2) + (run-empty-server state :hq) + (run-empty-server state :rd) + (run-empty-server state :archives) + (play-from-hand state :runner "Chain Reaction") + (click-prompts state :runner "Vanilla" "Enigma") + (is (= 2 (count (:discard (get-corp)))) "Trashed 2") + (is (no-prompt? state :corp) "No prompt to trash nothing") + (play-from-hand state :runner "Ika") + (play-from-hand state :runner "Chain Reaction") + (click-card state :runner "PAD Campaign") + (is (= 3 (count (:discard (get-corp)))) "Trashed 1") + (click-card state :corp "Ika") + (is (= 3 (count (:discard (get-runner)))) "Trashed 1, runner prompt did not block"))) + (deftest charm-offensive (do-game (new-game {:corp {:deck [(qty "Hedge Fund" 5)] From 97978adb511c2d5c91416de7eac6b2a97d280a68 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Fri, 27 Feb 2026 07:15:28 +1300 Subject: [PATCH 47/48] hackerspace and test, test for class act triggering correctly on hosted unique over-write, test for cost bonus computation --- src/clj/game/cards/operations.clj | 12 +++++++ src/clj/game/cards/resources.clj | 12 ++++++- test/clj/game/cards/operations_test.clj | 8 +++++ test/clj/game/cards/resources_test.clj | 42 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/clj/game/cards/operations.clj b/src/clj/game/cards/operations.clj index a8f3096766..7f29a81f0c 100644 --- a/src/clj/game/cards/operations.clj +++ b/src/clj/game/cards/operations.clj @@ -2506,6 +2506,18 @@ (defcard "Restructure" {:on-play (gain-credits-ability 15)}) +(defcard "Retirement Plan" + {:on-play {:prompt "Install an Asset, Ice or Agenda from Archives" + :change-in-game-state {:req (req (some #(or (asset? %) (ice? %) (agenda? %) (not (:seen %))) (:discard corp)))} + :show-discard true + :not-distinct true + :choices {:card #(and (or (ice? %) (asset? %) (agenda? %)) + (corp? %) + (in-discard? %))} + :async true + :effect (effect (corp-install eid target nil {:msg-keys {:install-source card + :display-origin true}}))}}) + (defcard "Retribution" {:on-play (trash-type "program of piece of hardware" #(or (program? %) (hardware? %)) :loud 1 nil {:req (req tagged)})}) diff --git a/src/clj/game/cards/resources.clj b/src/clj/game/cards/resources.clj index f8dc8aedea..4ddff89a23 100644 --- a/src/clj/game/cards/resources.clj +++ b/src/clj/game/cards/resources.clj @@ -13,7 +13,7 @@ event? facedown? get-agenda-points get-card get-counters get-title get-zone hardware? has-subtype? has-any-subtype? ice? identity? in-discard? in-hand? in-set-aside? in-scored? installed? is-type? program? resource? rezzed? - runner? upgrade? virus-program?]] + runner? unique? upgrade? virus-program?]] [game.core.card-defs :refer [card-def]] [game.core.charge :refer [can-charge charge-ability]] [game.core.checkpoint :refer [fake-checkpoint]] @@ -1689,6 +1689,16 @@ (pay state :runner eid card (->c :credit 4))))} card nil)))}}]}) +(defcard "Hackerspace" + {:static-abilities [{:type :can-host + :req (req (and (resource? target) + (has-any-subtype? target ["Connection" "Companion"]) + (unique? target))) + :cost-bonus -1} + (runner-hand-size+ (req (if (and (some #(has-subtype? % "Connection") (:hosted card)) + (some #(has-subtype? % "Companion") (:hosted card))) + 2 0)))]}) + (defcard "Hades Shard" (shard-constructor "Hades Shard" :archives "breach Archives" (effect (breach-server eid [:archives] {:no-root true})))) diff --git a/test/clj/game/cards/operations_test.clj b/test/clj/game/cards/operations_test.clj index 5ff2ce3669..b3e6801223 100644 --- a/test/clj/game/cards/operations_test.clj +++ b/test/clj/game/cards/operations_test.clj @@ -3998,6 +3998,14 @@ (is (rezzed? (get-content state :remote1 0)) "Marilyn Campaign was rezzed") (is (= 2 (:credit (get-corp))) "Rezzed Marilyn Campaign 2 credit + 1 credit for Restore"))) +(deftest retirement-plan + (do-game + (new-game {:corp {:hand ["Retirement Plan"] + :discard ["PAD Campaign"]}}) + (play-from-hand state :corp "Retirement Plan") + (click-prompts state :corp "PAD Campaign" "New remote") + (is (= "PAD Campaign" (:title (get-content state :remote1 0)))))) + (deftest retribution ;; Retribution (do-game diff --git a/test/clj/game/cards/resources_test.clj b/test/clj/game/cards/resources_test.clj index f1279be1ba..35fcb5b436 100644 --- a/test/clj/game/cards/resources_test.clj +++ b/test/clj/game/cards/resources_test.clj @@ -3112,6 +3112,48 @@ (click-prompt state :runner "Trash Guru Davinder") (is (no-prompt? state :runner) "Dummy Box not prompting to prevent trash"))) +(deftest hackerspace-test + (do-game + (new-game {:runner {:hand ["Hackerspace" "Kati Jones" "Underworld Contact" "Paladin Poemu"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Hackerspace") + (play-from-hand state :runner "Kati Jones") + (click-prompt state :runner "Hackerspace") + (is (= 5 (core/hand-size state :runner)) "Runner should start with 5 max hand size") + (play-from-hand state :runner "Paladin Poemu") + (click-prompt state :runner "Hackerspace") + (is (= 7 (core/hand-size state :runner)) "Runner should start with 5 max hand size"))) + +(deftest hackerspace-with-cost-discount-requirement + (do-game + (new-game {:runner {:hand ["Career Fair" "Hackerspace" "The Class Act"] + :credits 2}}) + (take-credits state :corp) + (play-from-hand state :runner "Hackerspace") + (play-from-hand state :runner "Career Fair") + (click-card state :runner "The Class Act") + (click-prompt state :runner "Hackerspace") + (is (no-prompt? state :runner) "It worked") + (is (= "The Class Act" (-> (get-resource state 0) :hosted first :title)) "Installed for 0"))) + +(deftest hackerspace-class-act-repl + (do-game + (new-game {:runner {:hand ["Hackerspace" "The Class Act"] + :deck ["Sure Gamble" "Easy Mark" "The Class Act" "Euler" "Ika" "Dirty Laundry" "Corroder" "Carpe Diem"] + :credits 15} + :corp {:hand ["IPO"]}}) + (take-credits state :corp) + (play-from-hand state :runner "Hackerspace") + (play-from-hand state :runner "The Class Act") + (click-prompt state :runner "Hackerspace") + (take-credits state :runner) + (click-card state :runner (first (:set-aside (get-runner)))) + (take-credits state :corp) + (play-from-hand state :runner "The Class Act") + (click-prompt state :runner "Hackerspace") + (take-credits state :runner) + (is (not (no-prompt? state :runner)) "TCA Prompt"))) + (deftest hannah-wheels-pilintra-basic-test (do-game (new-game {:runner {:hand ["Hannah \"Wheels\" Pilintra"]} From 98942259117025dd1794c15b5b8e5ba35bdef827 Mon Sep 17 00:00:00 2001 From: NB Kelly Date: Fri, 27 Feb 2026 07:15:47 +1300 Subject: [PATCH 48/48] perfect recall and test --- src/clj/game/cards/upgrades.clj | 37 +++++++++++++++++++++++++++ test/clj/game/cards/upgrades_test.clj | 17 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/clj/game/cards/upgrades.clj b/src/clj/game/cards/upgrades.clj index 8d8cc4bd41..c02a4a966f 100644 --- a/src/clj/game/cards/upgrades.clj +++ b/src/clj/game/cards/upgrades.clj @@ -1623,6 +1623,43 @@ :keep-menu-open :while-credits-left :req (req (and run (= (target-server run) :hq)))})]}) +(defcard "Perfect Recall" + (let [ab {:req (req run) + :choices {:req (req (and (corp? target) + (in-hand? target)))} + :label "Reveal a card and prevent it being trashed or stolen this run" + :msg (msg "reveal " (:title target) "from HQ and prevent the runner from stealing or trashing any copies of it this run") + :async true + :waiting-prompt true + :effect (req + (wait-for + (reveal state side target) + (let [revealed-card target] + (register-lingering-effect + state side card + {:type :cannot-steal + :req (req (= (:title target) (:title revealed-card))) + :value true + :duration :end-of-run}) + (register-lingering-effect + state side card + {:type :cannot-be-trashed + :req (req (and (= (:title target) (:title revealed-card)) + (= :runner side))) + :value true + :duration :end-of-run})) + (effect-completed state side eid)))}] + {:events (mapv + #(merge {:async true :effect (req (add-counter state side eid card :power 1))} %) + [{:event :agenda-stolen + :req (req (= (:previous-zone (:card context)) (get-zone card)))} + {:event :agenda-scored + :req (req (= (:previous-zone (:card context)) (get-zone card)))}]) + :on-rez {:silent (req true) + :async true + :effect (req (add-counter state side eid card :power 1))} + :abilities [(assoc ab :cost [(->c :power 1)])]})) + (defcard "Port Anson Grid" {:on-rez {:msg "prevent the Runner from jacking out unless they trash an installed program"} :static-abilities [{:type :jack-out-additional-cost diff --git a/test/clj/game/cards/upgrades_test.clj b/test/clj/game/cards/upgrades_test.clj index 59ef5d73da..7cef84b4e9 100644 --- a/test/clj/game/cards/upgrades_test.clj +++ b/test/clj/game/cards/upgrades_test.clj @@ -3751,6 +3751,23 @@ (is (find-card "Enigma" (:hand (get-corp)))) (is (zero? (count (:deck (get-corp)))))))) +(deftest perfect-recall-test + (do-game + (new-game {:corp {:hand ["Perfect Recall" "Merger" "Merger"] :credits 10}}) + (play-from-hand state :corp "Perfect Recall" "New remote") + (rez state :corp (get-content state :remote1 0)) + (play-from-hand state :corp "Merger" "Server 1") + (core/gain state :corp :click 5) + (dotimes [_ 3] + (click-advance state :corp (get-content state :remote1 1))) + (score state :corp (get-content state :remote1 1)) + (take-credits state :corp) + (run-on state :hq) + (card-ability state :corp (get-content state :remote1 0) 0) + (click-card state :corp (first (:hand (get-corp)))) + (run-continue-until state :success) + (is (= ["No action"] (prompt-titles :runner))))) + (deftest port-anson-grid ;; Port Anson Grid - Prevent the Runner from jacking out until they trash a program (do-game