Ops hardening plus two rules gaps close (rev 13)
Operational: auto-reboot for kernel patches (09:30 UTC), Caddy access logs at /var/lib/caddy (self-rotating; the sandbox denies /var/log), logrotate for the backup log, a restore drill proving a Spaces snapshot boots 56/56 rooms clean, the about page's plain sentence on permanent recording, and /wizwar-pulse to read it all weekly. Rules: safes now smash — attacks aimed at the box accumulate on it and the fifteenth point bursts it (widening, ungated). And SLOW DEATH's bites pause for a victim holding an ABSORB — the card named by the FAQ, not Absorb Spell — each soaking exactly one point, rev-gated at 13 with the instant-bite legacy pinned. Bots weigh the soak against their life. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
a66b058c68
commit
92613d7386
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: wizwar-pulse
|
||||||
|
description: The weekly Wiz-War operations pulse — droplet health, error and traffic signals, backup status, and who has been playing. Use when Eric asks how the server is doing, wants an ops check, or says "run the pulse".
|
||||||
|
---
|
||||||
|
|
||||||
|
# The Wiz-War ops pulse
|
||||||
|
|
||||||
|
One invocation, one screen: how the table is doing. Gather everything,
|
||||||
|
then report in five short sections. Lead with anything ALARMING (service
|
||||||
|
down, memory near the cap, failed backup, new Sentry issues); if all is
|
||||||
|
quiet, say so in one line before the details.
|
||||||
|
|
||||||
|
## Gather
|
||||||
|
|
||||||
|
1. **Box health** (one ssh to root@104.236.96.198):
|
||||||
|
- `systemctl is-active wizwar caddy` — both must be active
|
||||||
|
- `systemctl status wizwar` Memory line — the cap is 700M; worry
|
||||||
|
past ~500M steady-state
|
||||||
|
- `free -m`, `df -h /`, `uptime` — swap in heavy use, disk past
|
||||||
|
~70%, or load near 1.0 (single vCPU) are the resize triggers
|
||||||
|
- `journalctl -u wizwar --since "-7 days" | grep -ci "unhandled
|
||||||
|
protocol error"` — anything nonzero deserves a look (Sentry has
|
||||||
|
the stack traces)
|
||||||
|
2. **Sentry** (MCP connector, org `locallygrownnet`, project `wizwar`):
|
||||||
|
- `search_issues` for `is:unresolved` — new issues are the headline
|
||||||
|
- `get_monitor_details` for cron monitor slug `new-monitor` (named
|
||||||
|
wizwar-backup) — last check-in must be ok and recent; the uptime
|
||||||
|
monitor alerts on its own but note any incidents
|
||||||
|
- New-project caveat: results can lag ingest by minutes; the
|
||||||
|
connector reads fine but 403s on create operations
|
||||||
|
3. **Backups**: `tail -3 /var/log/wizwar/backup.log` (or its rotation)
|
||||||
|
for the last run, plus the cron monitor above for missed runs
|
||||||
|
4. **The table itself**:
|
||||||
|
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
|
||||||
|
- New human names this week: room ledgers' join lines minus bots
|
||||||
|
(Automaton*) and known names, on rooms created in the last 7 days
|
||||||
|
(meta line `createdAt`)
|
||||||
|
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
|
||||||
|
if any, offer to run the reports desk (`/wizwar-reports`)
|
||||||
|
- Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of
|
||||||
|
distinct `client_ip`s in the last day, for the growth line
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
Five sections, tight: **Verdict** (one line), **Box**, **Errors &
|
||||||
|
monitors**, **Backups**, **The table** (games, new faces, feedback).
|
||||||
|
Numbers with their thresholds, not raw dumps. End with any recommended
|
||||||
|
action, or "nothing needs you."
|
||||||
|
|
||||||
|
## Standing knowledge
|
||||||
|
|
||||||
|
- The service restarts cleanly (ledgers replay); restarts in the journal
|
||||||
|
during deploys are normal, not incidents.
|
||||||
|
- Room memory is evicted after idle (finished 30 min, others 24 h);
|
||||||
|
boot-restores load everything then shed, so post-deploy memory spikes
|
||||||
|
settle within the hour.
|
||||||
|
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
|
||||||
|
requires it; a reboot there is maintenance, not an outage.
|
||||||
|
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
|
||||||
|
10MiB × 5); the systemd sandbox denies /var/log/caddy.
|
||||||
|
- Restore drill (2026-09-01): a Spaces snapshot booted 56/56 rooms
|
||||||
|
clean. The path: `rclone copy spaces:kestrel-wizwar-backups/wizwar/
|
||||||
|
snapshots/<date> ...` on the droplet, then run a local server with
|
||||||
|
WIZWAR_DATA_DIR pointed at its rooms dir. Re-drill quarterly-ish.
|
||||||
@@ -1274,6 +1274,17 @@ export function automatonCommand(
|
|||||||
const tier = TIERS[tierName] ?? TIERS.archmage;
|
const tier = TIERS[tierName] ?? TIERS.archmage;
|
||||||
if (view.phase !== "playing") return null;
|
if (view.phase !== "playing") return null;
|
||||||
|
|
||||||
|
if (view.slowDeathPending) {
|
||||||
|
if (view.slowDeathPending.playerId !== you) return null;
|
||||||
|
// Soak only when the bites reach toward the floor — the card is worth
|
||||||
|
// more against a fireball than one point of rot.
|
||||||
|
const absorb = inHand(view, "absorb");
|
||||||
|
const pts = view.slowDeathPending.points;
|
||||||
|
if (absorb && me(view).life - pts <= 4) {
|
||||||
|
return { type: "slowDeathChoice", absorbInstanceId: absorb.instanceId };
|
||||||
|
}
|
||||||
|
return { type: "slowDeathChoice" };
|
||||||
|
}
|
||||||
if (view.wardPending) {
|
if (view.wardPending) {
|
||||||
// A Ward is free damage on a thief of OUR gold: always spring it.
|
// A Ward is free damage on a thief of OUR gold: always spring it.
|
||||||
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
|
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
|
||||||
@@ -1803,6 +1814,7 @@ export function automatonCommand(
|
|||||||
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
|
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
|
||||||
const you = view.you;
|
const you = view.you;
|
||||||
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
|
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
|
||||||
|
if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" };
|
||||||
if (view.pendingDiscard === you) {
|
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))) };
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-2
@@ -238,7 +238,7 @@ export interface CastParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
export const CURRENT_RULES_REV = 12;
|
export const CURRENT_RULES_REV = 13;
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
playerIds: PlayerId[];
|
||||||
@@ -284,6 +284,9 @@ export interface GameConfig {
|
|||||||
* number riding the cast fuels it, and numbers or POWER RUN points
|
* number riding the cast fuels it, and numbers or POWER RUN points
|
||||||
* played under the dash double; older games doubled only the base
|
* played under the dash double; older games doubled only the base
|
||||||
* allowance and consumed the rider without effect.
|
* allowance and consumed the rider without effect.
|
||||||
|
* Rev 13: SLOW DEATH's bites pause for a victim holding ABSORB — each
|
||||||
|
* absorb soaks exactly one point (FAQ: "only stop the damage incurred
|
||||||
|
* by one card"); older games bit instantly.
|
||||||
*/
|
*/
|
||||||
deckRev?: number;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
@@ -301,6 +304,9 @@ export interface GameState {
|
|||||||
/** A treasure was just grabbed and its owner holds WARD:
|
/** A treasure was just grabbed and its owner holds WARD:
|
||||||
* the table waits while they choose to play it "at that time" or not. */
|
* the table waits while they choose to play it "at that time" or not. */
|
||||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||||
|
/** SLOW DEATH's bites hang while the victim decides whether an ABSORB
|
||||||
|
* soaks a point ("only stop the damage incurred by one card"). */
|
||||||
|
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
||||||
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
|
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
|
||||||
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
|
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
|
||||||
/** Permanent door-lock changes, by edge key. */
|
/** Permanent door-lock changes, by edge key. */
|
||||||
@@ -745,6 +751,10 @@ export type GameEvent =
|
|||||||
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
|
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
|
||||||
| { type: "madDash"; player: PlayerId; newAllowance: number }
|
| { type: "madDash"; player: PlayerId; newAllowance: number }
|
||||||
| { type: "safeOpened"; player: PlayerId; cell: Cell; withCardId: string }
|
| { type: "safeOpened"; player: PlayerId; cell: Cell; withCardId: string }
|
||||||
|
| { type: "slowDeathWindow"; player: PlayerId; points: number }
|
||||||
|
| { type: "slowDeathAbsorbed"; player: PlayerId; remaining: number }
|
||||||
|
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
|
||||||
|
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
|
||||||
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
||||||
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
||||||
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||||
@@ -786,6 +796,7 @@ export type Command =
|
|||||||
| { type: "testIllusion"; cell: Cell; side: Side }
|
| { type: "testIllusion"; cell: Cell; side: Side }
|
||||||
| { type: "armWard" }
|
| { type: "armWard" }
|
||||||
| { type: "wardChoice"; play: boolean }
|
| { type: "wardChoice"; play: boolean }
|
||||||
|
| { type: "slowDeathChoice"; absorbInstanceId?: string }
|
||||||
| { type: "warpStep" }
|
| { type: "warpStep" }
|
||||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||||
| { type: "creatureWarpStep"; creatureId: string }
|
| { type: "creatureWarpStep"; creatureId: string }
|
||||||
@@ -3807,6 +3818,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
wallDamage: {},
|
wallDamage: {},
|
||||||
slimeTraps: {},
|
slimeTraps: {},
|
||||||
wardPending: null,
|
wardPending: null,
|
||||||
|
slowDeathPending: null,
|
||||||
chaosPending: null,
|
chaosPending: null,
|
||||||
doorStates: {},
|
doorStates: {},
|
||||||
openDoorEdges: [],
|
openDoorEdges: [],
|
||||||
@@ -3886,6 +3898,13 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
return doWardChoice(state, command.play);
|
return doWardChoice(state, command.play);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SLOW DEATH's window: the bites hang while the victim weighs an absorb.
|
||||||
|
if (state.slowDeathPending) {
|
||||||
|
if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim");
|
||||||
|
if (command.type !== "slowDeathChoice") return err("soak a point with an Absorb or take the bites");
|
||||||
|
return doSlowDeathChoice(state, playerId, command.absorbInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.pendingDiscard) {
|
if (state.pendingDiscard) {
|
||||||
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
|
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
|
||||||
if (command.type !== "discard") return err("you must discard down to the hand limit first");
|
if (command.type !== "discard") return err("you must discard down to the hand limit first");
|
||||||
@@ -4006,6 +4025,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
||||||
case "armWard": return doArmWard();
|
case "armWard": return doArmWard();
|
||||||
case "wardChoice": return err("no grab is hanging on your Ward");
|
case "wardChoice": return err("no grab is hanging on your Ward");
|
||||||
|
case "slowDeathChoice": return err("no slow death bites are hanging");
|
||||||
case "warpStep": return doWarpStep(state);
|
case "warpStep": return doWarpStep(state);
|
||||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||||
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
|
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
|
||||||
@@ -5221,6 +5241,46 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
}
|
}
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
// SAFE: "To smash it open takes fifteen points of damage" — attacks
|
||||||
|
// aimed at the box accumulate on it; the fifteenth point bursts it,
|
||||||
|
// leaving whatever it guarded on the open floor.
|
||||||
|
if (cmd.target?.kind === "cell" &&
|
||||||
|
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
|
||||||
|
const cell = cmd.target.cell;
|
||||||
|
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) {
|
||||||
|
return err("you must be in the same square");
|
||||||
|
}
|
||||||
|
if (effect.requiresLos && !(mods.aroundCorner
|
||||||
|
? bentLos(state, caster, caster.position, cell)
|
||||||
|
: castSight(state, caster, cmd, cell))) {
|
||||||
|
return err("no line of sight to the safe");
|
||||||
|
}
|
||||||
|
const wandEvents: GameEvent[] = [];
|
||||||
|
{
|
||||||
|
const werr = spendWandCharge(state, caster, wandEvents);
|
||||||
|
if (werr) return err(werr);
|
||||||
|
}
|
||||||
|
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
|
||||||
|
if (dmg <= 0) return err("that spell would not dent the safe");
|
||||||
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
|
state.turn.attackUsed = true;
|
||||||
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
|
const events: GameEvent[] = [...wandEvents, {
|
||||||
|
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||||
|
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||||
|
from: caster.position, target: null, targetCell: { ...cell },
|
||||||
|
}];
|
||||||
|
const box = state.squareContents[cellKey(cell)]!;
|
||||||
|
box.damage += dmg;
|
||||||
|
events.push({ type: "safeDamaged", attacker: caster.id, cell: { ...cell }, amount: dmg, total: box.damage });
|
||||||
|
if (box.damage >= 15) {
|
||||||
|
delete state.squareContents[cellKey(cell)];
|
||||||
|
state.openSafes = state.openSafes.filter((k) => k !== cellKey(cell));
|
||||||
|
events.push({ type: "safeSmashed", attacker: caster.id, cell: { ...cell } });
|
||||||
|
}
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
||||||
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
||||||
// washes the slime away instead.
|
// washes the slime away instead.
|
||||||
@@ -6642,13 +6702,51 @@ function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerSt
|
|||||||
p.life += amount;
|
p.life += amount;
|
||||||
events.push({ type: "lifeGained", player: p.id, amount, source: "slow death (reversed)", lifeAfter: p.life });
|
events.push({ type: "lifeGained", player: p.id, amount, source: "slow death (reversed)", lifeAfter: p.life });
|
||||||
}
|
}
|
||||||
for (let i = 0; i < cardsDrawn * (curses.length - blessed); i++) {
|
const bites = cardsDrawn * (curses.length - blessed);
|
||||||
|
if (bites === 0) return;
|
||||||
|
// Rev 13, per the FAQ: ABSORB "will only stop the damage incurred by one
|
||||||
|
// card" — one point, one bite. A victim holding an ABSORB gets the choice
|
||||||
|
// before the bites land. Older games bit instantly and replay so.
|
||||||
|
if ((state.config.deckRev ?? 1) >= 13 && p.hand.some((c) => c.cardId === "absorb")) {
|
||||||
|
state.slowDeathPending = { playerId: p.id, points: bites };
|
||||||
|
events.push({ type: "slowDeathWindow", player: p.id, points: bites });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < bites; i++) {
|
||||||
if (!p.alive) break;
|
if (!p.alive) break;
|
||||||
applyDamage(state, events, p, 1, "slow death", null);
|
applyDamage(state, events, p, 1, "slow death", null);
|
||||||
}
|
}
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The victim's answer to hanging SLOW DEATH bites: each ABSORB soaks
|
||||||
|
* exactly one point ("the damage incurred by one card" is one bite);
|
||||||
|
* declining, or running out of absorbs, takes the rest. */
|
||||||
|
function doSlowDeathChoice(prev: GameState, playerId: PlayerId, absorbInstanceId?: string): CommandResult {
|
||||||
|
const state = clone(prev);
|
||||||
|
const pending = state.slowDeathPending!;
|
||||||
|
const p = state.players.find((q) => q.id === playerId)!;
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
if (absorbInstanceId) {
|
||||||
|
const card = p.hand.find((c) => c.instanceId === absorbInstanceId);
|
||||||
|
if (!card || card.cardId !== "absorb") return err("that is not an Absorb in your hand");
|
||||||
|
takeFromHand(p, absorbInstanceId);
|
||||||
|
state.discard.push(card);
|
||||||
|
pending.points -= 1;
|
||||||
|
events.push({ type: "slowDeathAbsorbed", player: p.id, remaining: pending.points });
|
||||||
|
if (pending.points > 0 && p.hand.some((c) => c.cardId === "absorb")) {
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.slowDeathPending = null;
|
||||||
|
for (let i = 0; i < pending.points; i++) {
|
||||||
|
if (!p.alive) break;
|
||||||
|
applyDamage(state, events, p, 1, "slow death", null);
|
||||||
|
}
|
||||||
|
checkVictory(state, events);
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
|
||||||
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
|
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
|
||||||
if (state.deck.length === 0) {
|
if (state.deck.length === 0) {
|
||||||
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
|
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface GameView {
|
|||||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||||
/** A grab hangs while the treasure's owner decides their Ward. */
|
/** A grab hangs while the treasure's owner decides their Ward. */
|
||||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||||
|
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
||||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||||
yourAmbushes: AmbushState[];
|
yourAmbushes: AmbushState[];
|
||||||
/** Once the game is finished, every hand goes face-up on the table. */
|
/** Once the game is finished, every hand goes face-up on the table. */
|
||||||
@@ -171,6 +172,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||||
: null,
|
: null,
|
||||||
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
||||||
|
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
|
||||||
yourAmbushes: state.ambushes
|
yourAmbushes: state.ambushes
|
||||||
.filter((a) => a.ownerId === playerId)
|
.filter((a) => a.ownerId === playerId)
|
||||||
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { pushSustained } from "./helpers";
|
|||||||
function actingSeat(state: GameState): PlayerId {
|
function actingSeat(state: GameState): PlayerId {
|
||||||
return (
|
return (
|
||||||
state.wardPending?.ownerId ??
|
state.wardPending?.ownerId ??
|
||||||
|
state.slowDeathPending?.playerId ??
|
||||||
state.stack?.waitingOn ??
|
state.stack?.waitingOn ??
|
||||||
state.pendingDiscard ??
|
state.pendingDiscard ??
|
||||||
state.chaosPending?.queue[0] ??
|
state.chaosPending?.queue[0] ??
|
||||||
|
|||||||
@@ -147,6 +147,45 @@ describe("slow death", () => {
|
|||||||
expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
|
expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an absorb in hand pauses the bites; each soaks one point (rev 13)", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const sd = giveCard(state, attacker, "slow-death");
|
||||||
|
state = castAt(state, attacker, defender, sd);
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId);
|
||||||
|
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
|
||||||
|
giveCard(state, defender, "absorb", "AB", 0);
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 2 });
|
||||||
|
// Two bites hang; nobody else may act.
|
||||||
|
expect(state.slowDeathPending).toEqual({ playerId: defender, points: 2 });
|
||||||
|
expect(applyCommand(state, attacker, { type: "endTurn", draw: 0 }).ok).toBe(false);
|
||||||
|
// One absorb soaks one point; the other lands.
|
||||||
|
state = must(state, defender, { type: "slowDeathChoice", absorbInstanceId: "absorb#AB" });
|
||||||
|
expect(state.slowDeathPending).toBeNull();
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(14);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "absorb")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without an absorb the bites land instantly, as in older decks", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const sd = giveCard(state, attacker, "slow-death");
|
||||||
|
state = castAt(state, attacker, defender, sd);
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
giveCard(state, defender, "absorb", "AB", 0);
|
||||||
|
const toDiscard = d.hand.filter((c) => c.cardId !== "absorb").slice(0, 2).map((c) => c.instanceId);
|
||||||
|
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 2 });
|
||||||
|
// Rev 12: even with an absorb in hand, the old decks bite instantly.
|
||||||
|
expect(state.slowDeathPending ?? null).toBeNull();
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
it("a reverse at the casting turns the curse: a point gained per draw (rev 11)", () => {
|
it("a reverse at the casting turns the curse: a point gained per draw (rev 11)", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
|
|||||||
@@ -136,6 +136,27 @@ describe("safes and lock cards", () => {
|
|||||||
expect(state.openSafes).toEqual([]);
|
expect(state.openSafes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fifteen points of battering bursts the safe open", () => {
|
||||||
|
let { state, raider, chest } = safeRig();
|
||||||
|
const box = state.players.find((p) => p.id === raider)!.position;
|
||||||
|
// Two 5-point fireballs dent it; the third bursts it.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const fb = giveCard(state, raider, "fireball", `F${i}`, 0);
|
||||||
|
state = must(state, raider, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: { ...box } },
|
||||||
|
});
|
||||||
|
if (i < 2) {
|
||||||
|
expect(state.squareContents[cellKey(box)]?.kind).toBe("safe");
|
||||||
|
state = must(state, raider, { type: "endTurn", draw: 0 });
|
||||||
|
const other = state.players.find((p) => p.id !== raider)!;
|
||||||
|
state = must(state, other.id, { type: "endTurn", draw: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(state.squareContents[cellKey(box)]).toBeUndefined();
|
||||||
|
state = must(state, raider, { type: "pickUpTreasure" });
|
||||||
|
expect(state.treasures.find((t) => t.id === chest.id)!.carriedBy).toBe(raider);
|
||||||
|
});
|
||||||
|
|
||||||
it("the key must be at or beside the safe", () => {
|
it("the key must be at or beside the safe", () => {
|
||||||
let { state, raider } = safeRig();
|
let { state, raider } = safeRig();
|
||||||
const p = state.players.find((q) => q.id === raider)!;
|
const p = state.players.find((q) => q.id === raider)!;
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ function actingSeat(room: Room): PlayerId | null {
|
|||||||
if (!s || s.phase !== "playing") return null;
|
if (!s || s.phase !== "playing") return null;
|
||||||
return (
|
return (
|
||||||
s.wardPending?.ownerId ??
|
s.wardPending?.ownerId ??
|
||||||
|
s.slowDeathPending?.playerId ??
|
||||||
s.stack?.waitingOn ??
|
s.stack?.waitingOn ??
|
||||||
s.pendingDiscard ??
|
s.pendingDiscard ??
|
||||||
s.chaosPending?.queue[0] ??
|
s.chaosPending?.queue[0] ??
|
||||||
|
|||||||
@@ -793,6 +793,13 @@
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedCard && cardDef(selectedCard.cardId).cardType === "attack" &&
|
||||||
|
view.squareContents[cellKey(cell)]?.kind === "safe") {
|
||||||
|
// Brute force: attacks batter the safe itself (fifteen points bursts it).
|
||||||
|
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedCard?.cardId === "relocate-sector") {
|
if (selectedCard?.cardId === "relocate-sector") {
|
||||||
// Any board click (re-)picks the sector; the landing is chosen from the
|
// Any board click (re-)picks the sector; the landing is chosen from the
|
||||||
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
||||||
@@ -1555,6 +1562,29 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you}
|
||||||
|
{@const sdAbsorbs = view.yourHand.filter((c) => c.cardId === "absorb")}
|
||||||
|
<div class="scrim attack-scrim" role="alertdialog" aria-label="slow death bites">
|
||||||
|
<div class="attack-notice">
|
||||||
|
<div class="attack-title">Slow Death bites for {view.slowDeathPending.points}!</div>
|
||||||
|
<div class="attack-power">
|
||||||
|
"ABSORB against SLOW DEATH will only stop the damage incurred by
|
||||||
|
one card" — each Absorb you spend soaks exactly one point.
|
||||||
|
</div>
|
||||||
|
<div class="attack-actions">
|
||||||
|
{#if sdAbsorbs.length > 0}
|
||||||
|
{@const firstAbsorb = sdAbsorbs[0]!}
|
||||||
|
<button class="stamp primary"
|
||||||
|
onclick={() => dispatch({ type: "slowDeathChoice", absorbInstanceId: firstAbsorb.instanceId })}>
|
||||||
|
Absorb a point ({sdAbsorbs.length} in hand)</button>
|
||||||
|
{/if}
|
||||||
|
<button class="hint-cancel" onclick={() => dispatch({ type: "slowDeathChoice" })}>
|
||||||
|
take {view.slowDeathPending.points === 1 ? "the point" : `all ${view.slowDeathPending.points}`}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if feedbackOpen && net.roomId}
|
{#if feedbackOpen && net.roomId}
|
||||||
<div class="scrim attack-scrim" role="button" tabindex="-1"
|
<div class="scrim attack-scrim" role="button" tabindex="-1"
|
||||||
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
|
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
|
||||||
|
|||||||
@@ -158,6 +158,13 @@
|
|||||||
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
||||||
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
||||||
</p>
|
</p>
|
||||||
|
<h3>What the table remembers</h3>
|
||||||
|
<p>
|
||||||
|
Every game here is recorded permanently, move by move — table talk
|
||||||
|
included — so finished games can be replayed, shared, and studied.
|
||||||
|
Play under whatever name you like, but know that what you say and
|
||||||
|
do at the table becomes part of its lasting record.
|
||||||
|
</p>
|
||||||
<h3>Send word</h3>
|
<h3>Send word</h3>
|
||||||
<p>
|
<p>
|
||||||
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
||||||
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
|
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
|
||||||
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
||||||
|
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
|
||||||
|
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
|
||||||
|
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — an Absorb hovers over the wound…`;
|
||||||
|
case "slowDeathAbsorbed": return `${e.player}'s Absorb soaks one point of the rot (${e.remaining} to go).`;
|
||||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||||
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
|
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
|
||||||
case "teleported": return e.by === e.player
|
case "teleported": return e.by === e.player
|
||||||
|
|||||||
Reference in New Issue
Block a user