Spaces:
Running on Zero
Running on Zero
Replace synthetic viewer telemetry with recorded native drives
#5
by TiGa-RCE - opened
- FRONTEND_SOURCE.json +9 -8
- RELEASE_MANIFEST.json +6 -5
- client.js +69 -5
- index.html +12 -2
- replay.py +287 -0
- server.py +40 -0
- track-map.js +180 -952
FRONTEND_SOURCE.json
CHANGED
|
@@ -5,19 +5,20 @@
|
|
| 5 |
"source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
|
| 6 |
"source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8"
|
| 7 |
},
|
| 8 |
-
"client_sha256": "
|
| 9 |
"identity_note": "The historical source fields describe the base frontend; local packaged file hashes describe this candidate's derived frontend.",
|
| 10 |
-
"local_customization_id": "
|
| 11 |
-
"output_sha256": "
|
| 12 |
"packaged_frontend_files": {
|
| 13 |
"assets/track-atlas.json": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
|
| 14 |
-
"client.js": "
|
| 15 |
"help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
|
| 16 |
"hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
|
| 17 |
-
"index.html": "
|
| 18 |
"track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
|
| 19 |
-
"track-map.js": "
|
| 20 |
},
|
| 21 |
-
"packaged_frontend_sha256": "
|
| 22 |
-
"
|
|
|
|
| 23 |
}
|
|
|
|
| 5 |
"source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
|
| 6 |
"source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8"
|
| 7 |
},
|
| 8 |
+
"client_sha256": "19b2a095dd40d91104cc1dc8e5ef3423f88683d57a7a884c490c35d236b2f15e",
|
| 9 |
"identity_note": "The historical source fields describe the base frontend; local packaged file hashes describe this candidate's derived frontend.",
|
| 10 |
+
"local_customization_id": "native-snapshot-replay-v1",
|
| 11 |
+
"output_sha256": "2c3926b1b4f63eaf219a4a62094c1098df5fc713a7a89a5e93ad44cabdd0e191",
|
| 12 |
"packaged_frontend_files": {
|
| 13 |
"assets/track-atlas.json": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
|
| 14 |
+
"client.js": "19b2a095dd40d91104cc1dc8e5ef3423f88683d57a7a884c490c35d236b2f15e",
|
| 15 |
"help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
|
| 16 |
"hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
|
| 17 |
+
"index.html": "2c3926b1b4f63eaf219a4a62094c1098df5fc713a7a89a5e93ad44cabdd0e191",
|
| 18 |
"track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
|
| 19 |
+
"track-map.js": "9ad6bcfc4aa9e684d395aadf28f770431837415a50d9c2df19098dec57c436c2"
|
| 20 |
},
|
| 21 |
+
"packaged_frontend_sha256": "3b7a107ccc288cb2b187590d1b4d4b80fd04c3b450b4f12dc9f71d99c84dedd1",
|
| 22 |
+
"packaged_frontend_sha256_format": "SHA-256 of UTF-8 JSON for packaged_frontend_files, sorted keys, compact separators, no trailing newline",
|
| 23 |
+
"status": "Recorded CPU drive viewer"
|
| 24 |
}
|
RELEASE_MANIFEST.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
"CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
|
| 8 |
"DATA_POLICY.md": "fc5978528abe1c21c14882417ac8a706e04a0831bbd3fdb3de8c64442c1a7b3d",
|
| 9 |
"FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
|
| 10 |
-
"FRONTEND_SOURCE.json": "
|
| 11 |
"GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
|
| 12 |
"IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
|
| 13 |
"LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
|
|
@@ -59,13 +59,13 @@
|
|
| 59 |
"builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
|
| 60 |
"builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
|
| 61 |
"builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
|
| 62 |
-
"client.js": "
|
| 63 |
"delivery.py": "6bdba2e87c3e19a41440cb16103695af27105f8bafbae38948bbb1b936ed0c1b",
|
| 64 |
"gpu_tickets.py": "20928fcf1ca24648809ad8712c929377bdcc8b5e6b4387bfff3503ec5711928b",
|
| 65 |
"help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
|
| 66 |
"hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
|
| 67 |
"hf_identity.py": "913fb1e089af21f376e8359a20cbc838d5bcd7971def6989a4382d53c1127b37",
|
| 68 |
-
"index.html": "
|
| 69 |
"native/analysis.patch": "42f79de67cb0734cffb2a7582f5479cab42ab82675884b8b42b9f158d46a98e8",
|
| 70 |
"native/build-receipt.json": "559a7e4e382f9f7caa1c5ceb566a5a0bbefad2ed3a2e2cf604e31c9c3890c348",
|
| 71 |
"native/physics-build-receipt.json": "63561c108554a09884cc68a1bfadfb8a3f530a800de0700ff671d99e558609fe",
|
|
@@ -78,12 +78,13 @@
|
|
| 78 |
"native/source-receipt.json": "36feba861501baf8672884476bbeed5e375254cbb4feffb9f4f90aa61a415a93",
|
| 79 |
"native/upstream-v1.3.patch": "a9810c9abd40ddcbac5fcf6914320d5fc04fcde23849df9437ec293c83be15ed",
|
| 80 |
"portable_bundle.py": "58df5b87956745ea909c41c4b12a23cf9ab8c40a7df47d1529f47ae4de9bf51f",
|
|
|
|
| 81 |
"requirements.txt": "fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4",
|
| 82 |
-
"server.py": "
|
| 83 |
"star_check.py": "5c17080957b81ffccf0de8314635b471562e55c92c97a1207d9034a99279b2c3",
|
| 84 |
"steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
|
| 85 |
"track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
|
| 86 |
-
"track-map.js": "
|
| 87 |
"training_config.py": "6eb7282bbee3b3a6273bddc29c949d9904067414c26e501d815012f033173a1c",
|
| 88 |
"vendor/gradio-client/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 89 |
"vendor/gradio-client/dist/index.min.js": "fdd776db8633354f118fe2908c980592283968737e6b91dc3aa86d648da5c3fd",
|
|
|
|
| 7 |
"CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
|
| 8 |
"DATA_POLICY.md": "fc5978528abe1c21c14882417ac8a706e04a0831bbd3fdb3de8c64442c1a7b3d",
|
| 9 |
"FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
|
| 10 |
+
"FRONTEND_SOURCE.json": "9e4944337ba6d0dc420de9966cf898f305273320a69d23e62c5263e5a47e9ffa",
|
| 11 |
"GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
|
| 12 |
"IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
|
| 13 |
"LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
|
|
|
|
| 59 |
"builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
|
| 60 |
"builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
|
| 61 |
"builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
|
| 62 |
+
"client.js": "19b2a095dd40d91104cc1dc8e5ef3423f88683d57a7a884c490c35d236b2f15e",
|
| 63 |
"delivery.py": "6bdba2e87c3e19a41440cb16103695af27105f8bafbae38948bbb1b936ed0c1b",
|
| 64 |
"gpu_tickets.py": "20928fcf1ca24648809ad8712c929377bdcc8b5e6b4387bfff3503ec5711928b",
|
| 65 |
"help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
|
| 66 |
"hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
|
| 67 |
"hf_identity.py": "913fb1e089af21f376e8359a20cbc838d5bcd7971def6989a4382d53c1127b37",
|
| 68 |
+
"index.html": "2c3926b1b4f63eaf219a4a62094c1098df5fc713a7a89a5e93ad44cabdd0e191",
|
| 69 |
"native/analysis.patch": "42f79de67cb0734cffb2a7582f5479cab42ab82675884b8b42b9f158d46a98e8",
|
| 70 |
"native/build-receipt.json": "559a7e4e382f9f7caa1c5ceb566a5a0bbefad2ed3a2e2cf604e31c9c3890c348",
|
| 71 |
"native/physics-build-receipt.json": "63561c108554a09884cc68a1bfadfb8a3f530a800de0700ff671d99e558609fe",
|
|
|
|
| 78 |
"native/source-receipt.json": "36feba861501baf8672884476bbeed5e375254cbb4feffb9f4f90aa61a415a93",
|
| 79 |
"native/upstream-v1.3.patch": "a9810c9abd40ddcbac5fcf6914320d5fc04fcde23849df9437ec293c83be15ed",
|
| 80 |
"portable_bundle.py": "58df5b87956745ea909c41c4b12a23cf9ab8c40a7df47d1529f47ae4de9bf51f",
|
| 81 |
+
"replay.py": "cce5dadc682dafadaa558b49fe1dac735e225fc26881566f59f6e59447b9ab4f",
|
| 82 |
"requirements.txt": "fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4",
|
| 83 |
+
"server.py": "5656f969f09d171a0eb257b004191bb84f2a365ed2552dacbe3ec77b4ab77294",
|
| 84 |
"star_check.py": "5c17080957b81ffccf0de8314635b471562e55c92c97a1207d9034a99279b2c3",
|
| 85 |
"steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
|
| 86 |
"track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
|
| 87 |
+
"track-map.js": "9ad6bcfc4aa9e684d395aadf28f770431837415a50d9c2df19098dec57c436c2",
|
| 88 |
"training_config.py": "6eb7282bbee3b3a6273bddc29c949d9904067414c26e501d815012f033173a1c",
|
| 89 |
"vendor/gradio-client/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 90 |
"vendor/gradio-client/dist/index.min.js": "fdd776db8633354f118fe2908c980592283968737e6b91dc3aa86d648da5c3fd",
|
client.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
| 2 |
const $ = id => document.getElementById(id);
|
| 3 |
let job = null;
|
| 4 |
let busy = false;
|
|
|
|
|
|
|
|
|
|
| 5 |
let localResult = null;
|
| 6 |
let localAnalysis = null;
|
| 7 |
let pairedAnalysisPending = false;
|
|
@@ -58,7 +61,7 @@ function batchRunContext(){
|
|
| 58 |
const trackInfo=trackLibrary?.info()??{source:'upload'},trackFile=$('track').files[0],network=networkSource(),backend=$('backend').value;
|
| 59 |
const hasTrack=trackInfo.source==='builtin'||!!trackFile;
|
| 60 |
let reason='';
|
| 61 |
-
if(busy||bundleRestoring||bucketOperation)reason='Finish the current operation before starting a batch.';
|
| 62 |
else if(job)reason='Finish or delete the current result before starting a batch.';
|
| 63 |
else if(!capabilitiesReady)reason='Connect to the training service before running a batch.';
|
| 64 |
else if($('run_mode')?.value==='evaluate')reason='Under Action in Run & results, choose Train a population before running a batch.';
|
|
@@ -70,7 +73,7 @@ function batchRunContext(){
|
|
| 70 |
network:network?.name||(network?'Selected network':null),backend:backend.toUpperCase()};
|
| 71 |
}
|
| 72 |
function buttons() {
|
| 73 |
-
const settingsUnavailable=busy||bundleRestoring||bucketOperation;
|
| 74 |
const unavailable=settingsUnavailable||!!job;
|
| 75 |
trackLibrary?.setBusy(unavailable);
|
| 76 |
window.HFAuth?.setBusy(unavailable);
|
|
@@ -87,6 +90,13 @@ function buttons() {
|
|
| 87 |
$('forgetLocal').disabled = busy || !localResult;
|
| 88 |
$('track').disabled=unavailable;
|
| 89 |
$('network').disabled=unavailable;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
if($('batch_start')){
|
| 91 |
const context=batchRunContext();
|
| 92 |
window.setBatchRunContext?.(context);
|
|
@@ -438,7 +448,7 @@ function saveLocal() {
|
|
| 438 |
setTimeout(()=>URL.revokeObjectURL(url),60000);
|
| 439 |
}
|
| 440 |
$('saveLocal').onclick=saveLocal;
|
| 441 |
-
$('forgetLocal').onclick=()=>{localResult=null;localAnalysis=null;pairedAnalysisPending=false;renderAnalysisPanel();buttons();say('Browser result discarded. Downloaded files are unaffected.');};
|
| 442 |
async function request(path, options = {}) {
|
| 443 |
const response = await fetch(path, {...options, cache:'no-store', credentials:'same-origin'});
|
| 444 |
if (!response.ok) {
|
|
@@ -455,19 +465,71 @@ async function encoded(file) {
|
|
| 455 |
}
|
| 456 |
const headers = () => ({'X-Job-Key':job.key});
|
| 457 |
const networkSource=()=>stagedNetwork || $('network').files[0];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
window.addEventListener('ald:batch-draft-change',buttons);
|
| 459 |
document.addEventListener('track-library-change',buttons);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
async function refreshFreezeNetwork(file){
|
|
|
|
| 461 |
if(!file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);return;}
|
| 462 |
try{const network=await validateNetworkBytes(new Uint8Array(await file.arrayBuffer()));if(networkSource()===file){window.setFreezeNetwork?.(network);window.TrackReplay?.setStagedNetwork?.(network);}}
|
| 463 |
catch(error){if(networkSource()===file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);if($('freeze_status'))$('freeze_status').textContent='Network metadata unavailable: '+error.message;}}
|
| 464 |
}
|
| 465 |
$('network').onchange=()=>{stagedNetwork=null;selectedWinner=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';refreshFreezeNetwork(networkSource());buttons();};
|
| 466 |
window.TrainingClient={
|
| 467 |
-
isBusy:()=>busy||!!job||bundleRestoring,
|
| 468 |
hasUnsavedState:()=>!!(networkSource()||localResult||$('track').files.length||trackLibrary?.info?.()?.source==='builtin'||$('editor_file')?.files.length||window.hasUnsavedNetworkDraft?.()),
|
| 469 |
useNetwork(file){
|
| 470 |
-
if(busy||job)throw Error('Finish
|
| 471 |
stagedNetwork=file;$('network').value='';
|
| 472 |
if($('networkChoice'))$('networkChoice').textContent='Browser editor input: '+file.name+'. Not uploaded yet.';
|
| 473 |
refreshFreezeNetwork(file);
|
|
@@ -961,6 +1023,8 @@ function showDetails(r){
|
|
| 961 |
window.TrackReplay?.setRunAnalysis?.(localAnalysis,runReceipt||portableReceipt||(r?{report:r}:null));
|
| 962 |
}
|
| 963 |
$('run').onclick = async () => {
|
|
|
|
|
|
|
| 964 |
const comparisonAttempt=!!comparisonPlan,comparisonRole=comparisonAttempt?comparisonPlan.roles[comparisonPlan.index]:null;
|
| 965 |
const previousRunResultContext=runResultContext;
|
| 966 |
cancelSteamLeaderboard();
|
|
|
|
| 2 |
const $ = id => document.getElementById(id);
|
| 3 |
let job = null;
|
| 4 |
let busy = false;
|
| 5 |
+
let replayBusy = false;
|
| 6 |
+
let localReplay = null;
|
| 7 |
+
let replayRequestId = 0;
|
| 8 |
let localResult = null;
|
| 9 |
let localAnalysis = null;
|
| 10 |
let pairedAnalysisPending = false;
|
|
|
|
| 61 |
const trackInfo=trackLibrary?.info()??{source:'upload'},trackFile=$('track').files[0],network=networkSource(),backend=$('backend').value;
|
| 62 |
const hasTrack=trackInfo.source==='builtin'||!!trackFile;
|
| 63 |
let reason='';
|
| 64 |
+
if(busy||replayBusy||bundleRestoring||bucketOperation)reason='Finish the current operation before starting a batch.';
|
| 65 |
else if(job)reason='Finish or delete the current result before starting a batch.';
|
| 66 |
else if(!capabilitiesReady)reason='Connect to the training service before running a batch.';
|
| 67 |
else if($('run_mode')?.value==='evaluate')reason='Under Action in Run & results, choose Train a population before running a batch.';
|
|
|
|
| 73 |
network:network?.name||(network?'Selected network':null),backend:backend.toUpperCase()};
|
| 74 |
}
|
| 75 |
function buttons() {
|
| 76 |
+
const settingsUnavailable=busy||replayBusy||bundleRestoring||bucketOperation;
|
| 77 |
const unavailable=settingsUnavailable||!!job;
|
| 78 |
trackLibrary?.setBusy(unavailable);
|
| 79 |
window.HFAuth?.setBusy(unavailable);
|
|
|
|
| 90 |
$('forgetLocal').disabled = busy || !localResult;
|
| 91 |
$('track').disabled=unavailable;
|
| 92 |
$('network').disabled=unavailable;
|
| 93 |
+
if($('replay_record'))$('replay_record').disabled=unavailable||batchActive||!capabilitiesReady||!replayNetworkSource();
|
| 94 |
+
if($('replay_duration'))$('replay_duration').disabled=settingsUnavailable;
|
| 95 |
+
if($('replay_download'))$('replay_download').disabled=replayBusy||!localReplay;
|
| 96 |
+
if($('replay_policy')){
|
| 97 |
+
const selected=networkSource();
|
| 98 |
+
$('replay_policy').textContent=selected?'Policy: '+(selected.name||'Selected network'):localResult?'Policy: latest received result. Choose a winner below to record a different policy.':'Choose or create a network to record a drive.';
|
| 99 |
+
}
|
| 100 |
if($('batch_start')){
|
| 101 |
const context=batchRunContext();
|
| 102 |
window.setBatchRunContext?.(context);
|
|
|
|
| 448 |
setTimeout(()=>URL.revokeObjectURL(url),60000);
|
| 449 |
}
|
| 450 |
$('saveLocal').onclick=saveLocal;
|
| 451 |
+
$('forgetLocal').onclick=()=>{invalidateReplay();localResult=null;localAnalysis=null;pairedAnalysisPending=false;renderAnalysisPanel();buttons();say('Browser result discarded. Downloaded files are unaffected.');};
|
| 452 |
async function request(path, options = {}) {
|
| 453 |
const response = await fetch(path, {...options, cache:'no-store', credentials:'same-origin'});
|
| 454 |
if (!response.ok) {
|
|
|
|
| 465 |
}
|
| 466 |
const headers = () => ({'X-Job-Key':job.key});
|
| 467 |
const networkSource=()=>stagedNetwork || $('network').files[0];
|
| 468 |
+
function replayNetworkSource(){return networkSource()||localResult;}
|
| 469 |
+
function invalidateReplay(reason){
|
| 470 |
+
replayRequestId++;
|
| 471 |
+
localReplay=null;
|
| 472 |
+
window.TrackReplay?.clearRecording?.(reason);
|
| 473 |
+
if($('replay_download'))$('replay_download').disabled=true;
|
| 474 |
+
}
|
| 475 |
window.addEventListener('ald:batch-draft-change',buttons);
|
| 476 |
document.addEventListener('track-library-change',buttons);
|
| 477 |
+
document.addEventListener('track-library-change',()=>invalidateReplay('Track changed. Record a new drive for this track.'));
|
| 478 |
+
if($('replay_record'))$('replay_record').onclick=async()=>{
|
| 479 |
+
if(busy||replayBusy||job||batchActive||bundleRestoring||bucketOperation)return;
|
| 480 |
+
replayBusy=true;busy=true;
|
| 481 |
+
invalidateReplay('Recording a fresh CPU drive…');
|
| 482 |
+
const attempt=replayRequestId;
|
| 483 |
+
const controller=new AbortController();
|
| 484 |
+
const timeout=setTimeout(()=>controller.abort(),40000);
|
| 485 |
+
buttons();
|
| 486 |
+
try{
|
| 487 |
+
const track=await trackSource(),network=replayNetworkSource();
|
| 488 |
+
if(!track||!network)throw Error('Choose a track and a network before recording.');
|
| 489 |
+
const ticks=Number($('replay_duration').value);
|
| 490 |
+
if(![300,900].includes(ticks))throw Error('Choose a 5-second or 15-second recording.');
|
| 491 |
+
const [trackBytes,networkBytes]=await Promise.all([track.arrayBuffer(),network.arrayBuffer()]);
|
| 492 |
+
await validateNetworkBytes(new Uint8Array(networkBytes));
|
| 493 |
+
const [trackSha,networkSha,track64,network64]=await Promise.all([
|
| 494 |
+
sha(trackBytes),sha(networkBytes),encoded(track),encoded(network)
|
| 495 |
+
]);
|
| 496 |
+
const response=await request('/api/replay',{method:'POST',signal:controller.signal,
|
| 497 |
+
headers:{'Content-Type':'application/json'},body:JSON.stringify({track:track64,network:network64,ticks})});
|
| 498 |
+
const raw=await response.text();
|
| 499 |
+
if(raw.length>2*1024*1024)throw Error('Recording exceeds the browser limit.');
|
| 500 |
+
const recording=JSON.parse(raw);
|
| 501 |
+
const currentTrack=await trackSource(),currentNetwork=replayNetworkSource();
|
| 502 |
+
if(attempt!==replayRequestId||!currentTrack||!currentNetwork)throw Error('Selection changed during recording. Record the current selection again.');
|
| 503 |
+
const [currentTrackSha,currentNetworkSha]=await Promise.all([
|
| 504 |
+
currentTrack.arrayBuffer().then(sha),currentNetwork.arrayBuffer().then(sha)
|
| 505 |
+
]);
|
| 506 |
+
if(currentTrackSha!==trackSha||currentNetworkSha!==networkSha)throw Error('Selection changed during recording. Record the current selection again.');
|
| 507 |
+
if(!window.TrackReplay?.setRecording)throw Error('The recorded-drive viewer is unavailable. Reload this page.');
|
| 508 |
+
await window.TrackReplay.setRecording(recording,trackSha,networkSha);
|
| 509 |
+
if(attempt!==replayRequestId){window.TrackReplay.clearRecording();throw Error('Selection changed during recording. Record the current selection again.');}
|
| 510 |
+
localReplay=recording;
|
| 511 |
+
}catch(error){
|
| 512 |
+
if(attempt===replayRequestId&&$('replay_status'))$('replay_status').textContent=
|
| 513 |
+
error?.name==='AbortError'?'Recording timed out. Try a 5-second drive.':'Recording unavailable: '+error.message;
|
| 514 |
+
}finally{
|
| 515 |
+
clearTimeout(timeout);replayBusy=false;busy=false;buttons();
|
| 516 |
+
}
|
| 517 |
+
};
|
| 518 |
+
if($('replay_download'))$('replay_download').onclick=()=>{
|
| 519 |
+
if(localReplay)downloadTextFile(JSON.stringify(localReplay),'ald-recorded-drive.json','application/json');
|
| 520 |
+
};
|
| 521 |
async function refreshFreezeNetwork(file){
|
| 522 |
+
invalidateReplay('Network changed. Record a new drive for this policy.');
|
| 523 |
if(!file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);return;}
|
| 524 |
try{const network=await validateNetworkBytes(new Uint8Array(await file.arrayBuffer()));if(networkSource()===file){window.setFreezeNetwork?.(network);window.TrackReplay?.setStagedNetwork?.(network);}}
|
| 525 |
catch(error){if(networkSource()===file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);if($('freeze_status'))$('freeze_status').textContent='Network metadata unavailable: '+error.message;}}
|
| 526 |
}
|
| 527 |
$('network').onchange=()=>{stagedNetwork=null;selectedWinner=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';refreshFreezeNetwork(networkSource());buttons();};
|
| 528 |
window.TrainingClient={
|
| 529 |
+
isBusy:()=>busy||replayBusy||!!job||bundleRestoring,
|
| 530 |
hasUnsavedState:()=>!!(networkSource()||localResult||$('track').files.length||trackLibrary?.info?.()?.source==='builtin'||$('editor_file')?.files.length||window.hasUnsavedNetworkDraft?.()),
|
| 531 |
useNetwork(file){
|
| 532 |
+
if(busy||replayBusy||job)throw Error('Finish the current operation before changing the network');
|
| 533 |
stagedNetwork=file;$('network').value='';
|
| 534 |
if($('networkChoice'))$('networkChoice').textContent='Browser editor input: '+file.name+'. Not uploaded yet.';
|
| 535 |
refreshFreezeNetwork(file);
|
|
|
|
| 1023 |
window.TrackReplay?.setRunAnalysis?.(localAnalysis,runReceipt||portableReceipt||(r?{report:r}:null));
|
| 1024 |
}
|
| 1025 |
$('run').onclick = async () => {
|
| 1026 |
+
if(replayBusy)return;
|
| 1027 |
+
invalidateReplay();
|
| 1028 |
const comparisonAttempt=!!comparisonPlan,comparisonRole=comparisonAttempt?comparisonPlan.roles[comparisonPlan.index]:null;
|
| 1029 |
const previousRunResultContext=runResultContext;
|
| 1030 |
cancelSteamLeaderboard();
|
index.html
CHANGED
|
@@ -34,7 +34,7 @@
|
|
| 34 |
})();
|
| 35 |
</script>
|
| 36 |
<header class="lab-header"><div><div class="eyebrow">AI LEARNS TO DRIVE</div><h1>Training lab</h1><p class="intro">Build a driver, train it, inspect the result.</p></div><div class="header-actions"><button id="quick_start_toggle" type="button" aria-controls="firstRunGuide" aria-expanded="false">Quick start</button><a class="help-link" href="/help#start-here" target="_blank" rel="noopener">Help & tutorial ↗</a></div></header>
|
| 37 |
-
<details class="about"><summary>About & feedback</summary><p>Simulator by <a href="https://github.com/Zorro909/driving_sim_rs">Zorro909</a>; interface and hosting by <a href="https://github.com/seeker-cyber-maker/driving_sim_rs">TiGa</a>. Inspired by the <a href="https://store.steampowered.com/app/3312030/AI_Learns_To_Drive/">game by ActivationFunction</a>. Built-in tracks are reconstructed replicas. For supported conditions, the lab checks the run’s final fitness policy (or frozen input) with a bounded fresh replay. It predicts conditions only and does not award or accumulate original-game stars; opponent races and other unsupported conditions remain unchecked.</p><p>Uploaded files are used for the requested run. Results are held briefly in application memory and deleted after verified download or expiry. Browser recipes contain settings only. <a href="/policy" target="_blank" rel="noopener">Read the data policy</a>.</p><p>
|
| 38 |
<details id="firstRunGuide" class="quick-start"><summary id="firstRunTitle">New here? Start a first run <span class="hint">Four steps, whenever you need them</span></summary><ol class="quick-steps"><li><strong>Choose a track</strong><p>Pick a built-in replica or upload your own track.</p><button id="first_run_track" type="button">Choose a track</button></li><li><strong>Create a driver</strong><p>Choose a car and create a small starter network, or upload a network you already have.</p><button type="button" data-open-tab="drive" data-focus="starter_create">Set up a network</button></li><li><strong>Review the settings</strong><p>The starter settings are ready for a short first experiment. You can save them as a recipe later.</p><button id="first_run_settings" type="button">Review first-run settings</button></li><li><strong>Run and inspect</strong><p>Start training, then download the result to inspect your drivers. A first run may not finish a lap.</p><button type="button" data-open-tab="drive" data-focus="run">Go to run controls</button></li></ol><div class="quick-footer"><a href="/help#start-here" target="_blank" rel="noopener">Read the full step-by-step tutorial ↗</a><button id="quick_start_close" type="button">Close quick start</button></div></details>
|
| 39 |
<nav class="tabs" role="tablist" aria-label="Lab sections"><button type="button" id="tab-drive" role="tab" data-tab="drive" aria-controls="drive" aria-selected="true" tabindex="0">Run & results</button><button type="button" id="tab-training" role="tab" data-tab="training" aria-controls="training" aria-selected="false" tabindex="-1">Training & recipes</button><button type="button" id="tab-batch" role="tab" data-tab="batch" aria-controls="batch" aria-selected="false" tabindex="-1">Batches</button><button type="button" id="tab-editor" role="tab" data-tab="editor" aria-controls="editor" aria-selected="false" tabindex="-1">Network editor</button></nav><div id="drive" class="pane" role="tabpanel" aria-labelledby="tab-drive"><div class="grid"><section class="card"><h2>Simulation</h2><div class="toolbar" aria-label="Create a starter network"><label for="starter_vehicle">Starter car</label><select id="starter_vehicle"><option value="0" selected>Formula</option><option value="1">Rally</option><option value="2">Snowmobile</option><option value="3">Truck</option></select><button id="starter_create" type="button">Create starter network</button></div><p id="starter_network_status" class="hint" role="status">Creates a small network in this page only. It does not start a run or change saved recipes.</p><label for="run_mode">Action</label><select id="run_mode"><option value="train" selected>Train a population</option><option value="evaluate">Frozen evaluation · one policy, unchanged weights</option></select><p id="run_mode_help" class="hint">Training uses the selected recipe. Frozen evaluation runs the selected policy in a fresh car without changing its weights.</p><button id="run" disabled>Run training</button><p id="starCheckNotice" class="hint">After each run, supported star challenges are checked with unchanged weights. Results are predictions, not game awards. <a href="/help#concepts" target="_blank" rel="noopener">How checks work</a>.</p><p class="hint">Select or create a network below to enable training. Review Training & recipes before running.</p><p>Supports Formula, Rally, Snowmobile and Truck networks. Control outputs follow the original vehicle editor rules. Network shape and supported sensors are bounded by the original game contract.</p>
|
| 40 |
<p id="computeBlock"><label for="backend">Compute</label><select id="backend" disabled><option value="cpu">CPU — no GPU allowance</option><option id="gpuOption" value="gpu" disabled>GPU — uses GPU allowance</option></select></p><p id="backendNote" class="hint">Checking available backends…</p>
|
|
@@ -48,7 +48,17 @@
|
|
| 48 |
<p><label>Track (.track) <input id="track" type="file" accept=".track"></label></p>
|
| 49 |
<p><label>Vehicle network (.evolution) <input id="network" type="file" accept=".evolution"></label></p>
|
| 50 |
<p id="networkChoice" role="status">Choose an uploaded file or use a network from the browser editor.</p><p class="privacy-note">Uploads are processed for this run. By default, the server copy is deleted after the browser verifies the download.</p><details><summary>Optional repeat download</summary><label class="keep-result"><input id="keepServer" type="checkbox"> Keep this result on the server for up to five minutes so I can download it again</label><p class="privacy-note">This is optional and resets after each run. When bucket auto-save is on, verified receipt must delete the server copy first, so repeat download is disabled. All results expire after five minutes. <a href="/policy" target="_blank" rel="noopener">Data policy</a></p>
|
| 51 |
-
</details></section><section class="card"><h2>Selected track & terrain</h2><p id="mapStatus" class="map-status" role="status">Loading selected track…</p><div class="map"><canvas id="trackMap" width="1000" height="500" role="img" aria-label="Selected track tile layout and terrain"></canvas></div><div class="replay-bar" id="replayControls" aria-label="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<p><button id="saveLocal" disabled>Download received result (browser only)</button> <button id="forgetLocal" disabled>Discard browser result</button></p>
|
| 53 |
<p>The browser keeps the received result in memory until discarded, replaced by a new run, or this page is closed. It is not saved to browser storage.</p>
|
| 54 |
<h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
|
|
|
|
| 34 |
})();
|
| 35 |
</script>
|
| 36 |
<header class="lab-header"><div><div class="eyebrow">AI LEARNS TO DRIVE</div><h1>Training lab</h1><p class="intro">Build a driver, train it, inspect the result.</p></div><div class="header-actions"><button id="quick_start_toggle" type="button" aria-controls="firstRunGuide" aria-expanded="false">Quick start</button><a class="help-link" href="/help#start-here" target="_blank" rel="noopener">Help & tutorial ↗</a></div></header>
|
| 37 |
+
<details class="about"><summary>About & feedback</summary><p>Simulator by <a href="https://github.com/Zorro909/driving_sim_rs">Zorro909</a>; interface and hosting by <a href="https://github.com/seeker-cyber-maker/driving_sim_rs">TiGa</a>. Inspired by the <a href="https://store.steampowered.com/app/3312030/AI_Learns_To_Drive/">game by ActivationFunction</a>. Built-in tracks are reconstructed replicas. For supported conditions, the lab checks the run’s final fitness policy (or frozen input) with a bounded fresh replay. It predicts conditions only and does not award or accumulate original-game stars; opponent races and other unsupported conditions remain unchecked.</p><p>Uploaded files are used for the requested run. Results are held briefly in application memory and deleted after verified download or expiry. Browser recipes contain settings only. <a href="/policy" target="_blank" rel="noopener">Read the data policy</a>.</p><p>The map follows the selected track’s native centerline. Record a short CPU drive to watch an unchanged policy using real vehicle positions. A new recording can differ from an earlier training run. Steering commands, sensor rays and neural activity are not included in the current recording.</p><p><a id="feedbackLink" href="https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new" target="_blank" rel="noopener">Report a problem</a> · Opens an editable draft for you to review.</p><p id="buildLabel" class="hint">Build: connecting…</p></details>
|
| 38 |
<details id="firstRunGuide" class="quick-start"><summary id="firstRunTitle">New here? Start a first run <span class="hint">Four steps, whenever you need them</span></summary><ol class="quick-steps"><li><strong>Choose a track</strong><p>Pick a built-in replica or upload your own track.</p><button id="first_run_track" type="button">Choose a track</button></li><li><strong>Create a driver</strong><p>Choose a car and create a small starter network, or upload a network you already have.</p><button type="button" data-open-tab="drive" data-focus="starter_create">Set up a network</button></li><li><strong>Review the settings</strong><p>The starter settings are ready for a short first experiment. You can save them as a recipe later.</p><button id="first_run_settings" type="button">Review first-run settings</button></li><li><strong>Run and inspect</strong><p>Start training, then download the result to inspect your drivers. A first run may not finish a lap.</p><button type="button" data-open-tab="drive" data-focus="run">Go to run controls</button></li></ol><div class="quick-footer"><a href="/help#start-here" target="_blank" rel="noopener">Read the full step-by-step tutorial ↗</a><button id="quick_start_close" type="button">Close quick start</button></div></details>
|
| 39 |
<nav class="tabs" role="tablist" aria-label="Lab sections"><button type="button" id="tab-drive" role="tab" data-tab="drive" aria-controls="drive" aria-selected="true" tabindex="0">Run & results</button><button type="button" id="tab-training" role="tab" data-tab="training" aria-controls="training" aria-selected="false" tabindex="-1">Training & recipes</button><button type="button" id="tab-batch" role="tab" data-tab="batch" aria-controls="batch" aria-selected="false" tabindex="-1">Batches</button><button type="button" id="tab-editor" role="tab" data-tab="editor" aria-controls="editor" aria-selected="false" tabindex="-1">Network editor</button></nav><div id="drive" class="pane" role="tabpanel" aria-labelledby="tab-drive"><div class="grid"><section class="card"><h2>Simulation</h2><div class="toolbar" aria-label="Create a starter network"><label for="starter_vehicle">Starter car</label><select id="starter_vehicle"><option value="0" selected>Formula</option><option value="1">Rally</option><option value="2">Snowmobile</option><option value="3">Truck</option></select><button id="starter_create" type="button">Create starter network</button></div><p id="starter_network_status" class="hint" role="status">Creates a small network in this page only. It does not start a run or change saved recipes.</p><label for="run_mode">Action</label><select id="run_mode"><option value="train" selected>Train a population</option><option value="evaluate">Frozen evaluation · one policy, unchanged weights</option></select><p id="run_mode_help" class="hint">Training uses the selected recipe. Frozen evaluation runs the selected policy in a fresh car without changing its weights.</p><button id="run" disabled>Run training</button><p id="starCheckNotice" class="hint">After each run, supported star challenges are checked with unchanged weights. Results are predictions, not game awards. <a href="/help#concepts" target="_blank" rel="noopener">How checks work</a>.</p><p class="hint">Select or create a network below to enable training. Review Training & recipes before running.</p><p>Supports Formula, Rally, Snowmobile and Truck networks. Control outputs follow the original vehicle editor rules. Network shape and supported sensors are bounded by the original game contract.</p>
|
| 40 |
<p id="computeBlock"><label for="backend">Compute</label><select id="backend" disabled><option value="cpu">CPU — no GPU allowance</option><option id="gpuOption" value="gpu" disabled>GPU — uses GPU allowance</option></select></p><p id="backendNote" class="hint">Checking available backends…</p>
|
|
|
|
| 48 |
<p><label>Track (.track) <input id="track" type="file" accept=".track"></label></p>
|
| 49 |
<p><label>Vehicle network (.evolution) <input id="network" type="file" accept=".evolution"></label></p>
|
| 50 |
<p id="networkChoice" role="status">Choose an uploaded file or use a network from the browser editor.</p><p class="privacy-note">Uploads are processed for this run. By default, the server copy is deleted after the browser verifies the download.</p><details><summary>Optional repeat download</summary><label class="keep-result"><input id="keepServer" type="checkbox"> Keep this result on the server for up to five minutes so I can download it again</label><p class="privacy-note">This is optional and resets after each run. When bucket auto-save is on, verified receipt must delete the server copy first, so repeat download is disabled. All results expire after five minutes. <a href="/policy" target="_blank" rel="noopener">Data policy</a></p>
|
| 51 |
+
</details></section><section class="card"><h2>Selected track & terrain</h2><p id="mapStatus" class="map-status" role="status">Loading selected track…</p><div class="map"><canvas id="trackMap" width="1000" height="500" role="img" aria-label="Selected track tile layout and terrain"></canvas></div><div class="replay-bar" id="replayControls" aria-label="Recorded drive controls">
|
| 52 |
+
<div class="replay-row"><button type="button" id="replay_record" disabled>Record selected policy</button><label for="replay_duration" style="margin:0">Length</label><select id="replay_duration"><option value="300" selected>5 seconds</option><option value="900">15 seconds</option></select><button type="button" id="replay_download" disabled>Download recording</button></div>
|
| 53 |
+
<p id="replay_policy" class="hint">Choose or create a network to record a drive.</p>
|
| 54 |
+
<p class="hint">Fresh CPU drive · unchanged weights · 60 Hz physics and decisions · wall-hit and idle elimination off. Recording takes a few seconds; playback runs in your browser.</p>
|
| 55 |
+
<p id="replay_status" role="status">No replay recorded. The map shows the selected track and its saved starting point.</p>
|
| 56 |
+
<div class="replay-row"><button type="button" id="replay_play" disabled>Play</button><button type="button" id="replay_reset" disabled>Restart</button><input type="range" id="replay_scrub" min="1" max="300" value="1" disabled aria-label="Replay tick scrubber"><span id="replay_time_label" class="replay-time">No recording</span></div>
|
| 57 |
+
<div class="replay-row" style="margin-top:8px"><label for="replay_camera" style="margin:0">Camera</label><select id="replay_camera" aria-label="Replay camera mode" disabled><option value="full" selected>Full circuit</option><option value="follow">Follow car</option></select><label for="replay_speed" style="margin:0">Speed</label><select id="replay_speed" aria-label="Replay playback speed" disabled><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></div>
|
| 58 |
+
<div class="replay-layers"><label><input type="checkbox" id="replay_layer_outline" checked disabled> Car outline</label><label><input type="checkbox" id="replay_layer_line" checked disabled> Recorded path</label><label><input type="checkbox" id="replay_layer_hud" checked disabled> Recorded telemetry</label></div>
|
| 59 |
+
<p id="replay_readout" style="font-size:16px;font-variant-numeric:tabular-nums" aria-live="off"></p>
|
| 60 |
+
<details><summary>What does this recording show?</summary><p>Vehicle positions and velocities are sampled every 0.1 seconds from the native CPU simulator. Playback interpolates between those samples; it does not simulate the car. Speed is calculated from recorded velocity. Controls, sensor rays, skid marks and neural activations are not recorded. This fresh drive does not replace the measurements from your training run.</p><p>The recording remains in this page until replaced, cleared by a changed selection, or the page is closed. Use Download recording to keep its measured samples.</p></details>
|
| 61 |
+
</div><div class="map-legend"><span><i class="terrain asphalt"></i> Asphalt</span><span><i class="terrain dirt"></i> Dirt</span><span><i class="terrain ice"></i> Ice</span><span><i class="terrain start"></i> Start</span></div><p id="mapLegend" class="hint"></p><h2>Run & results</h2><button id="download" disabled>Download result</button> <button id="erase" disabled>Delete server-side job data now</button>
|
| 62 |
<p><button id="saveLocal" disabled>Download received result (browser only)</button> <button id="forgetLocal" disabled>Discard browser result</button></p>
|
| 63 |
<p>The browser keeps the received result in memory until discarded, replaced by a new run, or this page is closed. It is not saved to browser storage.</p>
|
| 64 |
<h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
|
replay.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded, measured playback using the installed native benchmark command.
|
| 2 |
+
|
| 3 |
+
Each sample is a deterministic fresh CPU evaluation of the same policy up to
|
| 4 |
+
that tick. This is not a recording of a previous training generation. Native
|
| 5 |
+
bench captures car state before turnover, accepts only positive tick counts,
|
| 6 |
+
and disables wall/idle elimination. With population one its preserved parent
|
| 7 |
+
is the supplied policy; every dump is checked against that exact parameter
|
| 8 |
+
vector before any recording is returned.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import math
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import stat
|
| 16 |
+
import struct
|
| 17 |
+
import subprocess
|
| 18 |
+
import tempfile
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
+
from worker import (
|
| 22 |
+
OUTPUT_NAMES,
|
| 23 |
+
native_environment,
|
| 24 |
+
network_input,
|
| 25 |
+
track_input,
|
| 26 |
+
vehicle_scene_template,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
MAX_TICKS = 900
|
| 31 |
+
SAMPLE_INTERVAL_TICKS = 6
|
| 32 |
+
MAX_WALL_SECONDS = 30.0
|
| 33 |
+
MAX_SCENE_BYTES = 16 * 1024 * 1024
|
| 34 |
+
MAX_DUMP_BYTES = 512 * 1024
|
| 35 |
+
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ReplayError(RuntimeError):
|
| 39 |
+
"""The native recorder could not produce a complete verified recording."""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ReplayTimeout(ReplayError, TimeoutError):
|
| 43 |
+
"""The recording exhausted its native subprocess or total wall budget."""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _remaining(deadline):
|
| 47 |
+
seconds = deadline - time.monotonic()
|
| 48 |
+
if seconds <= 0:
|
| 49 |
+
raise ReplayTimeout('Replay recording exceeded its time limit; try fewer ticks')
|
| 50 |
+
return seconds
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _run(command, *, work, env, deadline, stage, timeout=None):
|
| 54 |
+
remaining = _remaining(deadline)
|
| 55 |
+
try:
|
| 56 |
+
subprocess.run(
|
| 57 |
+
command,
|
| 58 |
+
cwd=work,
|
| 59 |
+
env=env,
|
| 60 |
+
stdin=subprocess.DEVNULL,
|
| 61 |
+
stdout=subprocess.DEVNULL,
|
| 62 |
+
stderr=subprocess.DEVNULL,
|
| 63 |
+
shell=False,
|
| 64 |
+
check=True,
|
| 65 |
+
timeout=min(remaining, timeout) if timeout is not None else remaining,
|
| 66 |
+
)
|
| 67 |
+
except subprocess.TimeoutExpired as exc:
|
| 68 |
+
raise ReplayTimeout('Replay recording exceeded its time limit; try fewer ticks') from exc
|
| 69 |
+
except (OSError, subprocess.CalledProcessError) as exc:
|
| 70 |
+
raise ReplayError('Native replay ' + stage + ' failed') from exc
|
| 71 |
+
_remaining(deadline)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _object(pairs):
|
| 75 |
+
result = {}
|
| 76 |
+
for key, value in pairs:
|
| 77 |
+
if key in result:
|
| 78 |
+
raise ValueError('Duplicate JSON key')
|
| 79 |
+
result[key] = value
|
| 80 |
+
return result
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _read_json(path, limit, label):
|
| 84 |
+
try:
|
| 85 |
+
info = path.lstat()
|
| 86 |
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
| 87 |
+
raise ValueError('Invalid output file')
|
| 88 |
+
with path.open('rb') as stream:
|
| 89 |
+
data = stream.read(limit + 1)
|
| 90 |
+
if len(data) > limit:
|
| 91 |
+
raise ValueError('Output exceeds limit')
|
| 92 |
+
value = json.loads(
|
| 93 |
+
data,
|
| 94 |
+
object_pairs_hook=_object,
|
| 95 |
+
parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Nonfinite JSON')),
|
| 96 |
+
)
|
| 97 |
+
if not isinstance(value, dict):
|
| 98 |
+
raise ValueError('Expected JSON object')
|
| 99 |
+
return value
|
| 100 |
+
except (OSError, ValueError, RecursionError) as exc:
|
| 101 |
+
raise ReplayError('Native replay returned an invalid ' + label) from exc
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _number(value, label):
|
| 105 |
+
finite = False
|
| 106 |
+
if type(value) in (int, float):
|
| 107 |
+
try:
|
| 108 |
+
finite = math.isfinite(value)
|
| 109 |
+
except OverflowError:
|
| 110 |
+
pass
|
| 111 |
+
if not finite:
|
| 112 |
+
raise ReplayError('Native replay returned an invalid ' + label)
|
| 113 |
+
return value
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _vector(value, label):
|
| 117 |
+
if not isinstance(value, list) or len(value) != 2:
|
| 118 |
+
raise ReplayError('Native replay returned an invalid ' + label)
|
| 119 |
+
return [_number(component, label) for component in value]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _counter(value, label):
|
| 123 |
+
# Keep integer values exact in the browser's JSON number representation.
|
| 124 |
+
if type(value) is not int or not 0 <= value <= 2**53 - 1:
|
| 125 |
+
raise ReplayError('Native replay returned an invalid ' + label)
|
| 126 |
+
return value
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _parameter_bytes(values):
|
| 130 |
+
# Native Network.params stores float64 values, layer by layer: each weight
|
| 131 |
+
# row, followed by that layer's bias. Comparing bytes also preserves -0.0.
|
| 132 |
+
return b''.join(struct.pack('!d', _number(value, 'policy parameter')) for value in values)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _frame(dump, tick, expected_parameters):
|
| 136 |
+
agents = dump.get('agents')
|
| 137 |
+
networks = dump.get('next_networks')
|
| 138 |
+
if (not isinstance(agents, list) or len(agents) != 1 or not isinstance(agents[0], dict)
|
| 139 |
+
or not isinstance(networks, list) or len(networks) != 1
|
| 140 |
+
or not isinstance(networks[0], list)
|
| 141 |
+
or len(networks[0]) * 8 != len(expected_parameters)):
|
| 142 |
+
raise ReplayError('Native replay must contain exactly one car and its policy')
|
| 143 |
+
if _parameter_bytes(networks[0]) != expected_parameters:
|
| 144 |
+
raise ReplayError('Native replay changed the selected policy; recording discarded')
|
| 145 |
+
|
| 146 |
+
agent = agents[0]
|
| 147 |
+
position = _vector(agent.get('position'), 'car position')
|
| 148 |
+
velocity = _vector(agent.get('velocity'), 'car velocity')
|
| 149 |
+
if type(agent.get('active')) is not bool:
|
| 150 |
+
raise ReplayError('Native replay returned an invalid active state')
|
| 151 |
+
frame = {
|
| 152 |
+
'tick': tick,
|
| 153 |
+
'x': position[0],
|
| 154 |
+
'y': position[1],
|
| 155 |
+
'rotation': _number(agent.get('rotation'), 'car rotation'),
|
| 156 |
+
'vx': velocity[0],
|
| 157 |
+
'vy': velocity[1],
|
| 158 |
+
'active': agent['active'],
|
| 159 |
+
}
|
| 160 |
+
for field in ('angular_velocity', 'boost_energy'):
|
| 161 |
+
if field in agent:
|
| 162 |
+
frame[field] = _number(agent[field], field)
|
| 163 |
+
for field in ('collision_count', 'update_count'):
|
| 164 |
+
if field in agent:
|
| 165 |
+
frame[field] = _counter(agent[field], field)
|
| 166 |
+
if 'metrics' in agent:
|
| 167 |
+
metrics = agent['metrics']
|
| 168 |
+
if (not isinstance(metrics, dict) or len(metrics) > 64
|
| 169 |
+
or any(not isinstance(key, str) or not 1 <= len(key) <= 96 for key in metrics)):
|
| 170 |
+
raise ReplayError('Native replay returned invalid metrics')
|
| 171 |
+
frame['metrics'] = {
|
| 172 |
+
key: None if value is None else _number(value, 'metric')
|
| 173 |
+
for key, value in metrics.items()
|
| 174 |
+
}
|
| 175 |
+
return frame
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def record_replay(track_bytes, network_bytes, *, binary, importer, scene_template, ticks=300):
|
| 179 |
+
"""Record one unchanged policy for 1–900 native 60 Hz ticks.
|
| 180 |
+
|
| 181 |
+
``binary``, ``importer`` and the Formula ``scene_template`` path must come
|
| 182 |
+
from trusted application configuration. Uploads only supply saved track
|
| 183 |
+
and game network bytes. Samples include tick 1, every sixth tick, and the
|
| 184 |
+
requested endpoint. The importer spawn is labelled separately because the
|
| 185 |
+
native reset settles the vehicle before the first measured tick.
|
| 186 |
+
"""
|
| 187 |
+
deadline = time.monotonic() + MAX_WALL_SECONDS
|
| 188 |
+
if type(ticks) is not int or not 1 <= ticks <= MAX_TICKS:
|
| 189 |
+
raise ValueError('Replay ticks must be an integer from 1 to 900')
|
| 190 |
+
track = track_input(track_bytes)
|
| 191 |
+
template = network_input(network_bytes)
|
| 192 |
+
binary = Path(binary).resolve()
|
| 193 |
+
importer = Path(importer).resolve()
|
| 194 |
+
scene_template = vehicle_scene_template(scene_template, template['Type'])
|
| 195 |
+
if not all(path.is_file() for path in (binary, importer, scene_template)):
|
| 196 |
+
raise ReplayError('Native replay tools or vehicle template unavailable')
|
| 197 |
+
|
| 198 |
+
network = template['NeuralNetwork']
|
| 199 |
+
parameters = []
|
| 200 |
+
for matrix, bias in zip(network['Weights'], network['Biases']):
|
| 201 |
+
parameters.extend(value for row in matrix for value in row)
|
| 202 |
+
parameters.extend(bias)
|
| 203 |
+
expected_parameters = _parameter_bytes(parameters)
|
| 204 |
+
sample_ticks = sorted({1, ticks, *range(SAMPLE_INTERVAL_TICKS, ticks + 1, SAMPLE_INTERVAL_TICKS)})
|
| 205 |
+
environment = native_environment()
|
| 206 |
+
|
| 207 |
+
with tempfile.TemporaryDirectory(prefix='tiga-replay-') as directory:
|
| 208 |
+
work = Path(directory)
|
| 209 |
+
environment.update(TMPDIR=directory)
|
| 210 |
+
|
| 211 |
+
def save(name, value):
|
| 212 |
+
path = work / name
|
| 213 |
+
path.write_text(json.dumps(value, allow_nan=False), encoding='utf-8')
|
| 214 |
+
return str(path)
|
| 215 |
+
|
| 216 |
+
saved_track = save('track.json', track)
|
| 217 |
+
scene_path = work / 'scene.json'
|
| 218 |
+
_run(
|
| 219 |
+
[str(importer), saved_track, 'Replay track', str(scene_template), str(scene_path)],
|
| 220 |
+
work=work, env=environment, deadline=deadline, stage='track import', timeout=10.0,
|
| 221 |
+
)
|
| 222 |
+
scene = _read_json(scene_path, MAX_SCENE_BYTES, 'scene')
|
| 223 |
+
spawn_position = _vector(scene.get('reset_position'), 'requested spawn position')
|
| 224 |
+
spawn_rotation = _number(scene.get('reset_rotation'), 'requested spawn rotation')
|
| 225 |
+
spawn_path = save('spawn.json', {'frames': [{'position': spawn_position, 'rotation': spawn_rotation}]})
|
| 226 |
+
names = [f'sensor-{index}' for index in range(network['Shape'][0])]
|
| 227 |
+
network_path = save('network.json', {
|
| 228 |
+
'shape': network['Shape'], 'weights': network['Weights'], 'biases': network['Biases'],
|
| 229 |
+
'inputs': names, 'outputs': [OUTPUT_NAMES[index] for index in template['Outputs']],
|
| 230 |
+
})
|
| 231 |
+
model_path = save('model.json', {'sensor_layout': {'names': names, 'sensors': template['Sensors']}})
|
| 232 |
+
dump_path = work / 'state.json'
|
| 233 |
+
command = [
|
| 234 |
+
str(binary), '--math-profile', 'proton', '--threads', '1', 'bench',
|
| 235 |
+
'--scene', str(scene_path), '--network', network_path, '--model', model_path,
|
| 236 |
+
'--spawn-trace', spawn_path, '--population', '1', '--warmup-ticks', '0',
|
| 237 |
+
'--seed', '0', '--spawn-index', '0', '--batch-count', '1', '--dump-state', str(dump_path),
|
| 238 |
+
]
|
| 239 |
+
frames = []
|
| 240 |
+
for tick in sample_ticks:
|
| 241 |
+
# A successful command without a new dump must not reuse a prior
|
| 242 |
+
# sample. A single file also bounds temporary storage independently
|
| 243 |
+
# of the recording length.
|
| 244 |
+
dump_path.unlink(missing_ok=True)
|
| 245 |
+
_run(command + ['--ticks', str(tick)], work=work, env=environment,
|
| 246 |
+
deadline=deadline, stage='evaluation')
|
| 247 |
+
dump = _read_json(dump_path, MAX_DUMP_BYTES, 'car state')
|
| 248 |
+
frames.append(_frame(dump, tick, expected_parameters))
|
| 249 |
+
_remaining(deadline)
|
| 250 |
+
|
| 251 |
+
result = {
|
| 252 |
+
'schema': 'ald.recorded-replay',
|
| 253 |
+
'version': 1,
|
| 254 |
+
'kind': 'fresh_cpu_evaluation',
|
| 255 |
+
'physics_hz': 60,
|
| 256 |
+
'sample_interval_ticks': SAMPLE_INTERVAL_TICKS,
|
| 257 |
+
'first_sample_tick': 1,
|
| 258 |
+
'control_hz': 60,
|
| 259 |
+
'track_sha256': hashlib.sha256(track_bytes).hexdigest(),
|
| 260 |
+
'network_sha256': hashlib.sha256(network_bytes).hexdigest(),
|
| 261 |
+
'vehicle_type': template['Type'],
|
| 262 |
+
'requested_ticks': ticks,
|
| 263 |
+
'execution_backend': 'cpu',
|
| 264 |
+
'math_profile': 'proton',
|
| 265 |
+
'collision_elimination': False,
|
| 266 |
+
'idle_elimination': False,
|
| 267 |
+
'seed': 0,
|
| 268 |
+
'population': 1,
|
| 269 |
+
'warmup_ticks': 0,
|
| 270 |
+
'policy_weights_verified': True,
|
| 271 |
+
'sampling_method': 'deterministic_cumulative_native_bench',
|
| 272 |
+
'position_units': 'game_world_units',
|
| 273 |
+
'velocity_units': 'game_world_units_per_second',
|
| 274 |
+
'rotation_units': 'radians',
|
| 275 |
+
'native_spawn_kind': 'requested_before_native_reset_settle',
|
| 276 |
+
'native_spawn': {'x': spawn_position[0], 'y': spawn_position[1], 'rotation': spawn_rotation},
|
| 277 |
+
'frames': frames,
|
| 278 |
+
}
|
| 279 |
+
native_track = scene.get('track')
|
| 280 |
+
if isinstance(native_track, dict):
|
| 281 |
+
for field in ('tile_size', 'tile_map_position'):
|
| 282 |
+
if field in native_track:
|
| 283 |
+
result[field] = _vector(native_track[field], field)
|
| 284 |
+
if len(json.dumps(result, allow_nan=False).encode('utf-8')) > MAX_RESPONSE_BYTES:
|
| 285 |
+
raise ReplayError('Native replay recording exceeds the output limit')
|
| 286 |
+
_remaining(deadline)
|
| 287 |
+
return result
|
server.py
CHANGED
|
@@ -4,6 +4,7 @@ import hashlib
|
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
import re
|
|
|
|
| 7 |
import threading
|
| 8 |
from fastapi import HTTPException, Request
|
| 9 |
from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
|
|
@@ -363,6 +364,45 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
|
|
| 363 |
except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
|
| 364 |
except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
|
| 365 |
finally: admission.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
return app
|
| 367 |
|
| 368 |
app=create_app()
|
|
|
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
import re
|
| 7 |
+
import subprocess
|
| 8 |
import threading
|
| 9 |
from fastapi import HTTPException, Request
|
| 10 |
from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
|
|
|
|
| 364 |
except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
|
| 365 |
except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
|
| 366 |
finally: admission.release()
|
| 367 |
+
@app.post('/api/replay')
|
| 368 |
+
async def recorded_replay(request:Request):
|
| 369 |
+
# Share CPU admission with training; recordings never allocate a GPU.
|
| 370 |
+
if not admission.acquire(blocking=False):
|
| 371 |
+
raise HTTPException(429,'Worker busy; finish the current run before recording a drive')
|
| 372 |
+
data=bytearray()
|
| 373 |
+
try:
|
| 374 |
+
async for chunk in request.stream():
|
| 375 |
+
if len(data)+len(chunk)>3*1024*1024:
|
| 376 |
+
raise HTTPException(413,'Replay input exceeds the upload limit')
|
| 377 |
+
data.extend(chunk)
|
| 378 |
+
try:
|
| 379 |
+
body=json.loads(data)
|
| 380 |
+
if not isinstance(body,dict) or set(body)!={'track','network','ticks'}:
|
| 381 |
+
raise ValueError()
|
| 382 |
+
if type(body['ticks']) is not int or not 1<=body['ticks']<=900:
|
| 383 |
+
raise ValueError()
|
| 384 |
+
track=base64.b64decode(body['track'],validate=True)
|
| 385 |
+
network=base64.b64decode(body['network'],validate=True)
|
| 386 |
+
if not track or not network or max(len(track),len(network))>1024*1024:
|
| 387 |
+
raise ValueError()
|
| 388 |
+
except Exception:
|
| 389 |
+
raise HTTPException(400,'Invalid replay input; select a track, a network and up to 15 seconds') from None
|
| 390 |
+
finally:
|
| 391 |
+
data.clear()
|
| 392 |
+
from replay import record_replay, ReplayError
|
| 393 |
+
try:
|
| 394 |
+
recording=await run_in_threadpool(record_replay,track,network,
|
| 395 |
+
binary=ROOT/'bin/altd-sim',importer=ROOT/'bin/extract_saved_track',
|
| 396 |
+
scene_template=ROOT/'assets/formula_template.json',ticks=body['ticks'])
|
| 397 |
+
recording['build']=build_identity()
|
| 398 |
+
return JSONResponse(recording,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
| 399 |
+
except (TimeoutError,subprocess.TimeoutExpired):
|
| 400 |
+
raise HTTPException(504,'Recording reached its time limit. Try a 5-second drive.') from None
|
| 401 |
+
except (ReplayError,OSError,ValueError,KeyError,TypeError,subprocess.CalledProcessError):
|
| 402 |
+
raise HTTPException(422,'The engine could not produce a complete recorded drive. No estimated playback was substituted.') from None
|
| 403 |
+
finally:
|
| 404 |
+
data.clear()
|
| 405 |
+
admission.release()
|
| 406 |
return app
|
| 407 |
|
| 408 |
app=create_app()
|
track-map.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
/* Browser-only preview
|
| 2 |
(function(root,factory){
|
| 3 |
if(typeof module==='object'&&module.exports)module.exports=factory(root);
|
| 4 |
else root.TrackMap=factory(root);
|
| 5 |
})(typeof window==='undefined'?this:window,function(root){
|
| 6 |
const COLOURS=['#607e96','#b89561','#b9d9ed'];
|
| 7 |
const NAMES=['Asphalt','Dirt','Ice'];
|
| 8 |
-
const VEHICLE_NAMES=['Formula','Rally','Snowmobile','Truck'];
|
| 9 |
const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
|
| 10 |
function vector(value){
|
| 11 |
const match=typeof value==='string'&&VECTOR.exec(value);
|
|
@@ -88,139 +87,6 @@
|
|
| 88 |
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
|
| 89 |
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
|
| 90 |
}
|
| 91 |
-
|
| 92 |
-
function cubicPoint(a,p1,p2,z,t){
|
| 93 |
-
const u=1-t,uu=u*u,tt=t*t;
|
| 94 |
-
return [
|
| 95 |
-
uu*u*a[0]+3*uu*t*p1[0]+3*u*tt*p2[0]+tt*t*z[0],
|
| 96 |
-
uu*u*a[1]+3*uu*t*p1[1]+3*u*tt*p2[1]+tt*t*z[1]
|
| 97 |
-
];
|
| 98 |
-
}
|
| 99 |
-
function cubicTangent(a,p1,p2,z,t){
|
| 100 |
-
const u=1-t;
|
| 101 |
-
const dx=3*u*u*(p1[0]-a[0])+6*u*t*(p2[0]-p1[0])+3*t*t*(z[0]-p2[0]);
|
| 102 |
-
const dy=3*u*u*(p1[1]-a[1])+6*u*t*(p2[1]-p1[1])+3*t*t*(z[1]-p2[1]);
|
| 103 |
-
const len=Math.hypot(dx,dy)||1;
|
| 104 |
-
return [dx/len,dy/len];
|
| 105 |
-
}
|
| 106 |
-
function oppositeSide(side){return [1,0,3,2][side];}
|
| 107 |
-
function neighborDelta(side){return [[0,-1],[0,1],[-1,0],[1,0]][side];}
|
| 108 |
-
|
| 109 |
-
function buildTrackGeometry(track,w,h){
|
| 110 |
-
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 111 |
-
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
|
| 112 |
-
const left=(w-columns*size)/2,top=(h-rows*size)/2;
|
| 113 |
-
const roadHalfWidth=Math.max(7,size*0.215);
|
| 114 |
-
const tileMap=new Map(track.tiles.map(t=>[`${t.x},${t.y}`,t]));
|
| 115 |
-
const orderedSegments=[];
|
| 116 |
-
const visited=new Set();
|
| 117 |
-
let curTile=tileMap.get(`${track.start[0]},${track.start[1]}`);
|
| 118 |
-
let entryConn=track.startConnection;
|
| 119 |
-
while(curTile&&orderedSegments.length<track.tiles.length){
|
| 120 |
-
const key=`${curTile.x},${curTile.y}`;
|
| 121 |
-
if(visited.has(key))break;
|
| 122 |
-
visited.add(key);
|
| 123 |
-
const [c0,c1]=curTile.connections;
|
| 124 |
-
const isFirst=c0.side===entryConn.side&&c0.index===entryConn.index;
|
| 125 |
-
const enter=isFirst?c0:c1;
|
| 126 |
-
const exit=isFirst?c1:c0;
|
| 127 |
-
orderedSegments.push({tile:curTile,enter,exit});
|
| 128 |
-
const [dx,dy]=neighborDelta(exit.side);
|
| 129 |
-
const nextTile=tileMap.get(`${curTile.x+dx},${curTile.y+dy}`);
|
| 130 |
-
if(!nextTile)break;
|
| 131 |
-
const nextSide=oppositeSide(exit.side);
|
| 132 |
-
const match=nextTile.connections.find(c=>c.side===nextSide&&c.index===exit.index);
|
| 133 |
-
if(!match)break;
|
| 134 |
-
curTile=nextTile;
|
| 135 |
-
entryConn=match;
|
| 136 |
-
}
|
| 137 |
-
for(const tile of track.tiles){
|
| 138 |
-
const key=`${tile.x},${tile.y}`;
|
| 139 |
-
if(!visited.has(key)&&tile.connections.length>=2){
|
| 140 |
-
orderedSegments.push({tile,enter:tile.connections[0],exit:tile.connections[1]});
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
const rawSamples=[];
|
| 144 |
-
const stepsPerTile=26;
|
| 145 |
-
for(let sIdx=0;sIdx<orderedSegments.length;sIdx++){
|
| 146 |
-
const {tile,enter,exit}=orderedSegments[sIdx];
|
| 147 |
-
const tx=left+(tile.x-b.minX)*size,ty=top+(tile.y-b.minY)*size;
|
| 148 |
-
const a=connectionPoint(enter,tx,ty,size),z=connectionPoint(exit,tx,ty,size);
|
| 149 |
-
const p1=connectionControl(enter,exit,a,z),p2=connectionControl(exit,entryConnForControl(exit,enter),z,a);
|
| 150 |
-
for(let i=0;i<(sIdx===orderedSegments.length-1?stepsPerTile+1:stepsPerTile);i++){
|
| 151 |
-
const t=i/stepsPerTile;
|
| 152 |
-
const [px,py]=cubicPoint(a,p1,p2,z,t);
|
| 153 |
-
const [ux,uy]=cubicTangent(a,p1,p2,z,t);
|
| 154 |
-
const nx=-uy,ny=ux;
|
| 155 |
-
rawSamples.push({x:px,y:py,tx:ux,ty:uy,nx,ny,surface:tile.surface,tileIndex:sIdx});
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
function entryConnForControl(_,enter){return enter;}
|
| 159 |
-
if(rawSamples.length<2){
|
| 160 |
-
const sx=left+(track.start[0]-b.minX)*size+size*0.5,sy=top+(track.start[1]-b.minY)*size+size*0.5;
|
| 161 |
-
rawSamples.push({x:sx,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0},{x:sx+10,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0});
|
| 162 |
-
}
|
| 163 |
-
let totalLength=0;
|
| 164 |
-
rawSamples[0].s=0;
|
| 165 |
-
for(let i=1;i<rawSamples.length;i++){
|
| 166 |
-
const ds=Math.hypot(rawSamples[i].x-rawSamples[i-1].x,rawSamples[i].y-rawSamples[i-1].y);
|
| 167 |
-
totalLength+=ds;
|
| 168 |
-
rawSamples[i].s=totalLength;
|
| 169 |
-
}
|
| 170 |
-
totalLength=Math.max(1,totalLength);
|
| 171 |
-
const n=rawSamples.length;
|
| 172 |
-
const closed=Math.hypot(rawSamples[n-1].x-rawSamples[0].x,rawSamples[n-1].y-rawSamples[0].y)<size*0.45;
|
| 173 |
-
for(let i=0;i<n;i++){
|
| 174 |
-
const prev=rawSamples[closed?(i-2+n)%n:Math.max(0,i-2)];
|
| 175 |
-
const next=rawSamples[closed?(i+2)%n:Math.min(n-1,i+2)];
|
| 176 |
-
const a1=Math.atan2(prev.ty,prev.tx),a2=Math.atan2(next.ty,next.tx);
|
| 177 |
-
let da=a2-a1;
|
| 178 |
-
while(da>Math.PI)da-=Math.PI*2;
|
| 179 |
-
while(da<-Math.PI)da+=Math.PI*2;
|
| 180 |
-
const ds=Math.max(2,Math.hypot(next.x-prev.x,next.y-prev.y));
|
| 181 |
-
const p=rawSamples[i];
|
| 182 |
-
p.curvature=da/ds;
|
| 183 |
-
p.heading=Math.atan2(p.ty,p.tx);
|
| 184 |
-
p.u=p.s/totalLength;
|
| 185 |
-
p.lx=p.x+p.nx*roadHalfWidth;
|
| 186 |
-
p.ly=p.y+p.ny*roadHalfWidth;
|
| 187 |
-
p.rx=p.x-p.nx*roadHalfWidth;
|
| 188 |
-
p.ry=p.y-p.ny*roadHalfWidth;
|
| 189 |
-
}
|
| 190 |
-
return {size,left,top,columns,rows,roadHalfWidth,samples:rawSamples,totalLength,closed};
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
function sampleAtProgress(geom,u){
|
| 194 |
-
const samples=geom.samples,n=samples.length;
|
| 195 |
-
if(!n)return {x:0,y:0,tx:1,ty:0,nx:0,ny:1,heading:0,curvature:0,surface:0,u:0};
|
| 196 |
-
let norm=geom.closed?((u%1)+1)%1:Math.max(0,Math.min(1,u));
|
| 197 |
-
const targetS=norm*geom.totalLength;
|
| 198 |
-
let lo=0,hi=n-1;
|
| 199 |
-
while(lo<hi){
|
| 200 |
-
const mid=(lo+hi)>>1;
|
| 201 |
-
if(samples[mid].s<targetS)lo=mid+1;
|
| 202 |
-
else hi=mid;
|
| 203 |
-
}
|
| 204 |
-
const i1=Math.min(n-1,lo),i0=Math.max(0,i1-1);
|
| 205 |
-
const s0=samples[i0],s1=samples[i1];
|
| 206 |
-
const span=Math.max(1e-5,s1.s-s0.s);
|
| 207 |
-
const t=Math.max(0,Math.min(1,(targetS-s0.s)/span));
|
| 208 |
-
const tx=s0.tx+(s1.tx-s0.tx)*t,ty=s0.ty+(s1.ty-s0.ty)*t;
|
| 209 |
-
const tLen=Math.hypot(tx,ty)||1;
|
| 210 |
-
const ux=tx/tLen,uy=ty/tLen;
|
| 211 |
-
return {
|
| 212 |
-
x:s0.x+(s1.x-s0.x)*t,
|
| 213 |
-
y:s0.y+(s1.y-s0.y)*t,
|
| 214 |
-
tx:ux,ty:uy,
|
| 215 |
-
nx:-uy,ny:ux,
|
| 216 |
-
heading:Math.atan2(uy,ux),
|
| 217 |
-
curvature:s0.curvature+(s1.curvature-s0.curvature)*t,
|
| 218 |
-
surface:t<0.5?s0.surface:s1.surface,
|
| 219 |
-
u:norm,
|
| 220 |
-
idx:i0
|
| 221 |
-
};
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
function render(canvas,track){
|
| 225 |
const context=canvas.getContext('2d');
|
| 226 |
if(!context)throw Error('Canvas preview unavailable');
|
|
@@ -230,18 +96,6 @@
|
|
| 230 |
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 231 |
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
|
| 232 |
const left=(w-columns*size)/2,top=(h-rows*size)/2;
|
| 233 |
-
|
| 234 |
-
// Subtle CAD coordinate grid in background
|
| 235 |
-
if(typeof context.save==='function'){
|
| 236 |
-
context.save();
|
| 237 |
-
context.strokeStyle='rgba(42,61,82,0.25)';
|
| 238 |
-
context.lineWidth=1;
|
| 239 |
-
const step=Math.max(24,Math.round(size*0.5));
|
| 240 |
-
for(let gx=left%step;gx<w;gx+=step){context.beginPath();context.moveTo(gx,0);context.lineTo(gx,h);context.stroke();}
|
| 241 |
-
for(let gy=top%step;gy<h;gy+=step){context.beginPath();context.moveTo(0,gy);context.lineTo(w,gy);context.stroke();}
|
| 242 |
-
context.restore();
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
for(const tile of track.tiles){
|
| 246 |
const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
|
| 247 |
context.fillStyle=COLOURS[tile.surface];
|
|
@@ -252,142 +106,26 @@
|
|
| 252 |
const [entry,exit]=tile.connections;
|
| 253 |
const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
|
| 254 |
const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
|
| 255 |
-
// Road bed & wall corridor
|
| 256 |
-
context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
|
| 257 |
-
context.strokeStyle='#cbd7d9';context.lineWidth=Math.max(8,size*.46);context.lineCap='round';context.stroke();
|
| 258 |
-
context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
|
| 259 |
-
context.strokeStyle=tile.surface===2?'#223c52':tile.surface===1?'#2e2720':'#162434';
|
| 260 |
-
context.lineWidth=Math.max(6,size*.40);context.lineCap='round';context.stroke();
|
| 261 |
-
// Original centerline
|
| 262 |
context.beginPath();context.moveTo(...a);
|
| 263 |
context.bezierCurveTo(...p1,...p2,...z);
|
| 264 |
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
|
| 265 |
context.lineCap='round';context.stroke();
|
| 266 |
}
|
| 267 |
}
|
| 268 |
-
|
| 269 |
-
// High-detail FIA curbs, sector gates, and start gantry when full 2D context is available
|
| 270 |
-
const geom=buildTrackGeometry(track,w,h);
|
| 271 |
-
track._geometry=geom;
|
| 272 |
-
if(typeof context.save==='function'&&geom.samples.length>8){
|
| 273 |
-
context.save();
|
| 274 |
-
const hw=geom.roadHalfWidth;
|
| 275 |
-
// FIA red/white apex curbs on sharp corners
|
| 276 |
-
for(let i=1;i<geom.samples.length-1;i+=2){
|
| 277 |
-
const s0=geom.samples[i-1],s1=geom.samples[i+1];
|
| 278 |
-
if(Math.abs(s0.curvature)*size>0.65){
|
| 279 |
-
const side=s0.curvature>0?-1:1;
|
| 280 |
-
const ox0=s0.x+s0.nx*side*(hw*0.92),oy0=s0.y+s0.ny*side*(hw*0.92);
|
| 281 |
-
const ox1=s1.x+s1.nx*side*(hw*0.92),oy1=s1.y+s1.ny*side*(hw*0.92);
|
| 282 |
-
context.beginPath();context.moveTo(ox0,oy0);context.lineTo(ox1,oy1);
|
| 283 |
-
context.strokeStyle=((i>>1)&1)?'#e05252':'#f0f6fc';
|
| 284 |
-
context.lineWidth=Math.max(2,size*0.045);context.lineCap='butt';context.stroke();
|
| 285 |
-
}
|
| 286 |
-
}
|
| 287 |
-
// Sector split markers (S1 / S2 / S3)
|
| 288 |
-
for(const [frac,label] of [[0.333,'S2'],[0.667,'S3']]){
|
| 289 |
-
const sp=sampleAtProgress(geom,frac);
|
| 290 |
-
context.beginPath();
|
| 291 |
-
context.moveTo(sp.x+sp.nx*hw*0.95,sp.y+sp.ny*hw*0.95);
|
| 292 |
-
context.lineTo(sp.x-sp.nx*hw*0.95,sp.y-sp.ny*hw*0.95);
|
| 293 |
-
context.strokeStyle='rgba(88,166,255,0.55)';context.lineWidth=1.5;context.stroke();
|
| 294 |
-
if(typeof context.fillText==='function'){
|
| 295 |
-
context.fillStyle='#8ec2ff';context.font='700 10px system-ui,sans-serif';
|
| 296 |
-
context.fillText(label,sp.x+sp.nx*(hw+8)-6,sp.y+sp.ny*(hw+8)+3);
|
| 297 |
-
}
|
| 298 |
-
}
|
| 299 |
-
// Checkered Start/Finish line at u=0
|
| 300 |
-
const st=geom.samples[0];
|
| 301 |
-
if(st){
|
| 302 |
-
const checkers=6;
|
| 303 |
-
for(let c=0;c<checkers;c++){
|
| 304 |
-
const f0=-1+2*c/checkers,f1=-1+2*(c+1)/checkers;
|
| 305 |
-
context.beginPath();
|
| 306 |
-
context.moveTo(st.x+st.nx*hw*f0,st.y+st.ny*hw*f0);
|
| 307 |
-
context.lineTo(st.x+st.nx*hw*f1,st.y+st.ny*hw*f1);
|
| 308 |
-
context.strokeStyle=(c&1)?'#0c1926':'#f0f6fc';
|
| 309 |
-
context.lineWidth=Math.max(3,size*0.055);context.lineCap='butt';context.stroke();
|
| 310 |
-
}
|
| 311 |
-
}
|
| 312 |
-
context.restore();
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
|
| 316 |
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
|
| 317 |
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
|
| 318 |
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
|
| 319 |
}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
function evaluateNeuralNetwork(nn,inputs){
|
| 323 |
-
if(!nn||!Array.isArray(nn.Weights)||!Array.isArray(nn.Biases))return null;
|
| 324 |
-
let act=inputs.slice(0,nn.Shape?.[0]||inputs.length);
|
| 325 |
-
while(act.length<(nn.Shape?.[0]||0))act.push(0);
|
| 326 |
-
const layerActivations=[act];
|
| 327 |
-
for(let l=0;l<nn.Weights.length;l++){
|
| 328 |
-
const W=nn.Weights[l],B=nn.Biases[l];
|
| 329 |
-
const next=new Array(B.length);
|
| 330 |
-
for(let j=0;j<B.length;j++){
|
| 331 |
-
let sum=B[j]||0;
|
| 332 |
-
for(let i=0;i<act.length;i++)sum+=(act[i]||0)*(W[i]?.[j]||0);
|
| 333 |
-
next[j]=Math.tanh(sum);
|
| 334 |
-
}
|
| 335 |
-
act=next;
|
| 336 |
-
layerActivations.push(act);
|
| 337 |
-
}
|
| 338 |
-
return {outputs:act,layerActivations};
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
// Analytic raycast against track corridor walls near car position
|
| 342 |
-
function castRayAgainstCorridor(geom,carX,carY,rayAngle,maxDistPx,centerIdx){
|
| 343 |
-
const samples=geom.samples,n=samples.length;
|
| 344 |
-
if(n<4)return {dist:maxDistPx,frac:1,hx:carX+Math.cos(rayAngle)*maxDistPx,hy:carY+Math.sin(rayAngle)*maxDistPx,hit:false};
|
| 345 |
-
const dx=Math.cos(rayAngle),dy=Math.sin(rayAngle);
|
| 346 |
-
let bestT=maxDistPx,hit=false;
|
| 347 |
-
const windowSize=Math.min(n-1,56);
|
| 348 |
-
const startIdx=geom.closed?(centerIdx-12+n)%n:Math.max(0,centerIdx-12);
|
| 349 |
-
for(let k=0;k<windowSize;k++){
|
| 350 |
-
const i0=geom.closed?(startIdx+k)%n:Math.min(n-2,startIdx+k);
|
| 351 |
-
const i1=geom.closed?(i0+1)%n:i0+1;
|
| 352 |
-
const s0=samples[i0],s1=samples[i1];
|
| 353 |
-
for(const [ax,ay,bx,by] of [[s0.lx,s0.ly,s1.lx,s1.ly],[s0.rx,s0.ry,s1.rx,s1.ry]]){
|
| 354 |
-
const sx=bx-ax,sy=by-ay;
|
| 355 |
-
const det=dx*sy-dy*sx;
|
| 356 |
-
if(Math.abs(det)<1e-6)continue;
|
| 357 |
-
const qx=ax-carX,qy=ay-carY;
|
| 358 |
-
const t=(qx*sy-qy*sx)/det;
|
| 359 |
-
const u=(qx*dy-qy*dx)/det;
|
| 360 |
-
if(t>2&&t<bestT&&u>=0&&u<=1){
|
| 361 |
-
bestT=t;hit=true;
|
| 362 |
-
}
|
| 363 |
-
}
|
| 364 |
-
}
|
| 365 |
-
return {
|
| 366 |
-
dist:bestT,
|
| 367 |
-
frac:Math.max(0,Math.min(1,bestT/maxDistPx)),
|
| 368 |
-
hx:carX+dx*bestT,
|
| 369 |
-
hy:carY+dy*bestT,
|
| 370 |
-
hit
|
| 371 |
-
};
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
// Draw detailed 2D vehicle chassis with steerable front wheels, white shader outline, brake lights & boost flame
|
| 375 |
-
function drawVehicle2D(ctx,x,y,heading,slipAngle,steerAngle,vehicleType,color,scale,showOutline,braking,boosting,active){
|
| 376 |
ctx.save();
|
| 377 |
ctx.translate(x,y);
|
| 378 |
-
ctx.rotate(heading
|
| 379 |
ctx.scale(scale,scale);
|
| 380 |
if(!active)ctx.globalAlpha=0.55;
|
| 381 |
|
| 382 |
-
const wheelSteer=
|
| 383 |
-
// Boost exhaust flame
|
| 384 |
-
if(boosting&&active){
|
| 385 |
-
ctx.fillStyle='#ff9a3c';
|
| 386 |
-
ctx.beginPath();ctx.moveTo(-14,-3);ctx.lineTo(-24,0);ctx.lineTo(-14,3);ctx.closePath();ctx.fill();
|
| 387 |
-
ctx.fillStyle='#ffd073';
|
| 388 |
-
ctx.beginPath();ctx.moveTo(-14,-1.6);ctx.lineTo(-19.5,0);ctx.lineTo(-14,1.6);ctx.closePath();ctx.fill();
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
// Draw wheels / skis
|
| 392 |
ctx.fillStyle='#101820';
|
| 393 |
ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
|
|
@@ -461,16 +199,7 @@
|
|
| 461 |
ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
|
| 462 |
}
|
| 463 |
|
| 464 |
-
//
|
| 465 |
-
if(braking&&active){
|
| 466 |
-
ctx.fillStyle='#ff4d4d';
|
| 467 |
-
ctx.shadowColor='#ff4d4d';ctx.shadowBlur=6;
|
| 468 |
-
ctx.fillRect(-13.8,-5.2,1.8,3.2);
|
| 469 |
-
ctx.fillRect(-13.8,2.0,1.8,3.2);
|
| 470 |
-
ctx.shadowBlur=0;
|
| 471 |
-
}
|
| 472 |
-
|
| 473 |
-
// Eliminated / crashed X badge over car
|
| 474 |
if(!active){
|
| 475 |
ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
|
| 476 |
ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
|
|
@@ -478,721 +207,220 @@
|
|
| 478 |
ctx.restore();
|
| 479 |
}
|
| 480 |
|
| 481 |
-
function buildCandidateProfile(spec,geom,maxTicks){
|
| 482 |
-
const ticks=Math.max(60,Math.min(18000,spec.ticksElapsed||maxTicks||900));
|
| 483 |
-
const lapSec=spec.bestLapSeconds&&spec.bestLapSeconds>0?spec.bestLapSeconds:null;
|
| 484 |
-
const laps=spec.forwardLaps||0;
|
| 485 |
-
const raw=spec.rawMetrics||{};
|
| 486 |
-
const driftFactor=raw.total_drifting_amount!=null?Math.min(1.4,Math.max(0.15,raw.total_drifting_amount/Math.max(1,ticks*0.25))):0.35;
|
| 487 |
-
const apexEff=raw.total_racing_line_efficiency!=null?Math.min(1,Math.max(0.2,raw.total_racing_line_efficiency/Math.max(1,ticks*0.5))):0.68;
|
| 488 |
-
const contacts=spec.physicalContacts??0;
|
| 489 |
-
const active=spec.active!==false;
|
| 490 |
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
const lapTicks=Math.max(90,lapSec*60);
|
| 495 |
-
totalProgress=Math.max(1.05,ticks/lapTicks);
|
| 496 |
-
}else if(spec.totalDistance&&spec.totalDistance>0){
|
| 497 |
-
totalProgress=Math.max(0.18,Math.min(3.5,spec.totalDistance/(geom.totalLength*3.1)));
|
| 498 |
-
}else if(laps>0){
|
| 499 |
-
totalProgress=Math.max(laps+0.15,1.1);
|
| 500 |
-
}
|
| 501 |
-
const phaseSeed=(spec.carIndex||0)*0.6180339887;
|
| 502 |
-
return {
|
| 503 |
-
id:spec.id||'primary',
|
| 504 |
-
label:spec.label||'Active Driver',
|
| 505 |
-
role:spec.role||'primary',
|
| 506 |
-
color:spec.color||'#62dfb1',
|
| 507 |
-
vehicleType:Number.isInteger(spec.vehicleType)?spec.vehicleType:0,
|
| 508 |
-
network:spec.network||null,
|
| 509 |
-
ticksElapsed:ticks,
|
| 510 |
-
bestLapSeconds:lapSec,
|
| 511 |
-
forwardLaps:laps,
|
| 512 |
-
physicalContacts:contacts,
|
| 513 |
-
active,
|
| 514 |
-
driftFactor,
|
| 515 |
-
apexEff,
|
| 516 |
-
totalProgress,
|
| 517 |
-
phaseSeed
|
| 518 |
-
};
|
| 519 |
}
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
const
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
const
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
heading:sCenter.heading,
|
| 556 |
-
curvature:sCenter.curvature,
|
| 557 |
-
surface:sCenter.surface,
|
| 558 |
-
u:sCenter.u,
|
| 559 |
-
lap:Math.floor(baseU)+1,
|
| 560 |
-
idx:sCenter.idx||0,
|
| 561 |
-
alive,
|
| 562 |
-
speedNorm,
|
| 563 |
-
speedKmh,
|
| 564 |
-
slipAngle,
|
| 565 |
-
braking,
|
| 566 |
-
boosting:boostCmd>0.5,
|
| 567 |
-
controls:{steer:steerCmd,throttle:throttleCmd,brake:brakeCmd,hbrake:hbrakeCmd,boost:boostCmd}
|
| 568 |
-
};
|
| 569 |
}
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
function mount({document,fetch,picker}){
|
| 572 |
-
const canvas=document.getElementById('trackMap');
|
| 573 |
-
|
| 574 |
-
const
|
| 575 |
-
|
| 576 |
-
let
|
| 577 |
-
let
|
| 578 |
-
let
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
const
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
render(off,currentTrack);
|
| 597 |
-
staticLayer=off;
|
| 598 |
}else{
|
| 599 |
-
|
| 600 |
-
|
| 601 |
}
|
| 602 |
}
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
const geom=currentTrack._geometry;
|
| 607 |
-
const reqTicks=analysisState?.summary?.conditions?.requested_ticks||receiptState?.report?.requested_ticks||Number(document.getElementById('training_ticks')?.value)||900;
|
| 608 |
-
maxTicks=Math.max(60,Math.min(18000,reqTicks));
|
| 609 |
-
const scrub=document.getElementById('replay_scrub');
|
| 610 |
-
if(scrub)scrub.max=String(maxTicks);
|
| 611 |
-
if(currentTick>maxTicks)currentTick=0;
|
| 612 |
-
|
| 613 |
-
const vType=analysisState?.summary?.conditions?.vehicle_type??receiptState?.report?.vehicle_type??stagedNet?.Type??Number(document.getElementById('starter_vehicle')?.value||0);
|
| 614 |
-
profiles=[];
|
| 615 |
-
swarmProfiles=[];
|
| 616 |
-
|
| 617 |
-
if(analysisState?.summary?.mode==='train'&&analysisState.summary.winners){
|
| 618 |
-
const roleMeta=[
|
| 619 |
-
['fitness','Fitness Leader','#62dfb1'],
|
| 620 |
-
['fastest_lap','Fastest Lap','#ffd073'],
|
| 621 |
-
['clean_laps','Clean Laps','#58a6ff']
|
| 622 |
-
];
|
| 623 |
-
for(const [role,label,color] of roleMeta){
|
| 624 |
-
const w=analysisState.summary.winners[role];
|
| 625 |
-
if(w&&w.status==='available'){
|
| 626 |
-
const row=analysisState.descriptorRows?.find(r=>r.candidate_id===w.candidate_id);
|
| 627 |
-
profiles.push(buildCandidateProfile({
|
| 628 |
-
id:w.candidate_id,
|
| 629 |
-
role,
|
| 630 |
-
label:`${label} (${w.candidate_id})`,
|
| 631 |
-
color,
|
| 632 |
-
vehicleType:vType,
|
| 633 |
-
network:analysisState.winnerNetworks?.[role]?.decoded||stagedNet,
|
| 634 |
-
ticksElapsed:row?.ticks_elapsed||maxTicks,
|
| 635 |
-
bestLapSeconds:w.best_lap_seconds,
|
| 636 |
-
forwardLaps:w.forward_laps,
|
| 637 |
-
physicalContacts:w.physical_contacts,
|
| 638 |
-
totalDistance:w.total_actual_distance,
|
| 639 |
-
active:row?.objectives?.active??true,
|
| 640 |
-
rawMetrics:row?.raw_metrics,
|
| 641 |
-
carIndex:w.car
|
| 642 |
-
},geom,maxTicks));
|
| 643 |
-
}
|
| 644 |
-
}
|
| 645 |
-
// Build population swarm for the selected generation in descriptor_generation (up to 20 cars)
|
| 646 |
-
if(Array.isArray(analysisState.descriptorRows)&&analysisState.descriptorRows.length){
|
| 647 |
-
const targetGen=Number(document.getElementById('descriptor_generation')?.value)||analysisState.summary.observations?.generations||1;
|
| 648 |
-
const genRows=analysisState.descriptorRows.filter(r=>r.generation===targetGen).slice(0,20);
|
| 649 |
-
const palette=['#62dfb1','#ffd073','#58a6ff','#c792ea','#ff8b7b','#7ce4c3'];
|
| 650 |
-
genRows.forEach((row,idx)=>{
|
| 651 |
-
swarmProfiles.push(buildCandidateProfile({
|
| 652 |
-
id:row.candidate_id,
|
| 653 |
-
role:'swarm',
|
| 654 |
-
label:`Gen ${row.generation} · Car #${row.car}`,
|
| 655 |
-
color:palette[idx%palette.length],
|
| 656 |
-
vehicleType:vType,
|
| 657 |
-
network:stagedNet,
|
| 658 |
-
ticksElapsed:row.ticks_elapsed||maxTicks,
|
| 659 |
-
bestLapSeconds:row.objectives?.best_lap_seconds,
|
| 660 |
-
forwardLaps:row.objectives?.forward_laps,
|
| 661 |
-
physicalContacts:row.objectives?.physical_contacts,
|
| 662 |
-
totalDistance:row.objectives?.total_actual_distance,
|
| 663 |
-
active:row.objectives?.active,
|
| 664 |
-
rawMetrics:row.raw_metrics,
|
| 665 |
-
carIndex:row.car
|
| 666 |
-
},geom,maxTicks));
|
| 667 |
-
});
|
| 668 |
-
}
|
| 669 |
-
}else if(analysisState?.summary?.mode==='evaluate'&&analysisState.evaluation){
|
| 670 |
-
const m=analysisState.evaluation.measurements;
|
| 671 |
-
profiles.push(buildCandidateProfile({
|
| 672 |
-
id:m.candidate_id||'eval',
|
| 673 |
-
role:'evaluate',
|
| 674 |
-
label:'Frozen Evaluation Policy',
|
| 675 |
-
color:'#62dfb1',
|
| 676 |
-
vehicleType:vType,
|
| 677 |
-
network:stagedNet,
|
| 678 |
-
ticksElapsed:m.ticks_elapsed||maxTicks,
|
| 679 |
-
bestLapSeconds:m.objectives?.best_lap_seconds,
|
| 680 |
-
forwardLaps:m.objectives?.forward_laps,
|
| 681 |
-
physicalContacts:m.objectives?.physical_contacts,
|
| 682 |
-
totalDistance:m.objectives?.total_actual_distance,
|
| 683 |
-
active:m.objectives?.active,
|
| 684 |
-
rawMetrics:m.raw_metrics,
|
| 685 |
-
carIndex:0
|
| 686 |
-
},geom,maxTicks));
|
| 687 |
-
}else if(receiptState?.report){
|
| 688 |
-
const r=receiptState.report;
|
| 689 |
-
profiles.push(buildCandidateProfile({
|
| 690 |
-
id:'receipt',
|
| 691 |
-
role:'fitness',
|
| 692 |
-
label:`Run Candidate (${VEHICLE_NAMES[vType]||'Formula'})`,
|
| 693 |
-
color:'#62dfb1',
|
| 694 |
-
vehicleType:vType,
|
| 695 |
-
network:stagedNet,
|
| 696 |
-
ticksElapsed:r.simulated_ticks||maxTicks,
|
| 697 |
-
bestLapSeconds:r.best_lap_seconds,
|
| 698 |
-
forwardLaps:r.best_lap_seconds?1:0,
|
| 699 |
-
physicalContacts:0,
|
| 700 |
-
totalDistance:null,
|
| 701 |
-
active:true,
|
| 702 |
-
carIndex:0
|
| 703 |
-
},geom,maxTicks));
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
if(!profiles.length){
|
| 707 |
-
profiles.push(buildCandidateProfile({
|
| 708 |
-
id:'staged',
|
| 709 |
-
role:'staged',
|
| 710 |
-
label:stagedNet?.Name?`Staged · ${stagedNet.Name}`:`Preview · ${VEHICLE_NAMES[vType]||'Formula'} Starter`,
|
| 711 |
-
color:'#62dfb1',
|
| 712 |
-
vehicleType:vType,
|
| 713 |
-
network:stagedNet,
|
| 714 |
-
ticksElapsed:maxTicks,
|
| 715 |
-
bestLapSeconds:11.8,
|
| 716 |
-
forwardLaps:1,
|
| 717 |
-
physicalContacts:0,
|
| 718 |
-
active:true,
|
| 719 |
-
carIndex:0
|
| 720 |
-
},geom,maxTicks));
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
// Precompute speed-heatmap ribbon, skid segments, and wall contact markers for the lead profile (zero per-frame path math)
|
| 724 |
-
const lead=getActiveProfiles()[0]||profiles[0];
|
| 725 |
-
speedRibbon=[];
|
| 726 |
-
skidSegments=[];
|
| 727 |
-
contactMarkers=[];
|
| 728 |
-
const ribbonSteps=140;
|
| 729 |
-
for(let i=0;i<=ribbonSteps;i++){
|
| 730 |
-
const st=evaluateCarAtTick(lead,geom,(i/ribbonSteps)*lead.ticksElapsed,maxTicks);
|
| 731 |
-
const col=st.braking?'#ff6b6b':st.speedNorm>0.76?'#62dfb1':'#ffd073';
|
| 732 |
-
speedRibbon.push({x:st.x,y:st.y,color:col,slip:st.slipAngle,heading:st.heading});
|
| 733 |
-
if(Math.abs(st.slipAngle)>0.11&&i>0){
|
| 734 |
-
const prev=speedRibbon[i-1];
|
| 735 |
-
skidSegments.push({x0:prev.x,y0:prev.y,x1:st.x,y1:st.y,nx:-Math.sin(st.heading),ny:Math.cos(st.heading)});
|
| 736 |
-
}
|
| 737 |
-
}
|
| 738 |
-
const totalContacts=lead.physicalContacts||(lead.active?0:1);
|
| 739 |
-
if(totalContacts>0){
|
| 740 |
-
// Place contact markers at sharpest corners or terminal point
|
| 741 |
-
const sortedCorners=geom.samples.map((s,idx)=>({s,idx,k:Math.abs(s.curvature)})).sort((a,b)=>b.k-a.k);
|
| 742 |
-
for(let c=0;c<Math.min(6,totalContacts);c++){
|
| 743 |
-
const pick=sortedCorners[c*4]||sortedCorners[0];
|
| 744 |
-
if(pick){
|
| 745 |
-
const side=pick.s.curvature>0?1:-1;
|
| 746 |
-
contactMarkers.push({
|
| 747 |
-
x:pick.s.x+pick.s.nx*side*geom.roadHalfWidth*0.92,
|
| 748 |
-
y:pick.s.y+pick.s.ny*side*geom.roadHalfWidth*0.92,
|
| 749 |
-
label:`Contact #${c+1}`
|
| 750 |
-
});
|
| 751 |
-
}
|
| 752 |
-
}
|
| 753 |
-
}
|
| 754 |
-
if(!lead.active){
|
| 755 |
-
const term=evaluateCarAtTick(lead,geom,lead.ticksElapsed,maxTicks);
|
| 756 |
-
contactMarkers.push({x:term.x,y:term.y,label:`Eliminated @ ${lead.ticksElapsed}t`,terminal:true});
|
| 757 |
-
}
|
| 758 |
-
}
|
| 759 |
-
|
| 760 |
-
function getActiveProfiles(){
|
| 761 |
-
if(focusMode==='swarm'&&swarmProfiles.length)return swarmProfiles;
|
| 762 |
-
if(focusMode==='fitness'||focusMode==='fastest_lap'||focusMode==='clean_laps'){
|
| 763 |
-
const found=profiles.find(p=>p.role===focusMode);
|
| 764 |
-
if(found)return [found];
|
| 765 |
-
}
|
| 766 |
-
return profiles;
|
| 767 |
}
|
| 768 |
-
|
| 769 |
-
function
|
| 770 |
-
if(!currentTrack
|
| 771 |
-
const ctx=canvas.getContext('2d');
|
| 772 |
-
|
| 773 |
-
const
|
| 774 |
-
|
| 775 |
-
const activeList=getActiveProfiles();
|
| 776 |
-
const lead=activeList[0]||profiles[0];
|
| 777 |
-
if(!lead)return;
|
| 778 |
-
const leadState=evaluateCarAtTick(lead,geom,currentTick,maxTicks);
|
| 779 |
-
|
| 780 |
-
ctx.clearRect(0,0,w,h);
|
| 781 |
-
ctx.fillStyle='#0c1926';
|
| 782 |
-
ctx.fillRect(0,0,w,h);
|
| 783 |
-
|
| 784 |
ctx.save();
|
| 785 |
-
if(cameraMode==='follow'){
|
| 786 |
-
|
| 787 |
-
const lookX=leadState.x+Math.cos(leadState.heading)*28;
|
| 788 |
-
const lookY=leadState.y+Math.sin(leadState.heading)*28;
|
| 789 |
-
ctx.translate(w*0.5,h*0.5);
|
| 790 |
-
ctx.scale(zoom,zoom);
|
| 791 |
-
ctx.translate(-lookX,-lookY);
|
| 792 |
-
}
|
| 793 |
-
|
| 794 |
-
if(staticLayer)ctx.drawImage(staticLayer,0,0);
|
| 795 |
-
else render(canvas,currentTrack);
|
| 796 |
-
|
| 797 |
-
// 1. Tire skid marks in high-slip drift zones
|
| 798 |
-
if(layers.skids&&skidSegments.length){
|
| 799 |
-
ctx.save();
|
| 800 |
-
ctx.strokeStyle='rgba(12,18,26,0.58)';
|
| 801 |
-
ctx.lineWidth=Math.max(1.4,geom.size*0.022);
|
| 802 |
-
const trackHalf=Math.max(3,geom.size*0.055);
|
| 803 |
-
for(const seg of skidSegments){
|
| 804 |
-
for(const s of [-1,1]){
|
| 805 |
-
ctx.beginPath();
|
| 806 |
-
ctx.moveTo(seg.x0+seg.nx*s*trackHalf,seg.y0+seg.ny*s*trackHalf);
|
| 807 |
-
ctx.lineTo(seg.x1+seg.nx*s*trackHalf,seg.y1+seg.ny*s*trackHalf);
|
| 808 |
-
ctx.stroke();
|
| 809 |
-
}
|
| 810 |
-
}
|
| 811 |
-
ctx.restore();
|
| 812 |
-
}
|
| 813 |
-
|
| 814 |
-
// 2. Speed-colored racing line heatmap
|
| 815 |
-
if(layers.line&&speedRibbon.length>1){
|
| 816 |
-
ctx.save();
|
| 817 |
-
ctx.lineWidth=Math.max(2.6,geom.size*0.048);
|
| 818 |
-
ctx.lineCap='round';
|
| 819 |
-
ctx.globalAlpha=0.82;
|
| 820 |
-
for(let i=1;i<speedRibbon.length;i++){
|
| 821 |
-
const p0=speedRibbon[i-1],p1=speedRibbon[i];
|
| 822 |
-
ctx.beginPath();ctx.moveTo(p0.x,p0.y);ctx.lineTo(p1.x,p1.y);
|
| 823 |
-
ctx.strokeStyle=p1.color;ctx.stroke();
|
| 824 |
-
}
|
| 825 |
-
ctx.restore();
|
| 826 |
}
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
ctx.save();
|
| 831 |
-
for(const m of contactMarkers){
|
| 832 |
-
ctx.beginPath();ctx.arc(m.x,m.y,m.terminal?9:6.5,0,Math.PI*2);
|
| 833 |
-
ctx.fillStyle=m.terminal?'rgba(255,107,107,0.28)':'rgba(255,208,115,0.24)';
|
| 834 |
-
ctx.fill();
|
| 835 |
-
ctx.strokeStyle=m.terminal?'#ff6b6b':'#ffd073';
|
| 836 |
-
ctx.lineWidth=2;
|
| 837 |
ctx.beginPath();
|
| 838 |
-
|
| 839 |
-
ctx.
|
| 840 |
-
ctx.stroke();
|
| 841 |
-
}
|
| 842 |
-
ctx.restore();
|
| 843 |
-
}
|
| 844 |
-
|
| 845 |
-
// 4. Raycast Sensor Fan on the lead vehicle (using actual .evolution Sensors or default 7-ray fan)
|
| 846 |
-
const netObj=lead.network||stagedNet;
|
| 847 |
-
const sensorDefs=Array.isArray(netObj?.Sensors)&&netObj.Sensors.some(s=>s.$type==='raycast')
|
| 848 |
-
?netObj.Sensors
|
| 849 |
-
:[
|
| 850 |
-
{$type:'speed'},
|
| 851 |
-
{$type:'raycast',Degrees:-75,Length:750},
|
| 852 |
-
{$type:'raycast',Degrees:-42,Length:850},
|
| 853 |
-
{$type:'raycast',Degrees:-18,Length:950},
|
| 854 |
-
{$type:'raycast',Degrees:0,Length:1100},
|
| 855 |
-
{$type:'raycast',Degrees:18,Length:950},
|
| 856 |
-
{$type:'raycast',Degrees:42,Length:850},
|
| 857 |
-
{$type:'raycast',Degrees:75,Length:750}
|
| 858 |
-
];
|
| 859 |
-
|
| 860 |
-
const sensorInputs=[];
|
| 861 |
-
const pxPerSimUnit=Math.max(0.08,geom.size/768);
|
| 862 |
-
for(const s of sensorDefs){
|
| 863 |
-
if(s.$type==='raycast'){
|
| 864 |
-
const rad=leadState.heading+(s.Degrees||0)*Math.PI/180;
|
| 865 |
-
const maxPx=Math.max(28,Math.min(260,(s.Length||800)*pxPerSimUnit*1.6));
|
| 866 |
-
const hit=castRayAgainstCorridor(geom,leadState.x,leadState.y,rad,maxPx,leadState.idx);
|
| 867 |
-
sensorInputs.push(hit.frac*2-1);
|
| 868 |
-
if(layers.rays&&leadState.alive){
|
| 869 |
-
ctx.save();
|
| 870 |
-
const beamCol=hit.frac<0.32?'#ff6b6b':hit.frac<0.62?'#ffd073':'#62dfb1';
|
| 871 |
-
ctx.strokeStyle=beamCol;
|
| 872 |
-
ctx.globalAlpha=0.52;
|
| 873 |
-
ctx.lineWidth=1.35;
|
| 874 |
-
ctx.beginPath();ctx.moveTo(leadState.x,leadState.y);ctx.lineTo(hit.hx,hit.hy);ctx.stroke();
|
| 875 |
-
ctx.globalAlpha=0.95;
|
| 876 |
-
ctx.fillStyle=beamCol;
|
| 877 |
-
ctx.beginPath();ctx.arc(hit.hx,hit.hy,2.6,0,Math.PI*2);ctx.fill();
|
| 878 |
-
ctx.restore();
|
| 879 |
-
}
|
| 880 |
-
}else if(s.$type==='speed'){
|
| 881 |
-
sensorInputs.push(leadState.speedNorm);
|
| 882 |
-
}else if(s.$type==='velocitySide'||s.$type==='angularVelocity'){
|
| 883 |
-
sensorInputs.push(leadState.slipAngle*2);
|
| 884 |
-
}else if(s.$type==='trackCurvature'){
|
| 885 |
-
sensorInputs.push(Math.max(-1,Math.min(1,leadState.curvature*geom.size)));
|
| 886 |
-
}else{
|
| 887 |
-
sensorInputs.push(leadState.speedNorm*0.7);
|
| 888 |
-
}
|
| 889 |
-
}
|
| 890 |
-
|
| 891 |
-
// Evaluate actual neural network forward pass if available (<0.01ms)
|
| 892 |
-
const nnEval=netObj?.NeuralNetwork?evaluateNeuralNetwork(netObj.NeuralNetwork,sensorInputs):null;
|
| 893 |
-
if(nnEval&&Array.isArray(netObj?.Outputs)&&leadState.alive){
|
| 894 |
-
const outIds=netObj.Outputs;
|
| 895 |
-
const idxSteer=outIds.indexOf(0),idxAccel=outIds.indexOf(1),idxHbk=outIds.indexOf(2),idxBrk=outIds.indexOf(3),idxBst=outIds.indexOf(4);
|
| 896 |
-
if(idxSteer>=0)leadState.controls.steer=leadState.controls.steer*0.55+nnEval.outputs[idxSteer]*0.45;
|
| 897 |
-
if(idxAccel>=0)leadState.controls.throttle=Math.max(0,(nnEval.outputs[idxAccel]+1)*0.5);
|
| 898 |
-
if(idxBrk>=0)leadState.controls.brake=Math.max(0,nnEval.outputs[idxBrk]);
|
| 899 |
-
if(idxHbk>=0)leadState.controls.hbrake=Math.max(0,nnEval.outputs[idxHbk]);
|
| 900 |
-
if(idxBst>=0)leadState.controls.boost=Math.max(0,nnEval.outputs[idxBst]);
|
| 901 |
-
}
|
| 902 |
-
|
| 903 |
-
// 5. Draw all active ghost/swarm cars back-to-front so lead car is on top
|
| 904 |
-
const carScale=Math.max(0.68,Math.min(1.38,geom.size/64));
|
| 905 |
-
for(let i=activeList.length-1;i>=0;i--){
|
| 906 |
-
const prof=activeList[i];
|
| 907 |
-
const st=i===0?leadState:evaluateCarAtTick(prof,geom,currentTick,maxTicks);
|
| 908 |
-
drawVehicle2D(
|
| 909 |
-
ctx,st.x,st.y,st.heading,st.slipAngle,st.controls.steer,
|
| 910 |
-
prof.vehicleType,prof.color,carScale*(i===0?1.06:0.92),
|
| 911 |
-
layers.outline,st.braking,st.boosting,st.alive
|
| 912 |
-
);
|
| 913 |
-
// Floating badge above ghost cars in multi-car race
|
| 914 |
-
if(activeList.length>1&&activeList.length<=4&&typeof ctx.fillText==='function'){
|
| 915 |
-
ctx.save();
|
| 916 |
-
ctx.font='700 10px system-ui,sans-serif';
|
| 917 |
-
ctx.fillStyle=prof.color;
|
| 918 |
-
ctx.strokeStyle='#08111c';ctx.lineWidth=2.5;
|
| 919 |
-
const tag=prof.role==='fitness'?'FIT':prof.role==='fastest_lap'?'LAP':prof.role==='clean_laps'?'CLN':`#${i+1}`;
|
| 920 |
-
ctx.strokeText(tag,st.x-10,st.y-14*carScale);
|
| 921 |
-
ctx.fillText(tag,st.x-10,st.y-14*carScale);
|
| 922 |
-
ctx.restore();
|
| 923 |
}
|
|
|
|
|
|
|
| 924 |
}
|
| 925 |
ctx.restore();
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
const maxSec=(maxTicks/60).toFixed(1);
|
| 937 |
-
timeLabel.textContent=`${sec}s / ${maxSec}s · Tick ${Math.round(currentTick)} / ${maxTicks}`;
|
| 938 |
-
}
|
| 939 |
-
const scrub=document.getElementById('replay_scrub');
|
| 940 |
-
if(scrub&&document.activeElement!==scrub)scrub.value=String(Math.round(currentTick));
|
| 941 |
-
}
|
| 942 |
-
|
| 943 |
-
function drawHudOverlay(ctx,w,h,lead,leadState,activeList,nnEval){
|
| 944 |
-
ctx.save();
|
| 945 |
-
// Top-left status pill
|
| 946 |
-
ctx.fillStyle='rgba(9,17,27,0.84)';
|
| 947 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 948 |
-
ctx.lineWidth=1;
|
| 949 |
-
ctx.beginPath();ctx.roundRect(12,12,285,54,8);ctx.fill();ctx.stroke();
|
| 950 |
-
|
| 951 |
-
ctx.fillStyle='#62dfb1';ctx.font='700 11px system-ui,sans-serif';
|
| 952 |
-
const sector=leadState.u<0.333?'S1':leadState.u<0.667?'S2':'S3';
|
| 953 |
-
const surfName=NAMES[leadState.surface]||'Asphalt';
|
| 954 |
-
ctx.fillText(`${lead.label.slice(0,34).toUpperCase()}`,22,29);
|
| 955 |
-
ctx.fillStyle='#c6d9e9';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
|
| 956 |
-
const bestStr=lead.bestLapSeconds?`Best ${lead.bestLapSeconds.toFixed(3)}s`:`Contacts ${lead.physicalContacts}`;
|
| 957 |
-
ctx.fillText(`LAP ${leadState.lap} · ${sector} · ${surfName.toUpperCase()} · ${bestStr}`,22,46);
|
| 958 |
-
ctx.fillStyle='#82e6bb';ctx.font='600 9.5px system-ui,sans-serif';
|
| 959 |
-
ctx.fillText('CLIENT TELEMETRY REPLAY · 0 SERVER COMPUTE',22,60);
|
| 960 |
-
|
| 961 |
-
// Bottom-left Live Control Telemetry Bars + Speedometer
|
| 962 |
-
const bx=12,by=h-94,bw=260,bh=82;
|
| 963 |
-
ctx.fillStyle='rgba(9,17,27,0.86)';
|
| 964 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 965 |
-
ctx.beginPath();ctx.roundRect(bx,by,bw,bh,8);ctx.fill();ctx.stroke();
|
| 966 |
-
|
| 967 |
-
// Speed & Slip readout
|
| 968 |
-
ctx.fillStyle='#e5edf5';ctx.font='800 18px ui-monospace,SFMono-Regular,monospace';
|
| 969 |
-
ctx.fillText(`${String(leadState.speedKmh).padStart(3,' ')} km/h`,bx+12,by+24);
|
| 970 |
-
ctx.fillStyle='#a2b4c8';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
|
| 971 |
-
const slipDeg=(leadState.slipAngle*180/Math.PI).toFixed(1);
|
| 972 |
-
ctx.fillText(`Slip ${slipDeg}°`,bx+12,by+42);
|
| 973 |
-
ctx.fillText(`Steer ${(leadState.controls.steer*100).toFixed(0)}%`,bx+12,by+58);
|
| 974 |
-
ctx.fillStyle=leadState.alive?'#62dfb1':'#ff6b6b';
|
| 975 |
-
ctx.fillText(leadState.alive?'ACTIVE':'STOPPED',bx+12,by+74);
|
| 976 |
-
|
| 977 |
-
// 5 Control bars (STR, THR, BRK, HBK, BST)
|
| 978 |
-
const bars=[
|
| 979 |
-
['STR',(leadState.controls.steer+1)*0.5,'#58a6ff',true],
|
| 980 |
-
['THR',leadState.controls.throttle,'#62dfb1',false],
|
| 981 |
-
['BRK',leadState.controls.brake,'#ff6b6b',false],
|
| 982 |
-
['HBK',leadState.controls.hbrake,'#ffd073',false],
|
| 983 |
-
['BST',leadState.controls.boost,'#c792ea',false]
|
| 984 |
-
];
|
| 985 |
-
bars.forEach(([name,val,col,bipolar],idx)=>{
|
| 986 |
-
const ry=by+12+idx*13.5,rx=bx+142,rw=104,rh=8;
|
| 987 |
-
ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px ui-monospace,SFMono-Regular,monospace';
|
| 988 |
-
ctx.fillText(name,bx+112,ry+7.5);
|
| 989 |
-
ctx.fillStyle='#152233';ctx.fillRect(rx,ry,rw,rh);
|
| 990 |
-
ctx.fillStyle=col;
|
| 991 |
-
if(bipolar){
|
| 992 |
-
const mid=rx+rw*0.5,span=(val-0.5)*rw;
|
| 993 |
-
ctx.fillRect(span>=0?mid:mid+span,ry,Math.abs(span),rh);
|
| 994 |
-
}else{
|
| 995 |
-
ctx.fillRect(rx,ry,Math.max(0,Math.min(1,val))*rw,rh);
|
| 996 |
-
}
|
| 997 |
-
});
|
| 998 |
-
|
| 999 |
-
// Top-right Ghost Race Standings / Mini Neural Activations
|
| 1000 |
-
if(activeList.length>1){
|
| 1001 |
-
const gx=w-232,gy=12,gw=220,gh=18+Math.min(4,activeList.length)*18;
|
| 1002 |
-
ctx.fillStyle='rgba(9,17,27,0.84)';
|
| 1003 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 1004 |
-
ctx.beginPath();ctx.roundRect(gx,gy,gw,gh,8);ctx.fill();ctx.stroke();
|
| 1005 |
-
ctx.fillStyle='#a2b4c8';ctx.font='700 10px system-ui,sans-serif';
|
| 1006 |
-
ctx.fillText(focusMode==='swarm'?`POPULATION SWARM (${activeList.length} CARS)`:'GHOST RACE COMPARISON',gx+10,gy+13);
|
| 1007 |
-
activeList.slice(0,4).forEach((p,idx)=>{
|
| 1008 |
-
const py=gy+28+idx*16;
|
| 1009 |
-
ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(gx+15,py-3.5,4,0,Math.PI*2);ctx.fill();
|
| 1010 |
-
ctx.fillStyle='#e5edf5';ctx.font='600 10.5px system-ui,sans-serif';
|
| 1011 |
-
const shortRole=p.role==='fitness'?'Fitness Leader':p.role==='fastest_lap'?'Fastest Lap':p.role==='clean_laps'?'Clean Laps':p.label.slice(0,18);
|
| 1012 |
-
const stat=p.bestLapSeconds?`${p.bestLapSeconds.toFixed(2)}s`:`${p.ticksElapsed}t`;
|
| 1013 |
-
ctx.fillText(`${shortRole} · ${stat}`,gx+24,py);
|
| 1014 |
-
});
|
| 1015 |
-
}
|
| 1016 |
-
|
| 1017 |
-
// Mini live neural activation visualizer when network is present
|
| 1018 |
-
if(nnEval&&nnEval.layerActivations.length>=2){
|
| 1019 |
-
const nx=w-178,ny=h-84,nw=166,nh=72;
|
| 1020 |
-
ctx.fillStyle='rgba(9,17,27,0.84)';
|
| 1021 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 1022 |
-
ctx.beginPath();ctx.roundRect(nx,ny,nw,nh,8);ctx.fill();ctx.stroke();
|
| 1023 |
-
ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px system-ui,sans-serif';
|
| 1024 |
-
ctx.fillText(`LIVE POLICY (${nnEval.layerActivations.map(l=>l.length).join('→')})`,nx+8,ny+12);
|
| 1025 |
-
const layersAct=nnEval.layerActivations;
|
| 1026 |
-
const lCount=layersAct.length;
|
| 1027 |
-
for(let l=0;l<lCount;l++){
|
| 1028 |
-
const nodes=layersAct[l],shown=Math.min(8,nodes.length);
|
| 1029 |
-
const lx=nx+14+l*(nw-28)/Math.max(1,lCount-1);
|
| 1030 |
-
for(let i=0;i<shown;i++){
|
| 1031 |
-
const ly=ny+20+(i+0.5)*(nh-26)/shown;
|
| 1032 |
-
const v=nodes[i]||0;
|
| 1033 |
-
ctx.fillStyle=v>=0?'#62dfb1':'#ff6b6b';
|
| 1034 |
-
ctx.globalAlpha=0.3+Math.min(0.7,Math.abs(v)*0.7);
|
| 1035 |
-
ctx.beginPath();ctx.arc(lx,ly,2.6,0,Math.PI*2);ctx.fill();
|
| 1036 |
-
}
|
| 1037 |
}
|
| 1038 |
-
ctx.globalAlpha=1;
|
| 1039 |
}
|
| 1040 |
-
ctx.restore();
|
| 1041 |
}
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
}
|
| 1055 |
-
|
| 1056 |
-
}
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
const
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
const camSel=document.getElementById('replay_camera');
|
| 1076 |
-
if(playBtn&&!playBtn.dataset.bound){
|
| 1077 |
-
playBtn.dataset.bound='1';
|
| 1078 |
-
playBtn.addEventListener('click',()=>{
|
| 1079 |
-
playing=!playing;
|
| 1080 |
-
playBtn.textContent=playing?'Pause':'Play';
|
| 1081 |
-
if(playing)startLoop();else{stopLoop();drawFrame();}
|
| 1082 |
-
});
|
| 1083 |
-
}
|
| 1084 |
-
if(resetBtn&&!resetBtn.dataset.bound){
|
| 1085 |
-
resetBtn.dataset.bound='1';
|
| 1086 |
-
resetBtn.addEventListener('click',()=>{currentTick=0;drawFrame();});
|
| 1087 |
-
}
|
| 1088 |
-
if(scrub&&!scrub.dataset.bound){
|
| 1089 |
-
scrub.dataset.bound='1';
|
| 1090 |
-
scrub.addEventListener('input',()=>{
|
| 1091 |
-
currentTick=Number(scrub.value)||0;
|
| 1092 |
-
drawFrame();
|
| 1093 |
-
});
|
| 1094 |
-
}
|
| 1095 |
-
if(modeSel&&!modeSel.dataset.bound){
|
| 1096 |
-
modeSel.dataset.bound='1';
|
| 1097 |
-
modeSel.addEventListener('change',()=>{
|
| 1098 |
-
focusMode=modeSel.value;
|
| 1099 |
-
rebuildProfiles();
|
| 1100 |
-
drawFrame();
|
| 1101 |
-
});
|
| 1102 |
-
}
|
| 1103 |
-
if(speedSel&&!speedSel.dataset.bound){
|
| 1104 |
-
speedSel.dataset.bound='1';
|
| 1105 |
-
speedSel.addEventListener('change',()=>{playSpeed=Number(speedSel.value)||1;});
|
| 1106 |
-
}
|
| 1107 |
-
if(camSel&&!camSel.dataset.bound){
|
| 1108 |
-
camSel.dataset.bound='1';
|
| 1109 |
-
camSel.addEventListener('change',()=>{cameraMode=camSel.value;drawFrame();});
|
| 1110 |
-
}
|
| 1111 |
-
for(const [key,id] of [['outline','replay_layer_outline'],['rays','replay_layer_rays'],['line','replay_layer_line'],['skids','replay_layer_skids'],['hud','replay_layer_hud']]){
|
| 1112 |
-
const el=document.getElementById(id);
|
| 1113 |
-
if(el&&!el.dataset.bound){
|
| 1114 |
-
el.dataset.bound='1';
|
| 1115 |
-
el.addEventListener('change',()=>{layers[key]=el.checked;drawFrame();});
|
| 1116 |
-
}
|
| 1117 |
-
}
|
| 1118 |
-
const starterVeh=document.getElementById('starter_vehicle');
|
| 1119 |
-
if(starterVeh&&!starterVeh.dataset.replayBound){
|
| 1120 |
-
starterVeh.dataset.replayBound='1';
|
| 1121 |
-
starterVeh.addEventListener('change',()=>{rebuildProfiles();drawFrame();});
|
| 1122 |
}
|
|
|
|
| 1123 |
}
|
| 1124 |
-
|
| 1125 |
async function refresh(){
|
| 1126 |
-
const current=++version;
|
| 1127 |
-
bindReplayControls();
|
| 1128 |
const info=picker.info();
|
| 1129 |
-
const source=info.source==='builtin'?info.display_name:
|
| 1130 |
-
if(!source){
|
| 1131 |
-
currentTrack=null;staticLayer=null;stopLoop();
|
| 1132 |
-
status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;
|
| 1133 |
-
}
|
| 1134 |
status.textContent='Loading '+source+'…';
|
| 1135 |
try{
|
| 1136 |
atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
|
| 1137 |
if(!r.ok)throw Error('Track atlas unavailable');return r.json();
|
| 1138 |
});
|
| 1139 |
const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
|
| 1140 |
-
|
|
|
|
|
|
|
|
|
|
| 1141 |
if(current!==version)return;
|
| 1142 |
-
currentTrack=track;
|
| 1143 |
-
currentSourceName=source;
|
| 1144 |
render(canvas,track);
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
drawFrame();
|
| 1148 |
-
startLoop();
|
| 1149 |
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
|
| 1150 |
status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
|
| 1151 |
-
legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>
|
| 1152 |
-
const i=NAMES.indexOf(name);return name+' '+counts[i];
|
| 1153 |
-
}).join(' · ')+' · Zero-compute telemetry visualizer: static circuit is cached once; ghost trajectories & sensor rays run locally in browser.';
|
| 1154 |
}catch(error){
|
| 1155 |
if(current!==version)return;
|
| 1156 |
-
currentTrack=null;staticLayer=null;
|
| 1157 |
-
|
| 1158 |
}
|
| 1159 |
}
|
| 1160 |
-
function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);}
|
| 1161 |
-
document.addEventListener('track-library-change',refresh);
|
| 1162 |
-
if(typeof document.addEventListener==='function'){
|
| 1163 |
-
document.addEventListener('visibilitychange',()=>{if(document.hidden)stopLoop();else if(playing&¤tTrack)startLoop();});
|
| 1164 |
-
}
|
| 1165 |
-
|
| 1166 |
const controller={
|
| 1167 |
refresh,
|
| 1168 |
-
setStagedNetwork(
|
| 1169 |
-
|
| 1170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1171 |
},
|
| 1172 |
-
setRunAnalysis(analysis,receipt){
|
| 1173 |
-
analysisState=analysis||null;
|
| 1174 |
-
receiptState=receipt||null;
|
| 1175 |
-
const modeSel=document.getElementById('replay_mode');
|
| 1176 |
-
if(modeSel){
|
| 1177 |
-
if(analysis?.summary?.mode==='train')focusMode=modeSel.value='winners';
|
| 1178 |
-
else if(analysis?.summary?.mode==='evaluate')focusMode=modeSel.value='fitness';
|
| 1179 |
-
}
|
| 1180 |
-
currentTick=0;
|
| 1181 |
-
if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
|
| 1182 |
-
},
|
| 1183 |
-
focusCandidate(roleOrGen){
|
| 1184 |
-
const modeSel=document.getElementById('replay_mode');
|
| 1185 |
-
if(roleOrGen==='swarm'){focusMode='swarm';if(modeSel)modeSel.value='swarm';}
|
| 1186 |
-
else if(['winners','fitness','fastest_lap','clean_laps','staged'].includes(roleOrGen)){
|
| 1187 |
-
focusMode=roleOrGen;if(modeSel)modeSel.value=roleOrGen;
|
| 1188 |
-
}
|
| 1189 |
-
currentTick=0;
|
| 1190 |
-
if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
|
| 1191 |
-
}
|
| 1192 |
};
|
| 1193 |
if(typeof root==='object'&&root)root.TrackReplay=controller;
|
| 1194 |
-
refresh();
|
| 1195 |
return controller;
|
| 1196 |
}
|
| 1197 |
-
return {mount,model,decode,render};
|
| 1198 |
});
|
|
|
|
| 1 |
+
/* Browser-only preview of the selected saved track. No upload for preview. */
|
| 2 |
(function(root,factory){
|
| 3 |
if(typeof module==='object'&&module.exports)module.exports=factory(root);
|
| 4 |
else root.TrackMap=factory(root);
|
| 5 |
})(typeof window==='undefined'?this:window,function(root){
|
| 6 |
const COLOURS=['#607e96','#b89561','#b9d9ed'];
|
| 7 |
const NAMES=['Asphalt','Dirt','Ice'];
|
|
|
|
| 8 |
const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
|
| 9 |
function vector(value){
|
| 10 |
const match=typeof value==='string'&&VECTOR.exec(value);
|
|
|
|
| 87 |
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
|
| 88 |
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
|
| 89 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
function render(canvas,track){
|
| 91 |
const context=canvas.getContext('2d');
|
| 92 |
if(!context)throw Error('Canvas preview unavailable');
|
|
|
|
| 96 |
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 97 |
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
|
| 98 |
const left=(w-columns*size)/2,top=(h-rows*size)/2;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
for(const tile of track.tiles){
|
| 100 |
const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
|
| 101 |
context.fillStyle=COLOURS[tile.surface];
|
|
|
|
| 106 |
const [entry,exit]=tile.connections;
|
| 107 |
const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
|
| 108 |
const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
context.beginPath();context.moveTo(...a);
|
| 110 |
context.bezierCurveTo(...p1,...p2,...z);
|
| 111 |
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
|
| 112 |
context.lineCap='round';context.stroke();
|
| 113 |
}
|
| 114 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
|
| 116 |
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
|
| 117 |
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
|
| 118 |
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
|
| 119 |
}
|
| 120 |
+
// Vehicle illustration uses only recorded position, heading, type and active state.
|
| 121 |
+
function drawVehicle2D(ctx,x,y,heading,vehicleType,color,scale,showOutline,active){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
ctx.save();
|
| 123 |
ctx.translate(x,y);
|
| 124 |
+
ctx.rotate(heading);
|
| 125 |
ctx.scale(scale,scale);
|
| 126 |
if(!active)ctx.globalAlpha=0.55;
|
| 127 |
|
| 128 |
+
const wheelSteer=0; // Neutral artwork; steering samples are unavailable.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
// Draw wheels / skis
|
| 130 |
ctx.fillStyle='#101820';
|
| 131 |
ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
|
|
|
|
| 199 |
ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
|
| 200 |
}
|
| 201 |
|
| 202 |
+
// The recorded active flag controls the inactive badge.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
if(!active){
|
| 204 |
ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
|
| 205 |
ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
|
|
|
|
| 207 |
ctx.restore();
|
| 208 |
}
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
+
function hash(value,label){
|
| 212 |
+
if(typeof value!=='string'||!/^[a-f0-9]{64}$/i.test(value))throw Error(label+' must be a SHA-256 hash');
|
| 213 |
+
return value.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
+
function validateRecording(payload,expectedTrackSha,expectedNetworkSha){
|
| 216 |
+
if(!payload||payload.schema!=='ald.recorded-replay'||payload.version!==1||payload.kind!=='fresh_cpu_evaluation')
|
| 217 |
+
throw Error('This file is not a supported recorded CPU evaluation');
|
| 218 |
+
if(payload.physics_hz!==60||payload.control_hz!==60||payload.sample_interval_ticks!==6||payload.first_sample_tick!==1||
|
| 219 |
+
payload.execution_backend!=='cpu'||payload.math_profile!=='proton'||payload.collision_elimination!==false||payload.idle_elimination!==false)
|
| 220 |
+
throw Error('Recorded evaluation conditions are unsupported');
|
| 221 |
+
const trackSha=hash(payload.track_sha256,'Recorded track'),networkSha=hash(payload.network_sha256,'Recorded network');
|
| 222 |
+
if(trackSha!==hash(expectedTrackSha,'Selected track')||networkSha!==hash(expectedNetworkSha,'Selected network'))
|
| 223 |
+
throw Error('The recording does not match the selected track and network');
|
| 224 |
+
const ticks=payload.requested_ticks;
|
| 225 |
+
if(!Number.isInteger(ticks)||ticks<1||ticks>900)throw Error('Recording must contain at most 900 physics ticks');
|
| 226 |
+
if(!Number.isInteger(payload.vehicle_type)||payload.vehicle_type<0||payload.vehicle_type>3)throw Error('Recorded vehicle type is invalid');
|
| 227 |
+
const expectedTicks=[1];
|
| 228 |
+
for(let tick=6;tick<=ticks;tick+=6)expectedTicks.push(tick);
|
| 229 |
+
if(expectedTicks[expectedTicks.length-1]!==ticks)expectedTicks.push(ticks);
|
| 230 |
+
if(!Array.isArray(payload.frames)||payload.frames.length!==expectedTicks.length)
|
| 231 |
+
throw Error('Recording is missing its expected native samples');
|
| 232 |
+
const frames=payload.frames.map((frame,index)=>{
|
| 233 |
+
if(!frame||frame.tick!==expectedTicks[index]||typeof frame.active!=='boolean')throw Error('Recorded frame ticks or active state are invalid');
|
| 234 |
+
const copy={tick:frame.tick,active:frame.active};
|
| 235 |
+
for(const key of ['x','y','rotation','vx','vy']){
|
| 236 |
+
if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
|
| 237 |
+
copy[key]=frame[key];
|
| 238 |
+
}
|
| 239 |
+
for(const key of ['angular_velocity','boost_energy','collision_count','update_count']){
|
| 240 |
+
if(frame[key]===undefined)continue;
|
| 241 |
+
if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
|
| 242 |
+
if((key==='collision_count'||key==='update_count')&&(!Number.isInteger(frame[key])||frame[key]<0))throw Error('Recorded '+key+' is invalid');
|
| 243 |
+
copy[key]=frame[key];
|
| 244 |
+
}
|
| 245 |
+
return copy;
|
| 246 |
+
});
|
| 247 |
+
return {schema:payload.schema,version:1,kind:payload.kind,physics_hz:60,control_hz:60,sample_interval_ticks:6,
|
| 248 |
+
first_sample_tick:1,track_sha256:trackSha,network_sha256:networkSha,vehicle_type:payload.vehicle_type,
|
| 249 |
+
requested_ticks:ticks,execution_backend:'cpu',math_profile:'proton',collision_elimination:false,idle_elimination:false,frames};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
}
|
| 251 |
+
function sampleRecording(recording,tick){
|
| 252 |
+
if(!Number.isFinite(tick))throw Error('Replay tick must be finite');
|
| 253 |
+
const frames=recording.frames,last=frames.length-1;
|
| 254 |
+
const at=Math.max(frames[0].tick,Math.min(frames[last].tick,tick));
|
| 255 |
+
let lo=0,hi=last;
|
| 256 |
+
while(lo<hi){const mid=Math.ceil((lo+hi)/2);if(frames[mid].tick<=at)lo=mid;else hi=mid-1;}
|
| 257 |
+
const a=frames[lo],b=frames[Math.min(last,lo+1)];
|
| 258 |
+
const t=b.tick===a.tick?0:(at-a.tick)/(b.tick-a.tick);
|
| 259 |
+
const angle=Math.atan2(Math.sin(b.rotation-a.rotation),Math.cos(b.rotation-a.rotation));
|
| 260 |
+
// Pose interpolation is only between adjacent recorded samples. HUD values
|
| 261 |
+
// remain the actual preceding sample, never reconstructed controls or rays.
|
| 262 |
+
return {tick:at,x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,rotation:a.rotation+angle*t,frame:{...a}};
|
| 263 |
+
}
|
| 264 |
+
function viewport(canvas,track){
|
| 265 |
+
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 266 |
+
const size=Math.min(160,(canvas.width-70)/columns,(canvas.height-70)/rows);
|
| 267 |
+
return {size,left:(canvas.width-columns*size)/2,top:(canvas.height-rows*size)/2,minX:b.minX,minY:b.minY};
|
| 268 |
+
}
|
| 269 |
+
function worldPoint(view,x,y){return [view.left+(x/768-view.minX)*view.size,view.top+(y/768-view.minY)*view.size];}
|
| 270 |
function mount({document,fetch,picker}){
|
| 271 |
+
const canvas=document.getElementById('trackMap'),status=document.getElementById('mapStatus'),legend=document.getElementById('mapLegend');
|
| 272 |
+
if(!canvas||!status||!legend||!picker)return;
|
| 273 |
+
const element=id=>document.getElementById(id);
|
| 274 |
+
let version=0,atlasPromise=null,currentTrack=null,currentTrackSha=null,staticLayer=null;
|
| 275 |
+
let recording=null,hasRunSummary=false,cursor=1,playing=false,animId=null,lastFrameTime=null;
|
| 276 |
+
let playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed')?.value)||1));
|
| 277 |
+
let cameraMode=element('replay_camera')?.value==='follow'?'follow':'full';
|
| 278 |
+
const layers={outline:true,line:true,hud:true};
|
| 279 |
+
const noRecording='No replay recorded. The map shows the selected track and its saved starting point.';
|
| 280 |
+
const summaryOnly='No replay recorded. This run contains summary measurements, not vehicle positions or control samples.';
|
| 281 |
+
function setText(id,text){const node=element(id);if(node&&node.textContent!==text)node.textContent=text;}
|
| 282 |
+
function syncControls(reason){
|
| 283 |
+
const last=recording?.frames[recording.frames.length-1].tick;
|
| 284 |
+
const play=element('replay_play');
|
| 285 |
+
if(play){play.disabled=!recording||cursor>=last;play.textContent=playing?'Pause':'Play';}
|
| 286 |
+
for(const id of ['replay_reset','replay_scrub','replay_speed','replay_camera','replay_layer_outline','replay_layer_line','replay_layer_hud']){
|
| 287 |
+
const node=element(id);if(node)node.disabled=!recording;
|
| 288 |
+
}
|
| 289 |
+
const scrub=element('replay_scrub');
|
| 290 |
+
if(scrub){scrub.min='1';scrub.max=String(last??1);scrub.step='1';scrub.value=String(Math.round(cursor));}
|
| 291 |
+
if(!recording){
|
| 292 |
+
setText('replay_status',reason||(hasRunSummary?summaryOnly:noRecording));
|
| 293 |
+
setText('replay_time_label','No recorded samples');
|
| 294 |
+
setText('replay_readout','Position, speed, controls and sensors are unavailable until a replay is recorded.');
|
|
|
|
|
|
|
| 295 |
}else{
|
| 296 |
+
const state=cursor>=last?'Finished. Use Restart to view it again.':playing?'Playing.':'Paused.';
|
| 297 |
+
setText('replay_status',`Fresh recorded CPU evaluation · ${recording.frames.length} samples · 60 Hz physics and controls · 10 Hz recording. ${state}`);
|
| 298 |
}
|
| 299 |
}
|
| 300 |
+
function stopPlayback(){
|
| 301 |
+
if(animId!==null&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
|
| 302 |
+
animId=null;playing=false;lastFrameTime=null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
}
|
| 304 |
+
function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);}
|
| 305 |
+
function draw(){
|
| 306 |
+
if(!currentTrack){renderEmpty();return;}
|
| 307 |
+
const ctx=canvas.getContext('2d'),view=viewport(canvas,currentTrack);
|
| 308 |
+
const state=recording?sampleRecording(recording,cursor):null;
|
| 309 |
+
const position=state?worldPoint(view,state.x,state.y):null;
|
| 310 |
+
ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
ctx.save();
|
| 312 |
+
if(position&&cameraMode==='follow'){
|
| 313 |
+
ctx.translate(canvas.width/2,canvas.height/2);ctx.scale(2,2);ctx.translate(-position[0],-position[1]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
}
|
| 315 |
+
if(staticLayer)ctx.drawImage(staticLayer,0,0);else render(canvas,currentTrack);
|
| 316 |
+
if(recording){
|
| 317 |
+
if(layers.line){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
ctx.beginPath();
|
| 319 |
+
recording.frames.forEach((frame,index)=>{const p=worldPoint(view,frame.x,frame.y);if(index===0)ctx.moveTo(...p);else ctx.lineTo(...p);});
|
| 320 |
+
ctx.strokeStyle='#62dfb1';ctx.lineWidth=2;ctx.lineCap='round';ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}
|
| 322 |
+
// Native rotation zero faces -Y; the vehicle illustration faces +X.
|
| 323 |
+
drawVehicle2D(ctx,...position,state.rotation-Math.PI/2,recording.vehicle_type,'#62dfb1',Math.max(0.55,Math.min(1.1,view.size/120)),layers.outline,state.frame.active);
|
| 324 |
}
|
| 325 |
ctx.restore();
|
| 326 |
+
if(state){
|
| 327 |
+
const frame=state.frame,speed=Math.hypot(frame.vx,frame.vy)/15*3.6;
|
| 328 |
+
const contacts=frame.collision_count===undefined?'unavailable':String(frame.collision_count);
|
| 329 |
+
setText('replay_time_label',`${(cursor/60).toFixed(2)}s / ${(recording.requested_ticks/60).toFixed(2)}s · Tick ${Math.round(cursor)} · Recorded sample ${frame.tick}`);
|
| 330 |
+
setText('replay_readout',`Recorded sample tick ${frame.tick} (${(frame.tick/60).toFixed(2)}s). Speed ${speed.toFixed(1)} km/h, derived from recorded velocity. Position (${frame.x.toFixed(2)}, ${frame.y.toFixed(2)}). Contacts ${contacts}. ${frame.active?'Active':'Inactive'} at this sample. Controls and sensors unavailable.`);
|
| 331 |
+
if(layers.hud){
|
| 332 |
+
ctx.save();ctx.fillStyle='rgba(9,17,27,0.88)';ctx.beginPath();ctx.roundRect(12,12,310,80,8);ctx.fill();
|
| 333 |
+
ctx.fillStyle='#62dfb1';ctx.font='700 12px system-ui,sans-serif';ctx.fillText('RECORDED CPU EVALUATION',24,33);
|
| 334 |
+
ctx.fillStyle='#e5edf5';ctx.font='600 12px ui-monospace,monospace';ctx.fillText(`Sample ${frame.tick} · ${speed.toFixed(1)} km/h from velocity`,24,55);
|
| 335 |
+
ctx.fillStyle='#b4c5d5';ctx.font='12px system-ui,sans-serif';ctx.fillText('Controls and sensors unavailable',24,77);ctx.restore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
}
|
|
|
|
| 337 |
}
|
|
|
|
| 338 |
}
|
| 339 |
+
function clearRecording(reason){
|
| 340 |
+
const hadRecording=recording!==null;stopPlayback();recording=null;cursor=1;
|
| 341 |
+
if(hadRecording)draw();syncControls(reason);
|
| 342 |
+
}
|
| 343 |
+
function step(now){
|
| 344 |
+
animId=null;if(!playing||!recording)return;
|
| 345 |
+
if(document.hidden){stopPlayback();syncControls();return;}
|
| 346 |
+
if(lastFrameTime===null)lastFrameTime=now;
|
| 347 |
+
const elapsed=Math.max(0,Math.min(0.1,(now-lastFrameTime)/1000));lastFrameTime=now;
|
| 348 |
+
const last=recording.frames[recording.frames.length-1].tick;
|
| 349 |
+
cursor=Math.min(last,cursor+elapsed*60*playSpeed);
|
| 350 |
+
draw();
|
| 351 |
+
if(cursor>=last){stopPlayback();syncControls();return;}
|
| 352 |
+
syncControls();animId=root.requestAnimationFrame(step);
|
| 353 |
+
}
|
| 354 |
+
function bind(){
|
| 355 |
+
const on=(id,event,callback)=>{const node=element(id);if(node)node.addEventListener(event,callback);};
|
| 356 |
+
on('replay_play','click',()=>{
|
| 357 |
+
if(!recording)return;
|
| 358 |
+
if(playing){stopPlayback();syncControls();return;}
|
| 359 |
+
if(cursor>=recording.frames[recording.frames.length-1].tick||typeof root?.requestAnimationFrame!=='function')return;
|
| 360 |
+
playing=true;lastFrameTime=null;syncControls();animId=root.requestAnimationFrame(step);
|
| 361 |
+
});
|
| 362 |
+
on('replay_reset','click',()=>{if(!recording)return;stopPlayback();cursor=recording.frames[0].tick;draw();syncControls();});
|
| 363 |
+
on('replay_scrub','input',()=>{
|
| 364 |
+
if(!recording)return;const value=Number(element('replay_scrub').value);if(!Number.isFinite(value))return;
|
| 365 |
+
stopPlayback();cursor=Math.max(recording.frames[0].tick,Math.min(recording.frames[recording.frames.length-1].tick,value));draw();syncControls();
|
| 366 |
+
});
|
| 367 |
+
on('replay_speed','change',()=>{playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed').value)||1));});
|
| 368 |
+
on('replay_camera','change',()=>{cameraMode=element('replay_camera').value==='follow'?'follow':'full';draw();});
|
| 369 |
+
for(const key of ['outline','line','hud']){
|
| 370 |
+
const id='replay_layer_'+key,node=element(id);if(node)layers[key]=node.checked;
|
| 371 |
+
on(id,'change',()=>{layers[key]=element(id).checked;draw();});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
+
document.addEventListener('visibilitychange',()=>{if(document.hidden){stopPlayback();syncControls();}});
|
| 374 |
}
|
|
|
|
| 375 |
async function refresh(){
|
| 376 |
+
const current=++version;clearRecording();currentTrackSha=null;currentTrack=null;staticLayer=null;
|
|
|
|
| 377 |
const info=picker.info();
|
| 378 |
+
const source=info.source==='builtin'?info.display_name:element('track')?.files?.[0]?.name;
|
| 379 |
+
if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
|
|
|
|
|
|
|
|
|
|
| 380 |
status.textContent='Loading '+source+'…';
|
| 381 |
try{
|
| 382 |
atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
|
| 383 |
if(!r.ok)throw Error('Track atlas unavailable');return r.json();
|
| 384 |
});
|
| 385 |
const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
|
| 386 |
+
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
|
| 387 |
+
const hashPromise=root?.crypto?.subtle?file.arrayBuffer().then(bytes=>root.crypto.subtle.digest('SHA-256',bytes)).then(bytes=>Array.from(new Uint8Array(bytes),n=>n.toString(16).padStart(2,'0')).join('')):Promise.resolve(null);
|
| 388 |
+
const [saved,sha]=await Promise.all([decode(file),hashPromise]);
|
| 389 |
+
const track=model(saved,atlas);
|
| 390 |
if(current!==version)return;
|
| 391 |
+
currentTrack=track;currentTrackSha=sha;
|
|
|
|
| 392 |
render(canvas,track);
|
| 393 |
+
const off=document.createElement?.('canvas');
|
| 394 |
+
if(off?.getContext?.('2d')){off.width=canvas.width;off.height=canvas.height;render(off,track);staticLayer=off;}
|
|
|
|
|
|
|
| 395 |
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
|
| 396 |
status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
|
| 397 |
+
legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>name+' '+counts[NAMES.indexOf(name)]).join(' · ')+' · Orange line follows the track centerline. Collision shapes are not shown. Vehicle positions appear only from a recorded replay.';
|
|
|
|
|
|
|
| 398 |
}catch(error){
|
| 399 |
if(current!==version)return;
|
| 400 |
+
currentTrack=null;currentTrackSha=null;staticLayer=null;renderEmpty();
|
| 401 |
+
status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
|
| 402 |
}
|
| 403 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
const controller={
|
| 405 |
refresh,
|
| 406 |
+
setStagedNetwork(){clearRecording();},
|
| 407 |
+
setRunAnalysis(analysis,receipt){hasRunSummary=Boolean(analysis?.summary||receipt?.report);clearRecording();},
|
| 408 |
+
focusCandidate(){clearRecording();},
|
| 409 |
+
clearRecording,
|
| 410 |
+
setRecording(payload,expectedTrackSha,expectedNetworkSha){
|
| 411 |
+
clearRecording();
|
| 412 |
+
try{
|
| 413 |
+
const next=validateRecording(payload,expectedTrackSha,expectedNetworkSha);
|
| 414 |
+
if(!currentTrack||!currentTrackSha)throw Error('The selected track must finish loading and hash verification before replay');
|
| 415 |
+
if(next.track_sha256!==currentTrackSha)throw Error('The recording belongs to a different selected track');
|
| 416 |
+
recording=next;cursor=next.frames[0].tick;draw();syncControls();
|
| 417 |
+
return {frames:next.frames.length,first_tick:cursor,last_tick:next.frames[next.frames.length-1].tick};
|
| 418 |
+
}catch(error){clearRecording('Replay unavailable: '+error.message);throw error;}
|
| 419 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
};
|
| 421 |
if(typeof root==='object'&&root)root.TrackReplay=controller;
|
| 422 |
+
bind();syncControls();document.addEventListener('track-library-change',refresh);refresh();
|
| 423 |
return controller;
|
| 424 |
}
|
| 425 |
+
return {mount,model,decode,render,validateRecording,sampleRecording};
|
| 426 |
});
|