diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 1780de3..35a287c 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -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 = { +const ATTACKS: Record = { 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 = { + 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 }; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0ba36c9..e2bed1e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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)) {