The clockwork reads the whole deck
Every playable card now has a place in the automaton's mind. The
damage table completes: lightning blast, wizardblade, and power drain
scale with numbers (and are skipped without one), disease bites in
the shared square, the dagger's true three points corrected. A new
affliction book casts the miseries — blind, slow, medusa, no-spell,
lock-in-place, walking dead, slow death, idiot, thought steal,
go-away — with a number for the duration, and WEAKNESS is saved for
whoever carries stolen gold. AMPLIFY doubles the heavy spells.
The counteraction ladder runs the full rack: REVERSE eats the big
blasts, ABSORB SPELL steals the good ones, FULL SHIELD, FULL
REFLECTION, REMOVE CURSE refuses afflictions at the door, REFLECTION
halves, ABSORB soaks the mid-range, BLUNT, EMPATHY out of berserker
spite, teleport escapes, shieldstone numbers.
Housekeeping: every stone hits the table the turn it is drawn, GIFT
FROM ABOVE is cashed instantly, curses get scrubbed with REMOVE
CURSE, LIFESAVER goes up at three players, a SAFE or GLUE guards
floor gold with enemies near, and DEJA-VU pulls the best attack back
from the discard when the hand runs dry of violence.
Table talk verified live ("ACQUISITION COMPLETE." mid-probe) — the
silence was timing, not plumbing — and the remark odds rise to half.
Three-way tournaments tell true stories now: the hunter wins on
gold, the worrier survives to win by attrition, and the berserker
learns that aggression in a three-way gets you buried first.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3eda686e29
commit
a6ede6ca1e
@@ -15,18 +15,46 @@ export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
|||||||
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||||
|
|
||||||
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
|
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
|
||||||
const ATTACKS: Record<string, { base: number; perNumber: boolean }> = {
|
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
|
||||||
fireball: { base: 5, perNumber: false },
|
fireball: { base: 5, perNumber: false },
|
||||||
"sudden-death": { base: 10, perNumber: false },
|
"sudden-death": { base: 10, perNumber: false },
|
||||||
powerthrust: { base: 2, perNumber: true },
|
powerthrust: { base: 2, perNumber: true },
|
||||||
waterbolt: { base: 0, perNumber: true },
|
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
||||||
dagger: { base: 1, perNumber: false },
|
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
|
||||||
|
wizardblade: { base: 0, perNumber: true, needsNumber: true },
|
||||||
|
"power-drain": { base: 0, perNumber: true, needsNumber: true },
|
||||||
|
dagger: { base: 3, perNumber: false },
|
||||||
"large-rock": { base: 2, perNumber: false },
|
"large-rock": { base: 2, perNumber: false },
|
||||||
"blaster-wand": { base: 3, perNumber: false },
|
"blaster-wand": { base: 3, perNumber: false },
|
||||||
|
disease: { base: 3, perNumber: false, sameSquare: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]);
|
/**
|
||||||
const UNLOCKS = ["master-key", "pick-lock"];
|
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
|
||||||
|
* number for the duration where one helps.
|
||||||
|
*/
|
||||||
|
const AFFLICTIONS: Record<string, { withNumber: boolean; vsCarrier?: boolean }> = {
|
||||||
|
blind: { withNumber: true },
|
||||||
|
slow: { withNumber: true },
|
||||||
|
"no-spell": { withNumber: true },
|
||||||
|
medusa: { withNumber: true },
|
||||||
|
"lock-in-place": { withNumber: true },
|
||||||
|
"walking-dead": { withNumber: false },
|
||||||
|
"slow-death": { withNumber: false },
|
||||||
|
idiot: { withNumber: false },
|
||||||
|
weakness: { withNumber: false, vsCarrier: true },
|
||||||
|
"go-away": { withNumber: true },
|
||||||
|
"thought-steal": { withNumber: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every stone earns its place on the table the moment it is drawn. */
|
||||||
|
const STONES = new Set([
|
||||||
|
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
||||||
|
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
|
||||||
|
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
|
||||||
|
|
||||||
function me(view: GameView) {
|
function me(view: GameView) {
|
||||||
return view.players.find((p) => p.id === view.you)!;
|
return view.players.find((p) => p.id === view.you)!;
|
||||||
@@ -50,10 +78,12 @@ function numbersInHand(view: GameView): CardInstance[] {
|
|||||||
function discardValue(c: CardInstance): number {
|
function discardValue(c: CardInstance): number {
|
||||||
const def = cardDef(c.cardId);
|
const def = cardDef(c.cardId);
|
||||||
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
|
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
|
||||||
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId)) return 8;
|
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
|
||||||
if (ATTACKS[c.cardId] != null) return 7;
|
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7;
|
||||||
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
||||||
c.cardId === "ward" || c.cardId === "drop-object") return 6;
|
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") return 6;
|
||||||
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
||||||
if (def.cardType === "attack") return 3;
|
if (def.cardType === "attack") return 3;
|
||||||
return 2; // situational neutrals go first
|
return 2; // situational neutrals go first
|
||||||
@@ -210,18 +240,42 @@ function respond(view: GameView, style: AutomatonStyle): Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flinch = style === "worrier" ? 1 : 0;
|
const flinch = style === "worrier" ? 1 : 0;
|
||||||
const atk = stack.attackCard ? ATTACKS[stack.attackCard.cardId] : null;
|
const attackId = stack.attackCard?.cardId ?? null;
|
||||||
|
const atk = attackId ? ATTACKS[attackId] : null;
|
||||||
|
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||||
const incoming = stack.attackCard
|
const incoming = stack.attackCard
|
||||||
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||||
: 1;
|
: 1;
|
||||||
if (stack.kind === "spell") {
|
if (stack.kind === "spell") {
|
||||||
|
// REVERSE turns the biggest blasts into a meal.
|
||||||
|
const reverse = find("reverse");
|
||||||
|
if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||||
|
// ABSORB SPELL steals the good ones for later.
|
||||||
|
const absorbSpell = find("absorb-spell");
|
||||||
|
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||||
const shield = find("full-shield");
|
const shield = find("full-shield");
|
||||||
if (shield && incoming >= 3 - flinch) return { type: "counteract", instanceId: shield.instanceId };
|
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||||
|
return { type: "counteract", instanceId: shield.instanceId };
|
||||||
|
}
|
||||||
const reflect = find("full-reflection");
|
const reflect = find("full-reflection");
|
||||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||||
|
// A curse in flight is best refused at the door.
|
||||||
|
if (affliction) {
|
||||||
|
const cleanse = find("remove-curse");
|
||||||
|
if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId };
|
||||||
}
|
}
|
||||||
|
const half = find("reflection");
|
||||||
|
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
|
||||||
|
}
|
||||||
|
const absorb = find("absorb");
|
||||||
|
if (absorb && incoming >= 2 && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
||||||
const blunt = find("blunt");
|
const blunt = find("blunt");
|
||||||
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||||
|
// The berserker shares its pain out of spite.
|
||||||
|
const empathy = find("empathy");
|
||||||
|
if (empathy && style === "berserker" && incoming >= 2) {
|
||||||
|
return { type: "counteract", instanceId: empathy.instanceId };
|
||||||
|
}
|
||||||
// Nothing to block with: teleport clear of the big ones.
|
// Nothing to block with: teleport clear of the big ones.
|
||||||
const tp = find("teleport");
|
const tp = find("teleport");
|
||||||
if (tp && incoming >= 4 - flinch) {
|
if (tp && incoming >= 4 - flinch) {
|
||||||
@@ -237,22 +291,30 @@ function respond(view: GameView, style: AutomatonStyle): Command {
|
|||||||
return { type: "pass" };
|
return { type: "pass" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The best attack available against a visible target, numbers included. */
|
/** The best attack available against a visible target, numbers and amplify included. */
|
||||||
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
||||||
const numbers = numbersInHand(view);
|
const numbers = numbersInHand(view);
|
||||||
const biggest = numbers[numbers.length - 1];
|
const biggest = numbers[numbers.length - 1];
|
||||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||||
|
const target = view.players.find((p) => p.id === targetId)!;
|
||||||
|
const together = cellKey(target.position) === cellKey(me(view).position);
|
||||||
|
const amplify = inHand(view, "amplify");
|
||||||
let best: { cmd: Command; damage: number } | null = null;
|
let best: { cmd: Command; damage: number } | null = null;
|
||||||
for (const c of view.yourHand) {
|
for (const c of view.yourHand) {
|
||||||
const atk = ATTACKS[c.cardId];
|
const atk = ATTACKS[c.cardId];
|
||||||
if (!atk) continue;
|
if (!atk) continue;
|
||||||
|
if (atk.sameSquare && !together) continue;
|
||||||
// A wand needs its charges set by a number on first use.
|
// A wand needs its charges set by a number on first use.
|
||||||
const isWand = c.cardId === "blaster-wand";
|
const isWand = c.cardId === "blaster-wand";
|
||||||
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
||||||
if (uncharged && !biggest) continue;
|
if ((uncharged || atk.needsNumber) && !biggest) continue;
|
||||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
const withNumber = (atk.perNumber || uncharged) && biggest;
|
||||||
const damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||||
if (damage <= 0) continue;
|
if (damage <= 0) continue;
|
||||||
|
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
||||||
|
const amplified = amplify && damage >= 4 && !isWand &&
|
||||||
|
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||||
|
if (amplified) damage *= 2;
|
||||||
if (!best || damage > best.damage) {
|
if (!best || damage > best.damage) {
|
||||||
best = {
|
best = {
|
||||||
damage,
|
damage,
|
||||||
@@ -260,6 +322,7 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
|||||||
type: "cast", instanceId: c.instanceId,
|
type: "cast", instanceId: c.instanceId,
|
||||||
target: { kind: "player", playerId: targetId },
|
target: { kind: "player", playerId: targetId },
|
||||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||||
|
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -267,6 +330,24 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
|||||||
return best?.cmd ?? null;
|
return best?.cmd ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An affliction worth casting when no damage lands, mid number attached. */
|
||||||
|
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
|
||||||
|
const numbers = numbersInHand(view);
|
||||||
|
const mid = numbers[Math.floor(numbers.length / 2)];
|
||||||
|
for (const c of view.yourHand) {
|
||||||
|
const aff = AFFLICTIONS[c.cardId];
|
||||||
|
if (!aff) continue;
|
||||||
|
if (aff.vsCarrier && !isThief) continue;
|
||||||
|
if (aff.withNumber && !mid) continue;
|
||||||
|
return {
|
||||||
|
type: "cast", instanceId: c.instanceId,
|
||||||
|
target: { kind: "player", playerId: targetId },
|
||||||
|
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** An empty, sighted square near the target for a summoned creature. */
|
/** An empty, sighted square near the target for a summoned creature. */
|
||||||
function summonSpot(view: GameView, near: Cell): Cell | null {
|
function summonSpot(view: GameView, near: Cell): Cell | null {
|
||||||
const sighted = sightedCellsFor(view);
|
const sighted = sightedCellsFor(view);
|
||||||
@@ -293,15 +374,28 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
|
|||||||
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
|
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
|
||||||
return { type: "cast", instanceId: speed.instanceId };
|
return { type: "cast", instanceId: speed.instanceId };
|
||||||
}
|
}
|
||||||
|
// Free life is free.
|
||||||
|
const gift = inHand(view, "gift-from-above");
|
||||||
|
if (gift) return { type: "cast", instanceId: gift.instanceId };
|
||||||
|
// Every stone goes on the table the turn it is drawn.
|
||||||
|
for (const c of view.yourHand) {
|
||||||
|
if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) {
|
||||||
|
return { type: "cast", instanceId: c.instanceId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A curse on the clockwork gets scrubbed off.
|
||||||
|
const cursed = view.sustained.some(
|
||||||
|
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
|
||||||
|
);
|
||||||
|
const cleanse = inHand(view, "remove-curse");
|
||||||
|
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
|
||||||
|
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||||
|
const lifesaver = inHand(view, "lifesaver");
|
||||||
|
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
||||||
// The ward guards the gold while its owner is away robbing yours.
|
// The ward guards the gold while its owner is away robbing yours.
|
||||||
if (inHand(view, "ward") && !view.yourWardArmed) {
|
if (inHand(view, "ward") && !view.yourWardArmed) {
|
||||||
return { type: "armWard", armed: true };
|
return { type: "armWard", armed: true };
|
||||||
}
|
}
|
||||||
// A shieldstone on the table turns spare numbers into armor.
|
|
||||||
const stone = inHand(view, "shieldstone");
|
|
||||||
if (stone && !self.displayed.some((c) => c.cardId === "shieldstone")) {
|
|
||||||
return { type: "cast", instanceId: stone.instanceId };
|
|
||||||
}
|
|
||||||
const enemyNear = livingEnemies(view).some(
|
const enemyNear = livingEnemies(view).some(
|
||||||
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
||||||
);
|
);
|
||||||
@@ -334,6 +428,35 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
||||||
|
const myFloorTreasure = view.treasures.find(
|
||||||
|
(t) => t.owner === view.you && t.position && !t.carriedBy,
|
||||||
|
);
|
||||||
|
if (myFloorTreasure && enemyNear) {
|
||||||
|
const safe = inHand(view, "safe");
|
||||||
|
if (safe) {
|
||||||
|
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
|
||||||
|
}
|
||||||
|
const glue = inHand(view, "glue");
|
||||||
|
const midN = numbers[Math.floor(numbers.length / 2)];
|
||||||
|
if (glue && midN) {
|
||||||
|
return {
|
||||||
|
type: "cast", instanceId: glue.instanceId,
|
||||||
|
target: { kind: "cell", cell: myFloorTreasure.position! },
|
||||||
|
numberInstanceIds: [midN.instanceId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
|
||||||
|
const dv = inHand(view, "deja-vu");
|
||||||
|
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
|
||||||
|
const buried = [...view.discardPile].reverse().find(
|
||||||
|
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
|
||||||
|
);
|
||||||
|
if (buried) {
|
||||||
|
return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } };
|
||||||
|
}
|
||||||
|
}
|
||||||
// An ambush costs nothing to hold and everything to walk into.
|
// An ambush costs nothing to hold and everything to walk into.
|
||||||
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire");
|
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire");
|
||||||
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
|
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
|
||||||
@@ -434,6 +557,8 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
|
|||||||
}
|
}
|
||||||
const spell = bestAttack(view, target.id);
|
const spell = bestAttack(view, target.id);
|
||||||
if (spell) return spell;
|
if (spell) return spell;
|
||||||
|
const misery = bestAffliction(view, target.id, thief?.id === target.id);
|
||||||
|
if (misery) return misery;
|
||||||
if (cellKey(target.position) === here && style !== "worrier") {
|
if (cellKey(target.position) === here && style !== "worrier") {
|
||||||
return { type: "punch", targetId: target.id };
|
return { type: "punch", targetId: target.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ function botRemark(room: Room, seat: string, events: { type: string; [k: string]
|
|||||||
(e.type === "creatureCreated" && e.controller === seat) ||
|
(e.type === "creatureCreated" && e.controller === seat) ||
|
||||||
(e.type === "wallCreated" && e.caster === seat);
|
(e.type === "wallCreated" && e.caster === seat);
|
||||||
const pool = lines[e.type];
|
const pool = lines[e.type];
|
||||||
if (!mine || !pool || Math.random() > 0.35) continue;
|
if (!mine || !pool || Math.random() > 0.5) continue;
|
||||||
const text = pool[Math.floor(Math.random() * pool.length)]!;
|
const text = pool[Math.floor(Math.random() * pool.length)]!;
|
||||||
const said = addChat(room, seat, text);
|
const said = addChat(room, seat, text);
|
||||||
if (!("error" in said)) {
|
if (!("error" in said)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user