The drake keeps its Dragon home: its evaluation counts a Dragon's distance from the air above its own King, an enemy Dragon near that air, and draws the Griffon, Hero, Paladin and Mage towards a raider; drake against drake no longer races Dragons to a mate by ply 21

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-26 22:14:25 -04:00
1 parent 47e9bc39ef
commit 944e4e8306
3 files changed
+78 -7

No files matched your search

+54 -4
View File
@@ -6,7 +6,7 @@
import {
CELLS, PER_LEVEL, VALUES, type Cell, type Kind, type Side, type Setup,
frozen, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves
frozen, idx, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves
} from './board';
/** Nodes a move may cost: under two seconds on the one-core host, a fifth of that on a laptop. The server waits on it. */
@@ -20,9 +20,37 @@ const WEIGHT: Record<Kind, number> = {
K: 0, M: 220, P: 200, C: 180, R: 200, G: 100, O: 100, H: 90, T: 80, E: 80, B: 60, U: 50, D: 40, S: 20, W: 20
};
/** How the position looks to `side`: material, a little shape, and the cost of being frozen or off the land. */
/** The pieces that can reach the air and so can hunt a Dragon: the Griffon, the Hero, the Paladin and the Mage. */
const HUNTERS: ReadonlySet<Kind> = new Set<Kind>(['G', 'H', 'P', 'M']);
/** Chebyshev distance between two squares of the air, the Dragon's measure. */
const apart = (a: number, b: number): number => Math.max(Math.abs(fileOf(a) - fileOf(b)), Math.abs(rankOf(a) - rankOf(b)));
/**
* What a Dragon means to a King: the cost of a King whose own Dragon has
* wandered from the air above it, and the greater cost of an enemy Dragon
* near enough to strike the King's square or its neighbours. Gygax's
* advice is to keep the Dragon home, and the drake takes it, since a race
* of raiding Dragons is won by whoever moved first.
*/
function roof(cells: Cell[], king: number, own: number, enemy: number): number {
if (king < 0) return 0;
const above = idx(3, fileOf(king), rankOf(king));
let v = 0;
if (own >= 0) v -= 25 * Math.max(0, apart(own, above) - 1);
if (enemy >= 0) {
const near = apart(enemy, above);
v -= near <= 1 ? 90 : near === 2 ? 45 : near === 3 ? 15 : 0;
}
return v;
}
/** How the position looks to `side`: material, a little shape, the cost of being frozen or off the land, and the Dragons' bearing on the Kings. */
export function evaluate(cells: Cell[], side: Side): number {
let score = 0;
const king = { gold: -1, scarlet: -1 };
const dragon = { gold: -1, scarlet: -1 };
const hunters: Record<Side, number[]> = { gold: [], scarlet: [] };
for (let i = 0; i < CELLS; i++) {
const cell = cells[i];
if (cell === '') continue;
@@ -36,15 +64,37 @@ export function evaluate(cells: Cell[], side: Side): number {
const advance = sideOf(cell) === 'gold' ? rank - 1 : 6 - rank;
v += advance * 4;
} else if (kind === 'K') {
king[sideOf(cell)!] = i;
if (level !== 2) v -= 40;
} else if (level === 2 && kind !== 'D' && kind !== 'S' && kind !== 'E') {
} else if (kind === 'R') {
dragon[sideOf(cell)!] = i;
} else if (HUNTERS.has(kind)) {
hunters[sideOf(cell)!].push(i);
}
if (level === 2 && kind !== 'W' && kind !== 'K' && kind !== 'D' && kind !== 'S' && kind !== 'E') {
// Pieces of the land like the middle of it.
v += 6 - Math.abs(file - 5.5) - Math.abs(rank - 3.5);
}
if (frozen(cells, i)) v = Math.floor(v / 3);
score += own ? v : -v;
}
return score;
const foe = other(side);
return score + roof(cells, king[side], dragon[side], dragon[foe]) - roof(cells, king[foe], dragon[foe], dragon[side])
+ hunt(side, hunters[side], dragon[foe]) - hunt(foe, hunters[foe], dragon[side]);
}
/**
* A Dragon raiding in `side`'s half of the air can be caught only by the
* pieces that can rise to meet it, so they are drawn towards it; a Dragon
* at home among its own pieces draws nothing.
*/
function hunt(side: Side, hunters: number[], dragon: number): number {
if (dragon < 0) return 0;
const rank = rankOf(dragon);
if (side === 'gold' ? rank > 3 : rank < 4) return 0;
let v = 0;
for (const i of hunters) v += 6 * Math.max(0, 3 - apart(i, dragon));
return v;
}
interface Search {
+23 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_RULES, applyMove, createGame, game, describe as tell, notation, positionKey, resolveRound, validateMove, type State } from './index';
import { CELLS, census, frozen, inCheck, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board';
import { chooseMove } from './bot';
import { CELLS, census, frozen, inCheck, kindOf, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board';
import { chooseMove, evaluate } from './bot';
/** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */
function position(pieces: Record<string, string>, toMove: Side = 'gold'): State {
@@ -336,6 +336,27 @@ describe('the drake', () => {
expect(move).toEqual({ from: square('2h8'), to: square('2h1') });
});
it('keeps its Dragon home and sees a raiding Dragon as the danger it is', () => {
const s = createGame({ A: 'Aud', B: 'Bjorn' });
// The Dragon can take a Sylph on the first move, without recourse. The drake declines.
expect(legalMoves(s.cells, 'gold').some(([from, to]) => from === square('3g1') && to === square('3a7'))).toBe(true);
const first = chooseMove(s.cells, 'gold', 'printed', 20000)!;
expect(kindOf(s.cells[first.from])).not.toBe('R');
// A Dragon that has flown to the far corner is worth less to its side than one at home, Sylph and all.
const raided = s.cells.slice();
raided[square('3a7')] = raided[square('3g1')];
raided[square('3g1')] = '';
expect(evaluate(raided, 'gold')).toBeLessThan(evaluate(s.cells, 'gold'));
// An enemy Dragon near the air above the King is worse than the same Dragon as far from its own home but off in a corner.
const near = s.cells.slice();
near[square('3f3')] = near[square('3g8')];
near[square('3g8')] = '';
const far = s.cells.slice();
far[square('3b3')] = far[square('3g8')];
far[square('3g8')] = '';
expect(evaluate(near, 'gold')).toBeLessThan(evaluate(far, 'gold'));
});
it('plays a whole game against itself on a small budget and the game ends', { timeout: 120_000 }, () => {
let s = createGame({ A: 'Aud', B: 'Bjorn' });
let plies = 0;
+1 -1
View File
@@ -38,7 +38,7 @@ export const LORE: Record<Kind, Lore> = {
is: 'The great wyrm of the sky, roosting above its own King on the g file. The game takes its name from this one.',
moves: 'It never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. And it strikes from afar. Without moving, it can take an enemy on the land directly beneath it or on any of the four squares beside that one. The victim is lifted away and the Dragon stays where it is.',
why: 'Gygax set the Dragon directly over the King on purpose. Its strike covers the five land squares beneath it, so a Dragon at home is a roof over the King, and a Dragon that flies off to hunt leaves the King under the other Dragon’s shadow.',
play: 'Gygax rated it at eight, and players since have called that too low. The drake values it as a Paladin. Early, keep it home and let it threaten. Later, a Dragon over the enemy’s back rank is a terror, since only five kinds of land piece can reach the air at all. Mind the Sylphs, which capture straight ahead on its board and cost a point to lose.'
play: 'Gygax rated it at eight, and players since have called that too low. The drake values it as a Paladin, and keeps it home until the other Dragon is dead or far away. Early, keep yours home and let it threaten. Later, a Dragon over the enemy’s back rank is a terror, since only five kinds of land piece can reach the air at all. Mind the Sylphs, which capture straight ahead on its board and cost a point to lose.'
},
W: {
count: 'twelve a side',