Skip to content

Commit f75c12e

Browse files
authored
Merge branch 'FirePlank:main' into main
2 parents 48fdd04 + 1e6d216 commit f75c12e

11 files changed

Lines changed: 730 additions & 308 deletions

File tree

sprt/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ The UI provides download buttons:
140140
| Button | Content |
141141
|--------|---------|
142142
| **Copy Log** | Copy game log to clipboard |
143-
| **Download Logs** | Save game log as `.txt` |
144-
| **Download Games (ICN)** | Save games in ICN format |
143+
| **Download Logs** | Save game logs in ICN format |
144+
| **Download Games (TXT)** | Save games as a `.txt` |
145+
| **Download Games (JSON)** | Save games as a `.json` |
145146

146147
ICN format includes headers like:
147148
```

sprt/web/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -479,10 +479,10 @@ <h2 class="card-title">
479479
<div class="sprt-status" id="sprtStatus">Status: -</div>
480480

481481
<div class="btn-group" style="margin-top: 0.75rem">
482-
<button class="btn btn-secondary" id="downloadGames" style="display: none">
483-
Download Games (ICN)
482+
<button class="btn btn-secondary" id="downloadGames-txt" disabled>
483+
Download Games (TXT)
484484
</button>
485-
<button class="btn btn-secondary" id="downloadGamesJson" style="display: none">
485+
<button class="btn btn-secondary" id="downloadGames-json" disabled>
486486
Download Games (JSON)
487487
</button>
488488
</div>

sprt/web/main.js

Lines changed: 40 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ import initOld, { Engine as EngineOld } from './pkg-old/hydrochess_wasm.js';
22
import initNew, { Engine as EngineNew } from './pkg-new/hydrochess_wasm.js';
33
import { VARIANTS, getVariantData } from './variants.js';
44

5+
// Map internal engine piece letters to infinitechess.org ICN codes (lowercase for ICN)
6+
function engineLetterToICNCode(letter) {
7+
const map = {
8+
'k': 'k', 'q': 'q', 'r': 'r', 'b': 'b', 'n': 'n', 'p': 'p',
9+
'm': 'am', 'c': 'ch', 'a': 'ar', 'h': 'ha', 'g': 'gu',
10+
'l': 'ca', 'i': 'gi', 'z': 'ze', 'e': 'ce', 'y': 'rq',
11+
'd': 'rc', 's': 'nr', 'u': 'hu', 'o': 'ro', 'x': 'ob', 'v': 'vo'
12+
};
13+
return map[letter] || letter;
14+
}
15+
516
// UI Elements
617
const statusDot = document.getElementById('statusDot');
718
const statusText = document.getElementById('statusText');
@@ -28,8 +39,8 @@ const sprtOutput = document.getElementById('sprtOutput');
2839
const gameLogEl = document.getElementById('gameLog');
2940
const copyLogBtn = document.getElementById('copyLog');
3041
const downloadLogsBtn = document.getElementById('downloadLogs');
31-
const downloadGamesBtn = document.getElementById('downloadGames');
32-
const downloadGamesJsonBtn = document.getElementById('downloadGamesJson');
42+
const downloadGamesTxtBtn = document.getElementById('downloadGames-txt');
43+
const downloadGamesJsonBtn = document.getElementById('downloadGames-json');
3344
const icnOutputEl = document.getElementById('icnOutput');
3445
const icnTextEl = document.getElementById('icnText');
3546
const sprtStatusEl = document.getElementById('sprtStatus');
@@ -430,16 +441,23 @@ function generateICNFromWorkerLog(workerLog, gameIndex, result, newPlaysWhite, e
430441
}).filter(Boolean);
431442

432443
const movesStr = moves.join('|');
433-
// Determine promotion ranks for the variant (default to standard 8 for white, 1 for black)
444+
// Determine promotion ranks and allowed promotions for the variant
445+
// Default to standard 8 for white, 1 for black, with q,r,b,n allowed
434446
let whiteRank = '8';
435447
let blackRank = '1';
448+
let promotionsAllowed = ['q', 'r', 'b', 'n'];
436449
let worldBoundsStr = '';
437450
try {
438451
const vdata = getVariantData(variantName);
439-
if (vdata && vdata.game_rules && vdata.game_rules.promotion_ranks) {
440-
const ranks = vdata.game_rules.promotion_ranks;
441-
if (ranks.white && ranks.white.length > 0) whiteRank = ranks.white[0];
442-
if (ranks.black && ranks.black.length > 0) blackRank = ranks.black[0];
452+
if (vdata && vdata.game_rules) {
453+
if (vdata.game_rules.promotion_ranks) {
454+
const ranks = vdata.game_rules.promotion_ranks;
455+
if (ranks.white && ranks.white.length > 0) whiteRank = ranks.white[0];
456+
if (ranks.black && ranks.black.length > 0) blackRank = ranks.black[0];
457+
}
458+
if (vdata.game_rules.promotions_allowed && Array.isArray(vdata.game_rules.promotions_allowed)) {
459+
promotionsAllowed = vdata.game_rules.promotions_allowed;
460+
}
443461
}
444462
// Compute world bounds: if worldBorder is a number, calculate finite bounds
445463
// Otherwise, use infinite bounds
@@ -476,7 +494,10 @@ function generateICNFromWorkerLog(workerLog, gameIndex, result, newPlaysWhite, e
476494
// Use infinite bounds on error
477495
worldBoundsStr = '-999999999999999,1000000000000008,-999999999999999,1000000000000008';
478496
}
479-
const promotionRanksToken = `(${whiteRank}|${blackRank})`;
497+
// Build promotion token: (whiteRank;promo1,promo2,...|blackRank;promo1,promo2,...)
498+
// Convert engine piece letters to ICN codes (e.g., 'm' -> 'am')
499+
const promotionsICN = promotionsAllowed.map(p => engineLetterToICNCode(p)).join(',');
500+
const promotionRanksToken = `(${whiteRank};${promotionsICN}|${blackRank};${promotionsICN})`;
480501
return `${headers} ${nextTurn} ${halfmove}/100 ${fullmove} ${promotionRanksToken} ${worldBoundsStr} ${startPositionStr}${movesStr ? ' ' + movesStr : ''}`;
481502
}
482503

@@ -850,6 +871,10 @@ async function runSprt() {
850871
let llr = 0;
851872
gameLogs = [];
852873

874+
// Disable download buttons as logs are cleared
875+
downloadGamesTxtBtn.disabled = true;
876+
downloadGamesJsonBtn.disabled = true;
877+
853878
sprtOutput.innerHTML = '';
854879
perVariantStats = {};
855880
clearLog();
@@ -925,6 +950,9 @@ async function runSprt() {
925950
msg.variantName, // Add variant to ICN log
926951
);
927952
gameLogs.push(icnLog);
953+
// Enable download buttons immediately upon first result
954+
downloadGamesTxtBtn.disabled = false;
955+
downloadGamesJsonBtn.disabled = false;
928956
// Global results
929957
if (result === 'win') wins++;
930958
else if (result === 'loss') losses++;
@@ -1085,12 +1113,6 @@ async function runSprt() {
10851113
sprtRunning = false;
10861114
runSprtBtn.disabled = false;
10871115
stopSprtBtn.disabled = true;
1088-
// Show/enable download games if we have any ICN logs
1089-
const hasGames = gameLogs.length > 0;
1090-
downloadGamesBtn.disabled = !hasGames;
1091-
downloadGamesBtn.style.display = hasGames ? '' : 'none';
1092-
downloadGamesJsonBtn.disabled = !hasGames;
1093-
downloadGamesJsonBtn.style.display = hasGames ? '' : 'none';
10941116
}
10951117

10961118
function stopSprt() {
@@ -1136,12 +1158,10 @@ function stopSprt() {
11361158
sprtLog(' Elo Difference: ' + (lastElo >= 0 ? '+' : '') + lastElo.toFixed(1) + ' ±' + lastEloError.toFixed(1));
11371159
}
11381160
}
1139-
// Allow downloads of games if any finished before abort
1161+
// Allow download of any completed games
11401162
const hasGamesAbort = gameLogs.length > 0;
1141-
downloadGamesBtn.disabled = !hasGamesAbort;
1142-
downloadGamesBtn.style.display = hasGamesAbort ? '' : 'none';
1163+
downloadGamesTxtBtn.disabled = !hasGamesAbort;
11431164
downloadGamesJsonBtn.disabled = !hasGamesAbort;
1144-
downloadGamesJsonBtn.style.display = hasGamesAbort ? '' : 'none';
11451165
}
11461166

11471167
function copyLog() {
@@ -1193,144 +1213,14 @@ function downloadGames() {
11931213
URL.revokeObjectURL(url);
11941214
}
11951215

1196-
/**
1197-
* Parse an ICN string and extract game metadata and moves into a JSON-friendly object.
1198-
* ICN format after headers: turn halfmove/100 fullmove (whitePromo|blackPromo) worldBounds startPosition moves
1199-
*/
1200-
function parseICNToJSON(icnString) {
1201-
if (!icnString || typeof icnString !== 'string') return null;
1202-
1203-
const result = {
1204-
headers: {},
1205-
startPosition: null,
1206-
worldBounds: null,
1207-
moves: [],
1208-
turn: 'w',
1209-
halfmoveClock: 0,
1210-
fullmoveNumber: 1,
1211-
promotionRanks: null
1212-
};
1213-
1214-
// Parse headers: [Key "Value"]
1215-
const headerRegex = /\[([^\]]+)\s+"([^"]*)"\]/g;
1216-
let match;
1217-
let lastHeaderEnd = 0;
1218-
while ((match = headerRegex.exec(icnString)) !== null) {
1219-
result.headers[match[1]] = match[2];
1220-
lastHeaderEnd = headerRegex.lastIndex;
1221-
}
1222-
1223-
const afterHeaders = icnString.slice(lastHeaderEnd).trim();
1224-
if (!afterHeaders) return result;
1225-
1226-
// Split by whitespace robustly
1227-
const tokens = afterHeaders.split(/\s+/);
1228-
1229-
let idx = 0;
1230-
1231-
// Token 0: turn (w or b)
1232-
if (tokens.length > idx && (tokens[idx] === 'w' || tokens[idx] === 'b')) {
1233-
result.turn = tokens[idx];
1234-
idx++;
1235-
}
1236-
1237-
// Token 1: halfmove/100
1238-
if (tokens.length > idx && tokens[idx].includes('/')) {
1239-
const halfmovePart = tokens[idx];
1240-
const slashIdx = halfmovePart.indexOf('/');
1241-
result.halfmoveClock = parseInt(halfmovePart.slice(0, slashIdx), 10) || 0;
1242-
idx++;
1243-
}
1244-
1245-
// Token 2: fullmove number
1246-
if (tokens.length > idx && /^\d+$/.test(tokens[idx])) {
1247-
result.fullmoveNumber = parseInt(tokens[idx], 10) || 1;
1248-
idx++;
1249-
}
1250-
1251-
// Token 3: promotion ranks like (8|1)
1252-
if (tokens.length > idx && tokens[idx].startsWith('(') && tokens[idx].includes('|')) {
1253-
result.promotionRanks = tokens[idx];
1254-
idx++;
1255-
}
1256-
1257-
// Token 4 might be world bounds (format: minX,maxX,minY,maxY - 4 numbers separated by 3 commas)
1258-
if (tokens.length > idx) {
1259-
const token = tokens[idx];
1260-
const commaCount = (token.match(/,/g) || []).length;
1261-
const hasLetterExceptE = /[a-df-zA-DF-Z]/.test(token);
1262-
const hasPipe = token.includes('|');
1263-
const hasGt = token.includes('>');
1264-
1265-
if (commaCount === 3 && !hasLetterExceptE && !hasPipe && !hasGt) {
1266-
result.worldBounds = token;
1267-
idx++;
1268-
}
1269-
}
1270-
1271-
// Remaining tokens: start position and moves
1272-
let positionParts = [];
1273-
1274-
for (let i = idx; i < tokens.length; i++) {
1275-
const token = tokens[i];
1276-
if (!token) continue;
1277-
1278-
if (token.includes('>')) {
1279-
// Moves token (pipe-separated)
1280-
const movesRaw = token.split('|');
1281-
for (const moveStr of movesRaw) {
1282-
if (!moveStr.includes('>')) continue;
1283-
const braceIdx = moveStr.indexOf('{');
1284-
const cleanMove = braceIdx !== -1 ? moveStr.slice(0, braceIdx).trim() : moveStr.trim();
1285-
if (cleanMove.includes('>')) {
1286-
result.moves.push(cleanMove);
1287-
}
1288-
}
1289-
} else if (token.includes('|') || /[a-zA-Z]/.test(token)) {
1290-
// Position piece(s)
1291-
const pieces = token.split('|').filter(p => p.length > 0);
1292-
positionParts.push(...pieces);
1293-
} else if (token.includes(',')) {
1294-
// Possible world bounds if not already found, or coordinate-only piece
1295-
positionParts.push(token);
1296-
}
1297-
}
1298-
1299-
if (positionParts.length > 0) {
1300-
result.startPosition = positionParts.join('|');
1301-
}
1302-
1303-
return result;
1304-
}
1305-
13061216

13071217
function downloadGamesJson() {
13081218
if (!gameLogs.length) {
13091219
log('No games to download yet', 'warn');
13101220
return;
13111221
}
13121222

1313-
const games = gameLogs.map((icn, index) => {
1314-
const parsed = parseICNToJSON(icn);
1315-
return {
1316-
gameIndex: index + 1,
1317-
event: parsed?.headers?.Event || null,
1318-
variant: parsed?.headers?.Variant || 'Classical',
1319-
result: parsed?.headers?.Result || '*',
1320-
white: parsed?.headers?.White || null,
1321-
black: parsed?.headers?.Black || null,
1322-
termination: parsed?.headers?.Termination || null,
1323-
timeControl: parsed?.headers?.TimeControl || null,
1324-
utcDate: parsed?.headers?.UTCDate || null,
1325-
utcTime: parsed?.headers?.UTCTime || null,
1326-
worldBounds: parsed?.worldBounds || null,
1327-
startPosition: parsed?.startPosition || null,
1328-
moves: parsed?.moves || [],
1329-
rawICN: icn
1330-
};
1331-
});
1332-
1333-
const jsonOutput = JSON.stringify({ games, exportedAt: new Date().toISOString() }, null, 2);
1223+
const jsonOutput = JSON.stringify(gameLogs, null, 2);
13341224
const blob = new Blob([jsonOutput], { type: 'application/json' });
13351225
const url = URL.createObjectURL(blob);
13361226
const a = document.createElement('a');
@@ -1348,7 +1238,7 @@ runSprtBtn.addEventListener('click', runSprt);
13481238
stopSprtBtn.addEventListener('click', stopSprt);
13491239
copyLogBtn.addEventListener('click', copyLog);
13501240
downloadLogsBtn.addEventListener('click', downloadLogs);
1351-
downloadGamesBtn.addEventListener('click', downloadGames);
1241+
downloadGamesTxtBtn.addEventListener('click', downloadGames);
13521242
downloadGamesJsonBtn.addEventListener('click', downloadGamesJson);
13531243
sprtVariantsEl.addEventListener('change', updateSelectedVariants);
13541244

@@ -1425,11 +1315,6 @@ window.__sprt_compute_features = async (rawSamples) => {
14251315
};
14261316

14271317
initWasm();
1428-
// Initially hide & disable games download until we have results
1429-
downloadGamesBtn.disabled = true;
1430-
downloadGamesBtn.style.display = 'none';
1431-
downloadGamesJsonBtn.disabled = true;
1432-
downloadGamesJsonBtn.style.display = 'none';
14331318

14341319
/* UI Logic for TC Mode */
14351320
if (typeof sprtTcMode !== 'undefined' && sprtTcMode) {

0 commit comments

Comments
 (0)