NAME

Game::Xiangqi::Engine - the xiangqi board, in C, with nothing that judges a move

SYNOPSIS

use Game::Xiangqi::Engine ':all';

my $b = Game::Xiangqi::Engine->new;              # the opening position
my $e = Game::Xiangqi::Engine->new(empty => 1);
my ($p, $err) = Game::Xiangqi::Engine->of_fen($fen);

my $pt = Game::Xiangqi::Engine->point_of(4, 0);  # file e, rank 0
$b->at($pt) == (RED | GENERAL);

my $mv = Game::Xiangqi::Engine->move($from, $to);
my ($captured, $undo) = $b->do_move($mv);
$b->undo_move($undo);

DESCRIPTION

The board and nothing else. put, lift and do_move are structure primitives and judge nothing: a caller may stack three generals on one point or leave a side without one. Legality, check and mate arrive in later phases and append to the C ABI rather than changing it.

A point is an opaque padded index

The board is 11 by 12 cells with a sentinel ring, so a neighbour walk needs no bounds test. A point is not rank * 9 + file and nothing outside this module may assume it is: build one with point_of and take it apart with file_of and rank_of.

Rank 0 is Red's back rank, which is ICCS's numbering and therefore the move log's. A FEN's first row is rank 9.

The key is a hex string, never a number

key_hex returns sixteen hex characters. On a perl with 32-bit IVs a UV cannot hold a 64-bit key and the top half would vanish silently, which means a test that passes while comparing half a number.

Every board is its own board

A board is a C allocation held in a private attribute and dropped by DEMOLISH when the object goes. Nothing outside this module can read the pointer, let alone overwrite it. clone allocates a new board, so two Perl objects never share one and a move on either is invisible to the other.

new(fen => ... ) croaks on a FEN it will not take, because a FEN that does not parse is a bug in the caller. of_fen is the route for a FEN that came from outside: it returns the board and the refusal code, or undef and the code, and never dies. That is what bin/xiangqi and the UCCI mode use.

do_move hands back the captured piece and an opaque undo token for undo_move. The token is a string of bytes rather than a pointer, so a caller who never undoes leaks nothing.

A list in list context, a count in scalar context

moves and legal return the moves in list context and how many there are in scalar context. is_chase, protected_at, search and search_to_depth return their first value in scalar context.

That is a guard rather than a convenience. The XSUBs underneath push their results onto the stack, and such an XSUB in scalar context returns the last value pushed: without these wrappers scalar $b->legal would be a packed move integer where a count was asked for, and scalar $b->search(...) would be stopped, a 0 or a 1 that looks like a move number. Both look like answers. scalar($b->legal) read 16437 from the opening position, where the count is 44.

Nothing about a position is a draw

outcome returns (winner, reason), and a side with no legal move loses whether it is in check or not. There is deliberately no draw value: every draw this game has is a property of a sequence, so it comes from judge and can never come from a position.

mate_in answers about checkmate specifically, because its caller is the Asian Rules' "threatening to checkmate". A forced stalemate is also a win here and mate_in says nothing about one; a search wanting terminal values uses outcome.

perft counts are strings, and mates are counted a ply early

perft returns (nodes, checks, captures, mates), each as a decimal string, because depth 6 is 5,392,831,844 and a 32-bit IV would quietly keep the low half of it.

A mate is counted at the node whose side to move is mated, one ply earlier than the move that delivered it. That is the published table's convention, and reading it the other way disagrees with every ladder there is to check against.

The vocabulary rules on nothing

is_check, is_mate, is_ttc, is_exchange, is_block, is_sacrifice, is_idle, is_chase and protected_at are the words the Asian Rules' Section 1 defines, each taking a move against the position as it stands. None of them decides anything: they exist so that the repetition rules can be a table of sentences over predicates instead of forty hand-rolled position tests, and judge is what rules.

protected_at returns (protected, real), and the second value is the source's own distinction: rule 34 turns on nothing else, because a protector that cannot actually recapture is a false protector. value_of is in centi-soldiers and is declared in the C so that is_exchange and the evaluation cannot drift apart.

The search is bounded in nodes and never in seconds

my ($mv, $nodes, $depth, $score, $stopped) = $b->search($budget, $seed);

$budget is a node count. It is not a number of seconds and there is no way to ask for one, because the site plays its bot inside the move transaction: a search bounded by time would make the same position answer differently on a loaded machine than on an idle one, and a bot game would stop replaying. Nothing here sets an alarm or handles a signal.

Spent through iterative deepening, so a budget that runs out always leaves a complete search one ply shallower rather than half of a deeper one. $nodes comes back as a string, because it is a 64-bit count. $depth is the last depth completed. $stopped says the budget ran out, which is the normal case and not an error.

$seed separates root moves that score exactly the same, and the caller must mix the seat into it. Game::Xiangqi::Bot does; anything else that calls search directly for two sides of one game and forgets will find both sides playing the same opening every time.

A depth, when a budget will not do

my ($mv, $nodes, $depth) = $b->search_to_depth(4, 2_000_000, $seed);

The same search, stopped after that many plies rather than after that many nodes. It exists for UCCI's go depth, which is how an external engine becomes an oracle: two engines can only be compared at equal depth, and a node budget cannot promise one. search is this function at its depth ceiling, so there is one search and not two.

The budget is still required and a generous one is the caller's job, because quiescence is bounded by the budget and by nothing else.

The evaluation is four terms, in centi-soldiers

evaluate answers from the point of view of the side to move, positive for good, with a soldier worth 100 so nothing needs a float.

Material alone plays a bad opening in this game, which is the mistake to know about before reading the other three terms: a cannon's value is positional from the first move, and a horse with its legs blocked is not worth what the table says it is.

Material

chariot   900
cannon    450
horse     400
advisor   200
elephant  200
soldier   100, and 200 once it has crossed the river

Flat, and a phase-tapered table is the obvious later improvement: a cannon is stronger early, while screens are plentiful and horses are still blocked, and a horse is stronger late.

Mobility

The difference in the number of moves available, which does more work in xiangqi than the same term does in chess. A horse hobbled at three of its four legs is close to worthless and the material term cannot see that at all.

The general's safety

The palace's own term: the advisors and elephants still at home, and a penalty for the general's own file standing open. The open file is the one that matters, because of the flying general: an open file between the two of them is not a weakness, it is a move.

Soldier advance

The march by rank, counted only beyond the river, because the crossing itself is already paid for in the material term and counting it twice would send every soldier forward at once.

SEE ALSO

include/xq_abi.h, which is the contract this module is a thin Perl skin over.