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"];
|
||||
|
||||
/** 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 },
|
||||
"sudden-death": { base: 10, perNumber: false },
|
||||
powerthrust: { base: 2, perNumber: true },
|
||||
waterbolt: { base: 0, perNumber: true },
|
||||
dagger: { base: 1, perNumber: false },
|
||||
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
||||
"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 },
|
||||
"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) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
@@ -50,10 +78,12 @@ function numbersInHand(view: GameView): CardInstance[] {
|
||||
function discardValue(c: CardInstance): 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)) return 8;
|
||||
if (ATTACKS[c.cardId] != null) return 7;
|
||||
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 (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 === "attack") return 3;
|
||||
return 2; // situational neutrals go first
|
||||
@@ -210,18 +240,42 @@ function respond(view: GameView, style: AutomatonStyle): Command {
|
||||
}
|
||||
|
||||
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
|
||||
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||
: 1;
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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.
|
||||
const tp = find("teleport");
|
||||
if (tp && incoming >= 4 - flinch) {
|
||||
@@ -237,22 +291,30 @@ function respond(view: GameView, style: AutomatonStyle): Command {
|
||||
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 {
|
||||
const numbers = numbersInHand(view);
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
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;
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
if (atk.sameSquare && !together) continue;
|
||||
// A wand needs its charges set by a number on first use.
|
||||
const isWand = c.cardId === "blaster-wand";
|
||||
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 damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
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) {
|
||||
best = {
|
||||
damage,
|
||||
@@ -260,6 +322,7 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -267,6 +330,24 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | 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. */
|
||||
function summonSpot(view: GameView, near: Cell): Cell | null {
|
||||
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) {
|
||||
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.
|
||||
if (inHand(view, "ward") && !view.yourWardArmed) {
|
||||
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(
|
||||
(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.
|
||||
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire");
|
||||
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);
|
||||
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") {
|
||||
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 === "wallCreated" && e.caster === seat);
|
||||
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 said = addChat(room, seat, text);
|
||||
if (!("error" in said)) {
|
||||
|
||||
Reference in New Issue
Block a user