NAME

Game::Xiangqi - xiangqi, Chinese chess

SYNOPSIS

use Game::Xiangqi;

my $g = Game::Xiangqi->new(seed => $thirty_two_bytes, red => 'p1');
$g->turn;                 # 'p1'
$g->legal;                # [ 'h2e2', ... ] in ICCS
my $why = $g->play('h2e2');   # 0, or a refusal NAME
$g->status;               # 'active' | 'finished'
$g->result;               # { winner, reason, rule, loop }

my ($replayed, $why, $at) = Game::Xiangqi->replay(
    seed => $seed, red => 'p1', moves => $g->log);

DESCRIPTION

Xiangqi, the game most of the world knows as Chinese chess: nine files, ten ranks, pieces on the intersections, a river across the middle and a palace at each end.

It is the engine behind the Xiangqi at https://peer2peergames.com.

A refusal is returned, never thrown

play hands back a Game::Xiangqi::Error or 0. The flags are specific on purpose: elephant_crosses_river rather than not_legal, because a player told which rule they broke does not come back and argue.

A bad construction, on the other hand, croaks. The two are different kinds of wrong: a refused move is an ordinary thing for a player to do, while a seed of the wrong length is a bug in the caller and is raised where it happens.

The Perl layer is Object::Proto::Sugar

Every attribute here is declared with has, so the objects are arrays and not hashes and $game->{status} is not a thing that works. Reach for the accessors: status, red, position, log. The internals are named with a leading underscore and the board itself is a private attribute, so a caller cannot reach past the counters and the judge by accident.

Stalemate is a loss

A side with no legal move loses, in check or not. There is no draw-by-position in this game at all: every draw it has is a property of a SEQUENCE, and those are the Asian Rules on repetition and three counters adopted from CXQ.

The result carries a rule number

When the Asian Rules decide a game, result carries the number of the rule that did it. A ruling with no number is one a player cannot check.

The three counters

Three of the draws are counters adopted from CXQ rather than rulings from the Asian Rules, and result's reason says which one ended the game. progress is thirty moves a side with no capture and no soldier advancing over the river. effective is a hundred and twenty moves a side counting only moves that are neither a check nor a chase nor a reply to one, which is CXQ's Effective Rule. moves is three hundred moves a side outright.

All three are counted by the judge while it classifies the sequence, because only the classification knows whether a move was an effective one, and all three come back with rule 0: they are house counters and there is no Asian Rules number to give.

The seed is held and never read

Xiangqi has no randomness in it. The seed is stored because the site hands every game thirty-two bytes and publishes them when the game finishes so it can be checked, and seed returns undef until then. Do not remove it on the grounds that nothing reads it.

METHODS

new

my $g = Game::Xiangqi->new(seed => $bytes, red => 'p1', position => $pos);

seed is exactly thirty-two bytes and is required; red is 'p1' or 'p2' and says which seat plays Red and therefore moves first, defaulting to 'p1'. Either of them wrong croaks, because a game built from a seed of the wrong length is the caller's bug and not a thing a player did. A refused move, by contrast, is returned: see Game::Xiangqi::Error.

position is an optional Game::Xiangqi::Engine to start from instead of the opening, and it is cloned, so the caller keeps theirs.

A game handed a position that is already finished reports itself finished immediately, rather than offering a turn with no moves in it.

status

'active' or 'finished'. Read-write: setting it is how a caller that has decided the game is over out-of-band, such as a resignation, says so.

red

The seat that plays Red, 'p1' or 'p2'. Read-only: which seat is Red is decided once, at construction.

turn

The seat to move, 'p1' or 'p2', or undef once the game is over.

An arrayref of every legal move for the side to move, in ICCS coordinates. Empty once the game is finished.

play

my $refusal = $g->play('h2e2');
say $refusal->code if $refusal;      # 'elephant_crosses_river'

Plays one move given in ICCS. Returns 0 on success or a Game::Xiangqi::Error object carrying the reason; it never throws, whatever it is handed. On success the game's counters, its ruling and its turn are all brought up to date.

The refusal is an object rather than a name so that a caller can ask $refusal->code for the flag, $refusal->message for the sentence to show a player, and $refusal->in_check for one specific reason, without a table of its own. There is no "" overload, so a refusal used as a string is a reference and looks like the mistake it is.

result

{ winner => 'p1' | 'p2' | undef,
  reason => 'checkmate' | 'stalemate' | 'perpetual_check' | 'perpetual_chase'
          | 'mutual' | 'no_violation' | 'effective' | 'progress' | 'moves',
  rule   => 19,
  loop   => [ 'h2e2', ... ] }

Empty while the game is on. rule is the Asian Rules rule number when the judge decided it and 0 otherwise, and loop is the repeated sequence when there was one.

winner

The winning seat, or undef. A shorthand for result->{winner}.

log

An arrayref copy of the moves so far, in ICCS. This is the canonical serialisation of the game: nothing else needs to be stored.

replay

my $again = Game::Xiangqi->replay(seed => $s, red => 'p1', moves => \@log);
my $again = $game->replay(\@log);

Rebuilds a game by playing the log from the start position, every time. It never loads a stored position, which is what makes a finished game checkable and a forged log catchable: an altered move is refused at that move and not after it.

The class form builds a fresh game from a seed and a log. The instance form replays this game's own moves from its own starting position, seed and seat.

In list context both return ($game, $refusal, $index) so a caller can see where a bad log went wrong, $refusal being a Game::Xiangqi::Error. In scalar context they return the game, or undef if any move was refused. A misuse, such as an odd-sized list, returns undef and does not warn; a bad seed croaks, as it does in new.

position

The live Game::Xiangqi::Engine board. Reading it is fine; moving on it directly bypasses the counters and the judge.

signature

Sixteen hex characters identifying the position, from the Zobrist key. Changes on every move. A string and never a number, because a 64-bit key does not fit in an IV on every perl.

seed

The thirty-two bytes the game was built with, but only once the game is finished, and undef before that. The engine never reads the seed: xiangqi has no randomness in it at all. It is held because the site publishes it when a game ends so that the game can be checked.

bot

The name of the opponent class, Game::Xiangqi::Bot, loaded on demand.

SEE ALSO

Game::Xiangqi::Engine, the board; Game::Xiangqi::Notation, the two spellings of a move; Game::Xiangqi::Error, the refusal names.

AUTHOR

LNATION, <email at lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION. This is free software, licensed under the Artistic License 2.0.