Basic move searcher, priority only based on depth (resulting in BFS), position base value takes into account isolated pawns, doubled pawns and bishop pairs

master
Dany Thach 2025-02-13 22:15:59 +01:00
parent 576a42b670
commit 0b88775b0c
10 changed files with 388 additions and 138 deletions

2
.gitignore vendored
View File

@ -3,4 +3,6 @@
.dart_tool/
# project-specific
bin/test_*
builds/
logs/

View File

@ -1,7 +1,8 @@
import 'dart:collection';
import 'package:omnichess/classes/position.dart';
import 'package:omnichess/constants/numbers.dart';
import 'package:omnichess/constants/piece.dart';
import 'package:omnichess/data_structures/position_tree_node.dart';
import 'package:omnichess/functions/legal_moves.dart';
class BestMoveSearcher {
@ -9,14 +10,29 @@ class BestMoveSearcher {
bool _isRunning = false;
bool _isStopped = false;
Future<int> getPositionBaseValue(Position position) async {
if (await LegalMoves.isCheckmate(position)) {
static int getPositionBaseValue(Position position) {
if (LegalMoves.isCheckmate(position)) {
return position.isWhiteTurn
? -(1 << 63) // min int (white is checkmated)
: ((1 << 63) - 1); // max int (black is checkmated)
? Numbers.maxInteger
: Numbers.minInteger;
}
int value = 0;
List<int> whitePawnFiles = List.filled(8, 0, growable: false); // isolated pawns, doubled pawns
List<int> blackPawnFiles = List.filled(8, 0, growable: false);
List<bool> whiteBishopColors = List.filled(2, false, growable: false); // bishop pairs
List<bool> blackBishopColors = List.filled(2, false, growable: false);
int row = 0;
int column = 0;
for (int i = 0; i < 64; i++) {
final int verticalDisplacementFromCenter = 7 - 2*row;
final int horizontalDisplacementFromCenter = 7 - 2*column;
final int attackingValue = (98-(verticalDisplacementFromCenter*verticalDisplacementFromCenter + horizontalDisplacementFromCenter*horizontalDisplacementFromCenter)) >> 4;
if (LegalMoves.isWhiteAttacking(position, i)) {
value += attackingValue;
}
if (LegalMoves.isBlackAttacking(position, i)) {
value -= attackingValue;
}
value += switch (position.board[i]) {
Piece.whiteQueen => 900,
Piece.whiteRook => 500,
@ -30,40 +46,86 @@ class BestMoveSearcher {
Piece.blackPawn => -100,
_ => 0,
};
if (position.board[i] == Piece.whitePawn) {
whitePawnFiles[i % 8]++;
if (whitePawnFiles[i % 8] > 1) {
value -= 25;
}
}
if (position.board[i] == Piece.blackPawn) {
blackPawnFiles[i % 8]++;
if (blackPawnFiles[i % 8] > 1) {
value += 25;
}
}
if (position.board[i] == Piece.whiteBishop) {
whiteBishopColors[i%2] = true;
}
if (position.board[i] == Piece.blackBishop) {
blackBishopColors[i%2] = true;
}
column = (column + 1) % 8;
if (column == 0) {
row++;
}
}
if (whitePawnFiles[0] > 0 && whitePawnFiles[1] == 0) {
value -= 50;
}
if (whitePawnFiles[7] > 0 && whitePawnFiles[6] == 0) {
value -= 50;
}
if (blackPawnFiles[0] > 0 && blackPawnFiles[1] == 0) {
value += 50;
}
if (blackPawnFiles[7] > 0 && blackPawnFiles[6] == 0) {
value += 50;
}
for (int i = 1; i < 7; i++) {
if (whitePawnFiles[i-1] == 0 && whitePawnFiles[i] > 0 && whitePawnFiles[i+1] == 0) {
value -= 50;
}
if (blackPawnFiles[i-1] == 0 && blackPawnFiles[i] > 0 && blackPawnFiles[i+1] == 0) {
value += 50;
}
}
if (whiteBishopColors[0] && whiteBishopColors[1]){
value += 50;
}
if (blackBishopColors[0] && blackBishopColors[1]){
value -= 50;
}
return value;
}
Future<String?> search(Position position) async {
Future<String?> search(Position position, int? moveTime) async {
_isRunning = true;
(int, String, Position)? bestMove;
Queue<(int, String, Position)> evaluatedMoves = Queue();
await for (final (String, Position) move in LegalMoves.getLegalMoves(position)) {
final (int, String, Position) evaluatedMove = (await getPositionBaseValue(move.$2), move.$1, move.$2);
if (
null == bestMove ||
(bestMove.$1 < evaluatedMove.$1 && position.isWhiteTurn) ||
(bestMove.$1 > evaluatedMove.$1 && !position.isWhiteTurn)
) {
bestMove = evaluatedMove;
PositionTreeNode positionTree = PositionTreeNode.fromPosition(position);
String bestLine = "";
int? bestValue;
int depth = 0;
final int startTime = DateTime.now().millisecondsSinceEpoch;
int time = 0;
while (!_isStopped && positionTree.priority < Numbers.maxInteger) {
positionTree.computeStep();
time = DateTime.now().millisecondsSinceEpoch - startTime;
if (null != moveTime && time >= moveTime) {
_isStopped = true;
}
if (positionTree.childrenByValue.isNotEmpty || bestValue != positionTree.value) {
final String thisBestLine = positionTree.childrenByValue.firstOrNull?.bestLine ?? "";
if (bestLine != thisBestLine || bestValue != positionTree.value || depth != positionTree.depth) {
bestLine = thisBestLine;
bestValue = positionTree.value;
depth = positionTree.depth;
print("info depth $depth multipv 1 nodes ${positionTree.nodeCount} time $time score cp ${(position.isWhiteTurn ? 1 : -1) * (positionTree.value)} pv $bestLine");
}
}
evaluatedMoves.add(evaluatedMove);
await Future.delayed(Duration.zero);
if (_isStopped) {
break;
}
// TODO evaluate checkmate
}
if (evaluatedMoves.isEmpty) {
_isRunning = false;
_isStopped = false;
return null;
}
// TODO search
// IDEA implement a BFS with pruning on the ones that give suboptimal values
_isRunning = false;
_isStopped = false;
return bestMove?.$2;
return positionTree.childrenByValue.firstOrNull?.move;
}
Future<void> stop() async {

View File

@ -0,0 +1,11 @@
import 'package:meta/meta.dart';
@immutable
class Numbers {
Numbers._();
static const int maxInteger = 0x7FFFFFFFFFFFFFFF;
static const int minInteger = -0x7FFFFFFFFFFFFFFF;
}

View File

@ -1,83 +0,0 @@
class Heap<T> {
final int Function(T) getPriority;
List<(T, int)> _items;
Heap({
required this.getPriority
}): _items = [];
factory Heap.fromItems(
List<T> items,
{
required int Function(T) getPriority,
}
) {
Heap<T> heap = Heap(getPriority: getPriority);
for (final T item in items) {
heap.add(item);
}
return heap;
}
T get first => _items.first.$1;
T? get firstOrNull => _items.firstOrNull?.$1;
int get length => _items.length;
bool get isEmpty => _items.isEmpty;
bool get isNotEmpty => _items.isNotEmpty;
void add(T item) {
final (T, int) heapItem = (item, getPriority(item));
final int thisKey = heapItem.$2;
int i = _items.length;
_items.add(heapItem);
while (i > 0 && _items[(i-1) ~/ 2].$2 > thisKey) {
_items[i] = _items[(i-1) ~/ 2];
i = (i-1)~/2;
}
_items[i] = heapItem;
}
T removeFirst() {
if (_items.isEmpty) {
throw StateError("No element");
}
final T firstItem = _items[0].$1;
if (_items.length < 2) {
_items.length--;
return firstItem;
}
final (T, int) itemToMove = _items.last;
_items[0] = itemToMove;
_items.length--;
int i = 0;
int leftIndex, rightIndex;
bool stop = false;
while (!stop) {
rightIndex = (i+1) << 1;
leftIndex = rightIndex-1;
if (leftIndex < _items.length && itemToMove.$2 > _items[leftIndex].$2) {
if (rightIndex < _items.length && _items[leftIndex].$2 > _items[rightIndex].$2) {
_items[i] = _items[rightIndex];
_items[rightIndex] = itemToMove;
i = rightIndex;
} else {
_items[i] = _items[leftIndex];
_items[leftIndex] = itemToMove;
i = leftIndex;
}
} else {
stop = true;
}
}
return firstItem;
}
@override
String toString() => _items.map(((T, int) item) => item.$1).toString();
}

View File

@ -0,0 +1,96 @@
import 'package:omnichess/classes/best_move_searcher.dart';
import 'package:omnichess/classes/position.dart';
import 'package:omnichess/constants/numbers.dart';
import 'package:omnichess/data_structures/search_heap.dart';
import 'package:omnichess/functions/legal_moves.dart';
class PositionTreeNode {
int priority;
int value;
int nodeCount;
int depth;
final String move;
final Position position;
final Iterable<(String, Position)> legalMoves;
SearchHeap<String, PositionTreeNode> children;
SearchHeap<String, PositionTreeNode> childrenByValue;
String get bestLine {
final String? childBestLine = childrenByValue.firstOrNull?.bestLine;
return "$move${null == childBestLine ? "" : " $childBestLine"}";
}
PositionTreeNode({
required this.priority,
required this.value,
required this.move,
required this.position,
required this.legalMoves,
required this.children,
required this.childrenByValue,
}):
nodeCount = 0,
depth = 0;
factory PositionTreeNode.fromPosition(Position position, [ String move = "" ]) {
final int value = BestMoveSearcher.getPositionBaseValue(position);
final Iterable<(String, Position)> legalMoves = LegalMoves.getLegalMoves(position);
return PositionTreeNode(
priority: getPositionBasePriority(position, value, legalMoves, 0),
value: value,
move: move,
position: position,
legalMoves: legalMoves,
children: SearchHeap<String, PositionTreeNode>(
getKey: (PositionTreeNode node) => node.move,
getPriority: (PositionTreeNode node) => node.priority,
),
childrenByValue: SearchHeap<String, PositionTreeNode>(
getKey: (PositionTreeNode node) => node.move,
getPriority: position.isWhiteTurn
? (PositionTreeNode node) => -node.value
: (PositionTreeNode node) => node.value,
),
);
}
void computeStep() {
if (legalMoves.isEmpty) {
return;
}
if (children.isEmpty) {
_computeChildren();
return;
}
final PositionTreeNode firstChild = children.first;
final int initialFirstChildNodeCount = firstChild.nodeCount;
firstChild.computeStep();
children.updateElement(firstChild.move);
childrenByValue.updateElement(firstChild.move);
nodeCount += firstChild.nodeCount - initialFirstChildNodeCount;
if (firstChild.depth + 1 > depth) {
depth = firstChild.depth + 1;
}
priority = (children.first.priority == Numbers.maxInteger) ? children.first.priority : children.first.priority + 1;
value = childrenByValue.first.value;
}
void _computeChildren() {
for (final (String, Position) move in legalMoves) {
final PositionTreeNode positionTreeNode = PositionTreeNode.fromPosition(move.$2, move.$1);
children.add(positionTreeNode);
childrenByValue.add(positionTreeNode);
}
nodeCount = legalMoves.length;
priority = (children.first.priority == Numbers.maxInteger) ? children.first.priority : children.first.priority + 1;
depth = 1;
value = childrenByValue.first.value;
}
static int getPositionBasePriority(Position position, int value, Iterable<(String, Position)> legalMoves, int depth) =>
legalMoves.isEmpty
? Numbers.maxInteger
: 0;
}

View File

@ -0,0 +1,135 @@
class SearchHeap<K, T> {
final K Function(T) getKey;
final int Function(T) getPriority;
List<(T, K, int)> _items;
Map<K, int> _positionByKey;
SearchHeap({
required this.getKey,
required this.getPriority,
}):
_items = [],
_positionByKey = {};
factory SearchHeap.fromItems(
List<T> items,
{
required K Function(T) getKey,
required int Function(T) getPriority,
}
) {
SearchHeap<K, T> heap = SearchHeap(getKey: getKey, getPriority: getPriority);
for (final T item in items) {
heap.add(item);
}
return heap;
}
T get first => _items.first.$1;
T? get firstOrNull => _items.firstOrNull?.$1;
int get length => _items.length;
bool get isEmpty => _items.isEmpty;
bool get isNotEmpty => _items.isNotEmpty;
void add(T item) {
final (T, K, int) heapItem = (item, getKey(item), getPriority(item));
final int thisKey = heapItem.$3;
int i = _items.length;
_items.add(heapItem);
while (i > 0 && _items[(i-1) >> 1].$3 > thisKey) {
_items[i] = _items[(i-1) >> 1];
_positionByKey[_items[i].$2] = i;
i = (i-1) >> 1;
}
_items[i] = heapItem;
_positionByKey[heapItem.$2] = i;
}
T removeFirst() {
if (_items.isEmpty) {
throw StateError("No element");
}
final T firstItem = _items[0].$1;
_positionByKey.remove(_items[0].$2);
if (_items.length < 2) {
_items.length--;
return firstItem;
}
final (T, K, int) itemToMove = _items.last;
_items.length--;
int i = 0;
int leftIndex, rightIndex;
bool stop = false;
while (!stop) {
rightIndex = (i+1) << 1;
leftIndex = rightIndex-1;
if (leftIndex < _items.length && itemToMove.$3 > _items[leftIndex].$3) {
if (rightIndex < _items.length && _items[leftIndex].$3 > _items[rightIndex].$3) {
_items[i] = _items[rightIndex];
_positionByKey[_items[i].$2] = i;
i = rightIndex;
} else {
_items[i] = _items[leftIndex];
_positionByKey[_items[i].$2] = i;
i = leftIndex;
}
} else if (rightIndex < _items.length && _items[leftIndex].$3 > _items[rightIndex].$3) {
_items[i] = _items[rightIndex];
_positionByKey[_items[i].$2] = i;
i = rightIndex;
} else {
stop = true;
}
}
_items[i] = itemToMove;
_positionByKey[itemToMove.$2] = i;
return firstItem;
}
void updateElement(K key) {
int i = _positionByKey[key]!;
final (T, K, int) item = (_items[i].$1, _items[i].$2, getPriority(_items[i].$1));
bool dontBubbleDown = false;
while (i > 0 && _items[(i-1) >> 1].$3 > item.$3) {
dontBubbleDown = true;
_items[i] = _items[(i-1) >> 1];
_positionByKey[_items[i].$2] = i;
i = (i-1) >> 1;
}
int leftIndex, rightIndex;
while (!dontBubbleDown) {
rightIndex = (i+1) << 1;
leftIndex = rightIndex-1;
if (leftIndex < _items.length && item.$3 > _items[leftIndex].$3) {
if (rightIndex < _items.length && _items[leftIndex].$3 > _items[rightIndex].$3) {
_items[i] = _items[rightIndex];
_positionByKey[_items[i].$2] = i;
i = rightIndex;
} else {
_items[i] = _items[leftIndex];
_positionByKey[_items[i].$2] = i;
i = leftIndex;
}
} else if (rightIndex < _items.length && _items[leftIndex].$3 > _items[rightIndex].$3) {
_items[i] = _items[rightIndex];
_positionByKey[_items[i].$2] = i;
i = rightIndex;
} else {
dontBubbleDown = true;
}
}
_items[i] = item;
_positionByKey[item.$2] = i;
}
@override
String toString() => _items.map(((T, K, int) item) => item.$1).toString();
List<T> get debugItems => _items.map(((T, K, int) item) => item.$1).toList();
}

View File

@ -4,9 +4,9 @@ class _LegalMovesBlack {
_LegalMovesBlack._();
static Stream<(String, Position)> _getLegalMoves(Position position) => _getValidOrCheckedMoves(position).where(((String, Position) move) => !_isBlackChecked(move.$2));
static Iterable<(String, Position)> _getLegalMoves(Position position) => _getValidOrCheckedMoves(position).where(((String, Position) move) => !_isBlackChecked(move.$2));
static Stream<(String, Position)> _getValidOrCheckedMoves(Position position) async* {
static Iterable<(String, Position)> _getValidOrCheckedMoves(Position position) sync* {
for (int i = 0; i < 64; i++) {
switch (position.board[i]) {
case Piece.emptySquare:
@ -40,7 +40,7 @@ class _LegalMovesBlack {
}
}
static Stream<(String, Position)> _getBlackPawnMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getBlackPawnMoves(Position position, int i) sync* {
int j = i+8;
if (Piece.emptySquare == position.board[j]) { // pawn forward
if (i > 47) {
@ -87,7 +87,7 @@ class _LegalMovesBlack {
}
}
static Stream<(String, Position)> _getBlackKnightMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getBlackKnightMoves(Position position, int i) sync* {
final bool canGoUp2 = i > 15;
final bool canGoUp1 = i > 7;
final bool canGoDown1 = i < 56;
@ -131,7 +131,7 @@ class _LegalMovesBlack {
}
}
static Stream<(String, Position)> _getBlackBishopMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getBlackBishopMoves(Position position, int i) sync* {
final int column = i % 8;
// up left
bool hasEncounteredPiece = false;
@ -198,7 +198,7 @@ class _LegalMovesBlack {
j+=9;
}
}
static Stream<(String, Position)> _getBlackRookMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getBlackRookMoves(Position position, int i) sync* {
final int column = i % 8;
// up
bool hasEncounteredPiece = false;
@ -262,7 +262,7 @@ class _LegalMovesBlack {
}
}
static Stream<(String, Position)> _getBlackKingMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getBlackKingMoves(Position position, int i) sync* {
int j = i-8;
if (j >= 0 && !Piece.isBlack(position.board[j])) {
yield ("${Position.squareIndexToString(i)}${Position.squareIndexToString(j)}", Position.from(position)..playMoveIndices(i, j));
@ -352,7 +352,7 @@ class _LegalMovesBlack {
}
static bool _isWhitePawnAttacking(Position position, int square) {
if (square < 8) {
if (square > 47) {
return false;
}
final int column = square % 8;
@ -480,7 +480,7 @@ class _LegalMovesBlack {
// up
bool hasEncounteredPiece = false;
int j = square-8;
while (!hasEncounteredPiece && j > 0) {
while (!hasEncounteredPiece && j >= 0) {
final int piece = position.board[j];
if (Piece.emptySquare != piece) {
hasEncounteredPiece = true;
@ -507,7 +507,7 @@ class _LegalMovesBlack {
hasEncounteredPiece = false;
j = square - 1;
int k = column - 1;
while (!hasEncounteredPiece && k > 0) {
while (!hasEncounteredPiece && k >= 0) {
final int piece = position.board[j];
if (Piece.emptySquare != piece) {
hasEncounteredPiece = true;

View File

@ -4,9 +4,9 @@ class _LegalMovesWhite {
_LegalMovesWhite._();
static Stream<(String, Position)> _getLegalMoves(Position position) => _getValidOrCheckedMoves(position).where(((String, Position) move) => !_isWhiteChecked(move.$2));
static Iterable<(String, Position)> _getLegalMoves(Position position) => _getValidOrCheckedMoves(position).where(((String, Position) move) => !_isWhiteChecked(move.$2));
static Stream<(String, Position)> _getValidOrCheckedMoves(Position position) async* {
static Iterable<(String, Position)> _getValidOrCheckedMoves(Position position) sync* {
for (int i = 0; i < 64; i++) {
switch (position.board[i]) {
case Piece.emptySquare:
@ -40,7 +40,7 @@ class _LegalMovesWhite {
}
}
static Stream<(String, Position)> _getWhitePawnMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getWhitePawnMoves(Position position, int i) sync* {
int j = i-8;
if (Piece.emptySquare == position.board[j]) { // pawn forward
if (i < 16) {
@ -87,7 +87,7 @@ class _LegalMovesWhite {
}
}
static Stream<(String, Position)> _getWhiteKnightMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getWhiteKnightMoves(Position position, int i) sync* {
final bool canGoUp2 = i > 15;
final bool canGoUp1 = i > 7;
final bool canGoDown1 = i < 56;
@ -131,7 +131,7 @@ class _LegalMovesWhite {
}
}
static Stream<(String, Position)> _getWhiteBishopMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getWhiteBishopMoves(Position position, int i) sync* {
final int column = i % 8;
// up left
bool hasEncounteredPiece = false;
@ -198,7 +198,7 @@ class _LegalMovesWhite {
j+=9;
}
}
static Stream<(String, Position)> _getWhiteRookMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getWhiteRookMoves(Position position, int i) sync* {
final int column = i % 8;
// up
bool hasEncounteredPiece = false;
@ -262,7 +262,7 @@ class _LegalMovesWhite {
}
}
static Stream<(String, Position)> _getWhiteKingMoves(Position position, int i) async* {
static Iterable<(String, Position)> _getWhiteKingMoves(Position position, int i) sync* {
int j = i-8;
if (j >= 0 && !Piece.isWhite(position.board[j])) {
yield ("${Position.squareIndexToString(i)}${Position.squareIndexToString(j)}", Position.from(position)..playMoveIndices(i, j));
@ -352,7 +352,7 @@ class _LegalMovesWhite {
}
static bool _isBlackPawnAttacking(Position position, int square) {
if (square < 8) {
if (square < 16) {
return false;
}
final int column = square % 8;
@ -480,7 +480,7 @@ class _LegalMovesWhite {
// up
bool hasEncounteredPiece = false;
int j = square-8;
while (!hasEncounteredPiece && j > 0) {
while (!hasEncounteredPiece && j >= 0) {
final int piece = position.board[j];
if (Piece.emptySquare != piece) {
hasEncounteredPiece = true;
@ -507,7 +507,7 @@ class _LegalMovesWhite {
hasEncounteredPiece = false;
j = square - 1;
int k = column - 1;
while (!hasEncounteredPiece && k > 0) {
while (!hasEncounteredPiece && k >= 0) {
final int piece = position.board[j];
if (Piece.emptySquare != piece) {
hasEncounteredPiece = true;

View File

@ -8,12 +8,17 @@ part "_partials/legal_moves_white.dart";
class LegalMoves {
LegalMoves._();
static Stream<(String, Position)> getLegalMoves(Position position) => position.isWhiteTurn
static Iterable<(String, Position)> getLegalMoves(Position position) => position.isWhiteTurn
? _LegalMovesWhite._getLegalMoves(position)
: _LegalMovesBlack._getLegalMoves(position);
static Future<bool> isCheckmate(Position position) async => position.isWhiteTurn
? (_LegalMovesWhite._isWhiteChecked(position) && await getLegalMoves(position).isEmpty)
: (_LegalMovesBlack._isBlackChecked(position) && await getLegalMoves(position).isEmpty);
static bool isCheckmate(Position position) => position.isWhiteTurn
? (_LegalMovesWhite._isWhiteChecked(position) && getLegalMoves(position).isEmpty)
: (_LegalMovesBlack._isBlackChecked(position) && getLegalMoves(position).isEmpty);
static bool isWhiteAttacking(Position position, int square) => _LegalMovesBlack._isWhiteAttacking(position, square);
static bool isBlackAttacking(Position position, int square) => _LegalMovesWhite._isBlackAttacking(position, square);
static bool isWhiteChecked(Position position) => _LegalMovesWhite._isWhiteChecked(position);
static bool isBlackChecked(Position position) => _LegalMovesBlack._isBlackChecked(position);
}

View File

@ -22,8 +22,15 @@ class Omnichess {
}
Future<void> _goCommand(Queue<String> inputComponents) async {
int? moveTime;
while (inputComponents.isNotEmpty) {
final String inputComponent = inputComponents.removeFirst();
if ("movetime" == inputComponent) {
moveTime = int.tryParse(inputComponents.firstOrNull ?? "");
}
}
final Position position = Position.fromPositionCommand(positionCommand);
final String? bestMove = await bestMoveSearcher.search(position);
final String? bestMove = await bestMoveSearcher.search(position, moveTime);
print("bestmove ${bestMove ?? "(none)"}");
}
@ -103,11 +110,26 @@ class Omnichess {
}
void loop() async {
final DateTime now = DateTime.now();
final String logName = "${now.year}"
"${now.month.toString().padLeft(2, "0")}"
"${now.day.toString().padLeft(2, "0")}"
"-"
"${now.hour.toString().padLeft(2, "0")}"
"${now.minute.toString().padLeft(2, "0")}"
"${now.second.toString().padLeft(2, "0")}"
"${now.millisecond.toString().padLeft(3, "0")}"
".log";
final File log = File("logs/$logName");
if (!log.existsSync()) {
log.createSync();
}
late StreamSubscription<String> inputSubscription;
inputSubscription = stdin
.transform(utf8.decoder)
.transform(LineSplitter())
.listen((String line) async {
log.writeAsStringSync("$line\n", mode: FileMode.append);
final String input = line.trim();
final bool keepGoing = await elaborate(input);
if (!keepGoing) {