"Hold on —": cautions before easy-to-regret plays, and a house bot tier

An optional confirmation (on by default, one checkbox to silence) catches
the classic table groans before they land: ending the turn with movement
unspent, ending it with a hand too full to take the draw, and walking off
— or ending on — your own home with a deliverable treasure still in your
arms. Preferences also learn a default automaton skill, remembered from
either the prefs panel or the lobby's tier picker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 10:59:26 -04:00
co-authored by Claude Fable 5
parent bde68aa85c
commit 6e1ccb0195
2 changed files with 92 additions and 14 deletions
+75 -6
View File
@@ -57,6 +57,23 @@
/** A mind YOU read: the stolen glimpse arrives once, in an event — hold /** A mind YOU read: the stolen glimpse arrives once, in an event — hold
* it up until dismissed, or it is lost to the scrollback. */ * it up until dismissed, or it is lost to the scrollback. */
let mindRead = $state<{ player: string; cards: CardInstance[] } | null>(null); let mindRead = $state<{ player: string; cards: CardInstance[] } | null>(null);
/** An easy-to-regret play awaiting its "are you sure?" (see prefs.cautions). */
let caution = $state<{ reasons: string[]; go: () => void } | null>(null);
function withCaution(reasons: string[], go: () => void) {
if (!prefs.cautions || reasons.length === 0) go();
else caution = { reasons, go };
}
/** A move with the delivery check: stepping off your home still laden. */
function tryMove(direction: Side, over?: boolean) {
const reasons: string[] = [];
if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
reasons.push(t && t.owner !== view.you
? `you stand on your home with ${t.owner}'s treasure in your arms — drop it first and it scores`
: "you stand on your home with your own treasure in your arms — drop it first and it is safe");
}
withCaution(reasons, () => dispatch({ type: "move", direction, ...(over ? { over: true } : {}) }));
}
function playFx(events: Parameters<typeof scheduleFx>[0]) { function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return; if (!view) return;
for (const e of events) { for (const e of events) {
@@ -104,7 +121,7 @@
/** Leafing through the face-up discard pile. */ /** Leafing through the face-up discard pile. */
let showDiscards = $state(false); let showDiscards = $state(false);
let chatDraft = $state(""); let chatDraft = $state("");
let botTier = $state("adept"); let botTier = $state<string>(prefs.botTier);
/** Card whose official FAQ rulings are open. */ /** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null); let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */ /** A discard-pile card enlarged above the pile. */
@@ -669,7 +686,7 @@
for (const side of SIDES) { for (const side of SIDES) {
const t = stepTarget(view.board, me.position, side); const t = stepTarget(view.board, me.position, side);
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) { if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
dispatch({ type: "move", direction: side }); tryMove(side);
return; return;
} }
} }
@@ -685,7 +702,7 @@
illusionPrompt = illusionEdgeParts(k); illusionPrompt = illusionEdgeParts(k);
return; return;
} }
dispatch({ type: "move", direction: side }); tryMove(side);
return; return;
} }
} }
@@ -699,7 +716,7 @@
const hazard = view.squareContents[cellKey(mid)]?.kind; const hazard = view.squareContents[cellKey(mid)]?.kind;
if (cellKey(far) === cellKey(cell) && if (cellKey(far) === cellKey(cell) &&
(hazard === "pit" || hazard === "tacks" || hazard === "ooze")) { (hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
dispatch({ type: "move", direction: side, over: true }); tryMove(side, true);
return; return;
} }
} }
@@ -769,7 +786,7 @@
if (creature && creature.position.x === cell.x && creature.position.y === cell.y) { if (creature && creature.position.x === cell.x && creature.position.y === cell.y) {
dispatch({ type: "moveCreature", creatureId: creature.id, direction: side }); dispatch({ type: "moveCreature", creatureId: creature.id, direction: side });
} else if (me.position.x === cell.x && me.position.y === cell.y) { } else if (me.position.x === cell.x && me.position.y === cell.y) {
dispatch({ type: "move", direction: side }); tryMove(side);
} else { } else {
clickCell(cell); clickCell(cell);
} }
@@ -885,6 +902,25 @@
function endTurn() { function endTurn() {
clearSelection(); clearSelection();
const reasons: string[] = [];
if (view && me) {
const left = view.turn.movementAllowance - view.turn.movementUsed;
if (left > 0) reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
const room = limit - view.yourHand.length;
if (drawCount > room) {
reasons.push(room <= 0
? "your hand is full — the draw will bring nothing"
: `your hand only has room for ${room} of the ${drawCount}-card draw`);
}
if (me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
reasons.push(t && t.owner !== view.you
? `${t.owner}'s treasure is still in your arms — drop it here and it scores`
: "your own treasure is still in your arms — drop it here and it is safe");
}
}
withCaution(reasons, () => {
// Preference: a lone grabbable treasure underfoot is picked up on the // Preference: a lone grabbable treasure underfoot is picked up on the
// way out (pickup ends actions; ending the turn is still legal after). // way out (pickup ends actions; ending the turn is still legal after).
if (prefs.autoGrab && view && me && !me.carriedTreasureId && if (prefs.autoGrab && view && me && !me.carriedTreasureId &&
@@ -893,6 +929,7 @@
dispatch({ type: "pickUpTreasure" }); dispatch({ type: "pickUpTreasure" });
} }
dispatch({ type: "endTurn", draw: drawCount }); dispatch({ type: "endTurn", draw: drawCount });
});
} }
// SWAP MEET: pick one of your carried items, then one of theirs you can // SWAP MEET: pick one of your carried items, then one of theirs you can
@@ -1273,6 +1310,18 @@
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} /> onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span> <span>Spell flourishes (the animated effects)</span>
</label> </label>
<label class="pref-row">
<input type="checkbox" checked={prefs.cautions}
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
</label>
<div class="pref-row">
<span>Automatons default to</span>
{#each ["apprentice", "adept", "archmage"] as t (t)}
<button class="stamp tiny" class:lit={prefs.botTier === t}
onclick={() => { setPref("botTier", t as typeof prefs.botTier); botTier = t; }}>{t}</button>
{/each}
</div>
<label class="pref-row"> <label class="pref-row">
<span>Wizard name</span> <span>Wizard name</span>
<input class="setup-input pref-name" maxlength="20" value={prefs.wizardName} <input class="setup-input pref-name" maxlength="20" value={prefs.wizardName}
@@ -1338,6 +1387,23 @@
</div> </div>
{/if} {/if}
{#if caution}
<div class="scrim attack-scrim" role="alertdialog" aria-label="are you sure?">
<div class="attack-notice">
<div class="attack-headline">Hold on —</div>
<div class="attack-power">
{#each caution.reasons as r (r)}
<div class="caution-reason">{r}</div>
{/each}
</div>
<div class="attack-actions">
<button class="stamp big" onclick={() => { const go = caution!.go; caution = null; go(); }}>Do it anyway</button>
<button class="hint-cancel" onclick={() => (caution = null)}>never mind</button>
</div>
</div>
</div>
{/if}
{#if mindRead} {#if mindRead}
<div class="scrim attack-scrim" role="alertdialog" aria-label="a mind read"> <div class="scrim attack-scrim" role="alertdialog" aria-label="a mind read">
<div class="attack-notice mind-read"> <div class="attack-notice mind-read">
@@ -1679,7 +1745,8 @@
{#if net.players.length < 6} {#if net.players.length < 6}
<span class="bot-row"> <span class="bot-row">
<span class="bot-label">⚙ seat a</span> <span class="bot-label">⚙ seat a</span>
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"> <select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
onchange={() => setPref("botTier", botTier as typeof prefs.botTier)}>
<option value="apprentice">apprentice</option> <option value="apprentice">apprentice</option>
<option value="adept">adept</option> <option value="adept">adept</option>
<option value="archmage">archmage</option> <option value="archmage">archmage</option>
@@ -2289,6 +2356,8 @@
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; } .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; } .mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
.mind-read { max-width: min(92vw, 46rem); } .mind-read { max-width: min(92vw, 46rem); }
.caution-reason { padding: 0.15rem 0; }
.caution-reason + .caution-reason { border-top: 1px dotted #4a4536; }
.mind-cards { .mind-cards {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+10 -1
View File
@@ -14,12 +14,19 @@ export interface Prefs {
wizardName: string; wizardName: string;
/** Preferred wizard color (0-5), claimed in lobbies when free. */ /** Preferred wizard color (0-5), claimed in lobbies when free. */
color: number | null; color: number | null;
/** "Are you sure?" prompts before easy-to-regret plays. */
cautions: boolean;
/** Default skill for automatons seated from the lobby. */
botTier: "apprentice" | "adept" | "archmage";
} }
const KEY = "wizwar-prefs"; const KEY = "wizwar-prefs";
function load(): Prefs { function load(): Prefs {
const fallback: Prefs = { art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null }; const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "adept",
};
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
if (!raw) return fallback; if (!raw) return fallback;
@@ -30,6 +37,8 @@ function load(): Prefs {
flourishes: p.flourishes !== false, flourishes: p.flourishes !== false,
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "", wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null, color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
cautions: p.cautions !== false,
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
}; };
} catch { } catch {
return fallback; return fallback;