Discard valuation knows whose hand it is; the shed spares the playbook

The end-of-turn shed was eating cards the brain itself casts: BIG MAN,
MIST BODY, SHRINK, LIFESAVER, and STONE DEAD all sat at or below the shed
threshold. But worth is contextual — LIFESAVER is genuinely dead at a
two-seat table, BIG MAN outside the berserker's book — so discardValue now
takes the view and style: STONE DEAD joins the attacks tier outright, the
rest are kept exactly by the wizard who can play them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-17 19:57:26 -04:00
co-authored by Claude Fable 5
parent 78085e26fb
commit 4e14926c0c
+18 -11
View File
@@ -112,18 +112,25 @@ function numbersInHand(view: GameView): CardInstance[] {
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
}
/** Cards the automaton happily discards to churn its hand, worst first. */
function discardValue(c: CardInstance): number {
/** Cards the automaton happily discards to churn its hand, worst first.
* A card is only worth keeping to the wizard who can actually play it —
* LIFESAVER is dead weight at a two-seat table, BIG MAN outside the
* berserker's book — so the valuation knows whose hand this is. */
function discardValue(c: CardInstance, view: GameView, style?: AutomatonStyle): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7;
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null ||
c.cardId === "stone-dead") return 7;
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
c.cardId === "glue" || c.cardId === "destroy-wall" ||
c.cardId === "pass-through-wall" || c.cardId === "create-wall" ||
c.cardId === "fill-square-with-stone" || c.cardId === "thornbush") return 6;
if (c.cardId === "lifesaver" && view.players.length > 2) return 6;
if (c.cardId === "big-man" && style === "berserker") return 6;
if ((c.cardId === "mist-body" || c.cardId === "shrink") && style === "worrier") return 6;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
@@ -455,12 +462,12 @@ function overLimit(view: GameView): number {
* too full to take the draw. doEndTurn caps the draw by the room left, so a
* clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits): Command {
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand]
.filter((c) => discardValue(c) <= 3)
.sort((a, b) => discardValue(a) - discardValue(b))
.filter((c) => discardValue(c, view, style) <= 3)
.sort((a, b) => discardValue(a, view, style) - discardValue(b, view, style))
.slice(0, deficit)
.map((c) => c.instanceId);
if (shed.length > 0) return { type: "discard", instanceIds: shed };
@@ -468,9 +475,9 @@ function endTurnDrawing(view: GameView, tier: TierTraits): Command {
return { type: "endTurn", draw: tier.draw };
}
function worstCards(view: GameView, n: number): string[] {
function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] {
return [...view.yourHand]
.sort((a, b) => discardValue(a) - discardValue(b))
.sort((a, b) => discardValue(a, view, style) - discardValue(b, view, style))
.slice(0, n)
.map((c) => c.instanceId);
}
@@ -807,7 +814,7 @@ export function automatonCommand(
if (view.phase !== "playing") return null;
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
}
if (view.chaosPending && view.chaosPending.queue[0] === you) {
const shield = inHand(view, "full-shield");
@@ -824,7 +831,7 @@ export function automatonCommand(
const self = me(view);
const here = cellKey(self.position);
if (view.turn.actionsEnded) return endTurnDrawing(view, tier);
if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style);
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
@@ -988,7 +995,7 @@ export function automatonCommand(
}
}
return endTurnDrawing(view, tier);
return endTurnDrawing(view, tier, style);
}
/** The safe fallback when the automaton's choice was refused. */