Gitea/workflows #1
@@ -17,6 +17,7 @@ public class ChessController(
|
|||||||
ILearnedWeightsStore weightsStore,
|
ILearnedWeightsStore weightsStore,
|
||||||
IGameStore gameStore,
|
IGameStore gameStore,
|
||||||
ISelfPlayCoordinator selfPlay,
|
ISelfPlayCoordinator selfPlay,
|
||||||
|
AutoTrainingSettings autoTraining,
|
||||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
@@ -106,6 +107,25 @@ public class ChessController(
|
|||||||
_ => ChessEngineKind.Custom
|
_ => ChessEngineKind.Custom
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||||
|
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("autotrain")]
|
||||||
|
public ActionResult GetAutoTrain() =>
|
||||||
|
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||||
|
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("autotrain")]
|
||||||
|
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||||
|
{
|
||||||
|
autoTraining.GameCount = count; // clamped inside the setter
|
||||||
|
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joins the "pool" of chess players.
|
/// Joins the "pool" of chess players.
|
||||||
/// Test code expects to receive a GUID for the player
|
/// Test code expects to receive a GUID for the player
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>Auto-train games</legend>
|
||||||
|
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||||
|
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||||
<a id="viewWeights" href="/weights">View learned weights →</a>
|
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
|||||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||||
builder.Services.AddSingleton<IGameStore, GameStore>();
|
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||||
|
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,53 +3,71 @@ using JoshHeaps.Net.Services.Interfaces;
|
|||||||
namespace JoshHeaps.Net.Services.Implementations;
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Continuously trains the learned engine in the background: two self-play games run in
|
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||||
/// parallel, the learned engine (skill 6) against Stockfish (skill 20), one with Stockfish as
|
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||||
/// black and one as white. Each slot is independent — when its game finishes it immediately
|
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||||
/// starts another under the same conditions, without waiting on the other slot. Registered
|
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||||
/// only outside Development and gated by the ChessEngine:AutoTrain config flag.
|
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||||
|
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||||
|
/// ChessEngine:AutoTrain config flag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AutoTrainingService(
|
public sealed class AutoTrainingService(
|
||||||
ISelfPlayCoordinator coordinator,
|
ISelfPlayCoordinator coordinator,
|
||||||
|
AutoTrainingSettings settings,
|
||||||
ILogger<AutoTrainingService> logger) : BackgroundService
|
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private const int LearnedSkill = 6;
|
private const int LearnedSkill = 6;
|
||||||
private const int StockfishSkill = 20;
|
private const int StockfishSkill = 20;
|
||||||
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
// Learned plays both colors so the model trains symmetrically; each slot is its own loop.
|
var running = new List<Task>();
|
||||||
var stockfishBlack = RunSlot(
|
int started = 0;
|
||||||
new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill),
|
|
||||||
stoppingToken);
|
|
||||||
var stockfishWhite = RunSlot(
|
|
||||||
new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill),
|
|
||||||
stoppingToken);
|
|
||||||
|
|
||||||
return Task.WhenAll(stockfishBlack, stockfishWhite);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunSlot(SelfPlayConfig config, CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
running.RemoveAll(t => t.IsCompleted);
|
||||||
|
|
||||||
|
int desired = settings.GameCount;
|
||||||
|
bool startFailed = false;
|
||||||
|
|
||||||
|
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||||
|
var config = started++ % 2 == 0
|
||||||
|
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||||
|
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||||
|
running.Add(completion);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||||
|
// persistent failure doesn't spin a tight loop, then try again.
|
||||||
|
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||||
|
startFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await coordinator.StartGame(config, stoppingToken).Completion;
|
if (startFailed)
|
||||||
}
|
await Task.Delay(_restartBackoff, stoppingToken);
|
||||||
catch (OperationCanceledException)
|
else if (running.Count > 0)
|
||||||
{
|
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||||
break;
|
// count increase promptly).
|
||||||
}
|
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||||
catch (Exception ex)
|
else
|
||||||
{
|
// Pool is empty (count is 0) — just poll for the count to change.
|
||||||
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
await Task.Delay(_pollInterval, stoppingToken);
|
||||||
// persistent failure doesn't spin a tight loop, then try again.
|
|
||||||
logger.LogError(ex, "Auto-training game failed to start; retrying after backoff.");
|
|
||||||
try { await Task.Delay(_restartBackoff, stoppingToken); }
|
|
||||||
catch (OperationCanceledException) { break; }
|
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||||
|
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||||
|
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingSettings
|
||||||
|
{
|
||||||
|
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||||
|
public const int MaxGames = 16;
|
||||||
|
|
||||||
|
private int _gameCount;
|
||||||
|
|
||||||
|
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||||
|
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||||
|
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||||
|
/// </summary>
|
||||||
|
public int GameCount
|
||||||
|
{
|
||||||
|
get => Volatile.Read(ref _gameCount);
|
||||||
|
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ public sealed class ChessEngineOptions
|
|||||||
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoTrain { get; set; } = true;
|
public bool AutoTrain { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||||
|
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||||
|
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public int AutoTrainGameCount { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public sealed class SelfPlayCoordinator(
|
|||||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
// Abort a game if a single engine move takes longer than this — a stopgap for engines
|
||||||
|
// (usually Stockfish) that occasionally freeze and would otherwise hang the game.
|
||||||
|
private static readonly TimeSpan _moveTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
// Plies of random legal moves at the start of a training game, so self-play and
|
// Plies of random legal moves at the start of a training game, so self-play and
|
||||||
// engine-vs-engine games explore different lines instead of replaying one game.
|
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||||
private const int _openingRandomPlies = 4;
|
private const int _openingRandomPlies = 4;
|
||||||
@@ -61,11 +65,13 @@ public sealed class SelfPlayCoordinator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drives the game to completion, then trains from it. Never throws — a failure just ends
|
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||||
/// the game (and the trainer is always freed), so callers can safely await or ignore it.
|
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
bool aborted = false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Give spectators a moment to join the SignalR group before the first move.
|
// Give spectators a moment to join the SignalR group before the first move.
|
||||||
@@ -81,21 +87,48 @@ public sealed class SelfPlayCoordinator(
|
|||||||
|
|
||||||
while (IsLive(gameState, cancellationToken))
|
while (IsLive(gameState, cancellationToken))
|
||||||
{
|
{
|
||||||
await orchestrator.PlayAsync(gameState);
|
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { /* service shutting down */ }
|
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} aborted: a move took over " +
|
||||||
|
$"{_moveTimeout.TotalSeconds:n0}s (likely a frozen engine).");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
aborted = true;
|
||||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyLearning(gameState);
|
ApplyLearning(gameState);
|
||||||
|
|
||||||
// Leave the finished game in place briefly so spectators can see the result.
|
// A clean finish lingers briefly so spectators see the result; an aborted/hung game is
|
||||||
|
// torn down immediately so its engines (and any frozen Stockfish process) are released.
|
||||||
if (gameStore.Contains(gameState.GameId))
|
if (gameStore.Contains(gameState.GameId))
|
||||||
gameStore.ScheduleRemove(gameState.GameId, _selfPlayResultTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays one engine move, abandoning it if it exceeds <see cref="_moveTimeout"/> (throwing
|
||||||
|
/// <see cref="TimeoutException"/>). The abandoned move's eventual fault — it errors once the
|
||||||
|
/// game's engines are disposed — is observed so it isn't an unobserved task exception.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var play = orchestrator.PlayAsync(gameState);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await play.WaitAsync(_moveTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ body.fullscreen-open {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Always occupies its space (it's only hidden, not removed) so toggling "check" never
|
||||||
|
re-centers or wraps the header line. */
|
||||||
|
.checkTag {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
color: #e8a04a;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkTag.show {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.miniBoard {
|
.miniBoard {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
|
|||||||
@@ -23,9 +23,40 @@ const Spectate = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.refreshGames();
|
await this.refreshGames();
|
||||||
|
await this.loadAutoTrainCount();
|
||||||
setInterval(() => this.refreshGames(), 5000);
|
setInterval(() => this.refreshGames(), 5000);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Auto-training runs server-side; show its target count and let it be changed here.
|
||||||
|
async loadAutoTrainCount() {
|
||||||
|
const input = document.getElementById("autoTrainCount");
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chess/autotrain");
|
||||||
|
const data = await response.json();
|
||||||
|
input.max = data.max;
|
||||||
|
// Don't clobber the value while the user is editing it.
|
||||||
|
if (document.activeElement !== input)
|
||||||
|
input.value = data.count;
|
||||||
|
} catch {
|
||||||
|
// Leave the control as-is if auto-training status can't be read.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setAutoTrainCount() {
|
||||||
|
const input = document.getElementById("autoTrainCount");
|
||||||
|
const count = Math.max(0, parseInt(input.value, 10) || 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
|
||||||
|
const data = await response.json();
|
||||||
|
input.value = data.count;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Could not set the auto-training game count.", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async startCpuGame() {
|
async startCpuGame() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
whiteEngine: document.getElementById("whiteEngine").value,
|
whiteEngine: document.getElementById("whiteEngine").value,
|
||||||
@@ -92,7 +123,7 @@ const Spectate = {
|
|||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "gameCardHeader";
|
header.className = "gameCardHeader";
|
||||||
header.id = `header-${game.gameId}`;
|
header.id = `header-${game.gameId}`;
|
||||||
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
this.renderHeader(header, this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
|
|
||||||
const board = document.createElement("div");
|
const board = document.createElement("div");
|
||||||
@@ -186,7 +217,23 @@ const Spectate = {
|
|||||||
const header = document.getElementById(`header-${gameId}`);
|
const header = document.getElementById(`header-${gameId}`);
|
||||||
|
|
||||||
if (stored && header)
|
if (stored && header)
|
||||||
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
this.renderHeader(header, stored, currentPlayer, moveCount, isCheck);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Renders the header as a base text span plus a "check" tag that always occupies its slot
|
||||||
|
// (hidden when not in check), so toggling check never re-centers or wraps the line.
|
||||||
|
renderHeader(header, stored, currentPlayer, moveCount, isCheck) {
|
||||||
|
const showCheck = !stored.result && isCheck;
|
||||||
|
header.innerHTML = "";
|
||||||
|
|
||||||
|
const main = document.createElement("span");
|
||||||
|
main.textContent = this.headerText(stored, currentPlayer, moveCount);
|
||||||
|
|
||||||
|
const tag = document.createElement("span");
|
||||||
|
tag.className = showCheck ? "checkTag show" : "checkTag";
|
||||||
|
tag.textContent = "• check";
|
||||||
|
|
||||||
|
header.append(main, tag);
|
||||||
},
|
},
|
||||||
|
|
||||||
gameLabel(stored) {
|
gameLabel(stored) {
|
||||||
@@ -205,12 +252,11 @@ const Spectate = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
headerText(stored, currentPlayer, moveCount, isCheck) {
|
headerText(stored, currentPlayer, moveCount) {
|
||||||
if (stored.result)
|
if (stored.result)
|
||||||
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
||||||
|
|
||||||
const check = isCheck ? " • check" : "";
|
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move`;
|
||||||
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resultTextFromState(state) {
|
resultTextFromState(state) {
|
||||||
|
|||||||
Reference in New Issue
Block a user