{"context":{"domain":"lucca-playground"},"layers":{"ApplicationLayer":{"reference":"ApplicationLayer","availableInstances":[{"reference":"ComponentsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@DataSet","reference":"SystemsDataSet","params":{"filter":{"domain":"data2"},"pagination":{"size":1},"datasource":{"component":"data2-core-types@ProcessReference","params":{"reference":"SystemsDataSource"}}}},{"reference":"ExternalComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"SystemsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"5e3b796d6228227ca637d0f4","name":"data2","domain":"data2","organization":"62292184611f480b3191c37c","visibility":"public","updatedAt":"2026-07-27T14:19:36.810Z"}]}},{"reference":"OrganizationsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"DataComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@DataSet","reference":"OneFileComponentInstancesDataSet","params":{"pagination":{"size":1},"master":{"dataset":{"component":"data2-core-types@ProcessReference","params":{"reference":"SystemsDataSet"}},"localField":"system","masterField":"_id"},"datasource":{"component":"data2-core-types@ProcessReference","params":{"reference":"FileComponentInstancesDataSource"}}}},{"reference":"ApplicationsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ServiceComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@Data2TableDataSource","reference":"FileComponentInstancesDataSource","params":{"table":"fileComponentInstances","schema":[{"component":"data2-core-types@Field","params":{"key":"component","title":"Component","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"system","title":"System","component":"data2-core-primitives@ObjectId"}},{"component":"data2-core-types@Field","params":{"key":"displayName","title":"Display Name","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"params","title":"Params","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"reference","title":"Reference","component":"data2-core-primitives@String"}}]}},{"component":"data2-core@MemoryDataSource","reference":"EmptyDataSource","params":{"initialData":[]}},{"reference":"ComponentsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"UserComponentsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"DataComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ServiceComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ExternalComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ApplicationsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}}],"processesParams":{}},"HostApplicationLayer":{"reference":"HostApplicationLayer","availableInstances":[{"_id":"5f15ff190528b92601890f71","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"SystemsTableDataSource"}},"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"domain"}}},"pk":"_id","fields":[{"component":"data2-core-types@FieldReference","params":{"key":"_id"}},{"component":"data2-core-types@FieldReference","params":{"key":"name"}},{"component":"data2-core-types@FieldReference","params":{"key":"domain"}},{"component":"data2-core-types@FieldReference","params":{"key":"visibility"}},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"customDomains"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization._id"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.name"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.billingStatus"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.publicTrial.active"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"settings.defaultAccessPolicy"}}},"break":true}],"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}},"staticPagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}},"policies":[{"component":"data2-core-auth@Policy","params":{"allow":{"params":{"actions":[{"params":{"components":[{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"_id"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"},{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"domain"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"},{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"customDomains"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"}]},"component":"data2-core-logical-operators@Or"}]},"component":"data2-core-flow@Series"}}}]},"reference":"SystemsDataSet","updatedAt":"2026-08-07T21:51:24.037Z","createdAt":"2020-07-20T20:31:21.838Z"},{"_id":"5f1601ce0528b92601890f7c","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":1000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentsTableDataSource"}},"sort":{"params.package":1,"params.component":1},"pk":"_id","autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"staticFilter":{"system":{"exists":{"params":{"content":false},"component":"data2-core-primitives@Boolean"}}},"filter":{"system":{"exists":{"params":{"content":false},"component":"data2-core-primitives@Boolean"}}}},"reference":"ComponentsDataSet","updatedAt":"2025-11-05T19:37:01.935Z","createdAt":"2020-07-20T20:42:54.898Z"},{"_id":"5f9041dbc8da12be8b94aea4","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":2000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentsTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"UserComponentsDataSet","updatedAt":"2025-11-06T21:06:58.095Z","createdAt":"2020-07-20T20:44:09.350Z"},{"_id":"65d8c4fabc9dbb8f94de8d53","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ExternalComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ExternalComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:23.742Z","createdAt":"2024-02-23T16:17:01.816Z"},{"reference":"SystemsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6172b957976026c073ab9058","name":"Lucca`s Playground","domain":"lucca-playground","visibility":"public","settings":{"defaultAccessPolicy":"private"},"updatedAt":"2026-08-14T19:26:29.927Z"}]}},{"reference":"ComponentsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"5f52a278a575399a45d239b4","component":"data2-core-types@Component","params":{"package":"data2-core-primitives","component":"Router","interactions":{"component":"data2-core-types@FieldSet","params":{"fields":{"goBack":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.goBack","args":[]}}}},"icon":{"component":"data2-core-primitives@String","params":{"content":"arrow-left"}},"visibility":"public"}},"goTo":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.push","args":[{"component":"data2-core-types@Reference","params":{"reference":"url"}},{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"preserveQuery"}}}}]}},"parameters":[{"component":"data2-core-primitives@String","params":{"content":"url"}},{"component":"data2-core-primitives@String","params":{"content":"preserveQuery"}}]},"break":true},"icon":{"component":"data2-core-primitives@String","params":{"content":"share"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Path to route"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Path to redirect user"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Boolean"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Preserve Query"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Preserve query string parameters  preserve query parameters on navigating."}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"}]}},"open":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.push","args":[{"pathname":{"component":"data2-core-types@Reference","params":{"reference":"value"}}}]}},"parameters":["value"]}},"icon":{"component":"data2-core-primitives@String","params":{"content":"share"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Link to open"}},"description":{"component":"data2-core-primitives@String","params":{"content":""}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Target"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Where to open the link"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"oneOf":[{"params":{"content":"_blank"},"component":"data2-core-primitives@String"},{"params":{"content":"_self"},"component":"data2-core-primitives@String"},{"params":{"content":"_parent"},"component":"data2-core-primitives@String"},{"params":{"content":"_top"},"component":"data2-core-primitives@String"}]},"component":"data2-core-types@ComponentParameter"}]}},"close":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":"close"}},"parameters":["value"]}},"icon":{"component":"data2-core-primitives@String","params":{"content":"times"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Link to open"}},"description":{"component":"data2-core-primitives@String","params":{"content":""}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Target"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Where to open the link"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"oneOf":[{"params":{"content":"_blank"},"component":"data2-core-primitives@String"},{"params":{"content":"_self"},"component":"data2-core-primitives@String"},{"params":{"content":"_parent"},"component":"data2-core-primitives@String"},{"params":{"content":"_top"},"component":"data2-core-primitives@String"}]},"component":"data2-core-types@ComponentParameter"}],"description":{"component":"data2-core-primitives@String","params":{"content":"Closes a window that was opened via router.open."}}}}}}}}},{"_id":"620d28658271aa4129919d89","component":"data2-core-types@Component","params":{"package":"data2-interface-services","component":"DeviceController","interactions":{"params":{"fields":{"postMessage":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@String","title":{"component":"data2-core-primitives@String","params":{"content":"data"}}}}]},"component":"data2-core-types@ComponentParameter"},"print":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"parameters":{"zoom":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Number"}},"title":{"component":"data2-core-primitives@String","params":{"content":"zoom"}},"visibility":"public"}}},"visibility":"public"},"component":"data2-core-types@ComponentParameter"}],"visibility":"public"},"component":"data2-core-types@ComponentParameter"},"share":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}}},"component":"data2-core-types@ComponentParameter"},"clipboard.copy":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}}}}]},"component":"data2-core-types@ComponentParameter"},"clipboard.paste":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}}}}]},"component":"data2-core-types@ComponentParameter"},"download":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Content"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Options"}},"parameters":{"params":{"fields":{"type":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Mimetype"}}}}}},"component":"data2-core-types@FieldSet"}}}]},"component":"data2-core-types@ComponentParameter"},"downloadUrl":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Url"}}}}]},"component":"data2-core-types@ComponentParameter"},"promptFiles":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@Object","flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"parameters":{"params":{"fields":{"multiple":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Multiple"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Whether or not to allow selecting multiple files"}},"component":"data2-core-primitives@Boolean"}},"acceptedFormats":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Accepted Formats"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Accepted file formats"}},"component":"data2-core-primitives@String","list":{"component":"data2-core-primitives@Boolean","params":{"content":true}}}},"timeout":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Timeout"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Max milliseconds to wait for file upload"}},"component":"data2-core-primitives@Number"}}}},"component":"data2-core-types@FieldSet"}}}],"visibility":"public"},"component":"data2-core-types@ComponentParameter"},"meetingRecord":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","definition":{"params":{"actions":[{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"meeting"}},"script":{"component":"data2-core-primitives@String","params":{"content":"(function() {\n  let mediaRecorder;\n  let audioChunks = [];\n  let combinedStream;\n  let audioContext;\n  \n  window.meetingCapture = async function() {\n    try {\n      // Solicitar acesso ao áudio da guia (e vídeo, que será ignorado depois)\n      const tabStream = await navigator.mediaDevices.getDisplayMedia({ \n        video: true, // Necessário para alguns navegadores\n        audio: true\n      });\n      \n      // Remover a trilha de vídeo\n      const tabAudioTrack = tabStream.getAudioTracks()[0];\n      \n      // Solicitar acesso ao microfone\n      const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      const micAudioTrack = micStream.getAudioTracks()[0];\n      \n      // Criar um novo MediaStream com ambas as trilhas de áudio\n      combinedStream = new MediaStream([tabAudioTrack, micAudioTrack]);\n      \n      // Criar um contexto de áudio para misturar os streams\n      audioContext = new AudioContext();\n      const destination = audioContext.createMediaStreamDestination();\n      \n      // Conectar a trilha de áudio da guia\n      const tabSource = audioContext.createMediaStreamSource(new MediaStream([tabAudioTrack]));\n      tabSource.connect(destination);\n      \n      // Conectar a trilha de áudio do microfone\n      const micSource = audioContext.createMediaStreamSource(new MediaStream([micAudioTrack]));\n      micSource.connect(destination);\n      \n      // Iniciar gravação com o stream misturado\n      mediaRecorder = new MediaRecorder(destination.stream);\n      audioChunks = [];\n      mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data);\n      mediaRecorder.start();\n      \n      console.log('Gravação iniciada. Chame meetingCaptureStop() para finalizar.');\n    } catch (error) {\n      console.error('Erro ao iniciar a captura:', error);\n    }\n  };\n  \n  window.meetingCaptureStop = function() {\n    return new Promise((resolve, reject) => {\n      if (!mediaRecorder) {\n        reject('Nenhuma gravação em andamento.');\n        return;\n      }\n      \n      mediaRecorder.onstop = () => {\n        const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });\n        combinedStream.getTracks().forEach(track => track.stop());\n        audioContext.close();\n        \n        resolve(audioBlob);\n        console.log('Gravação finalizada. O arquivo de áudio está pronto para upload.');\n      };\n      \n      mediaRecorder.stop();\n    });\n  };\n})();"}}},"component":"data2-core-web@Script"},{"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":{"component":"data2-core-primitives@String","params":{"content":"meetingCapture"}}}}]},"component":"data2-core-flow@Series"}},"component":"data2-core-types@ComponentParameter"},"meetingRecordStop":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Content"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Options"}},"parameters":{"params":{"fields":{"type":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Mimetype"}}}}}},"component":"data2-core-types@FieldSet"}}}],"definition":{"params":{"actions":[{"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":{"component":"data2-core-primitives@String","params":{"content":"meetingCaptureStop"}}}}]},"component":"data2-core-flow@Series"}},"component":"data2-core-types@ComponentParameter"},"notify":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"private","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@Object","parameters":{"title":{"params":{"component":"data2-core-primitives@String","index":{"component":"data2-core-primitives@Number","params":{"content":0}},"title":{"component":"data2-core-primitives@String","params":{"content":"title"}}},"component":"data2-core-types@ComponentParameter"},"sound":{"params":{"component":"data2-core-types@FileComponentUrlReference","index":{"component":"data2-core-primitives@Number","params":{"content":1}},"title":{"component":"data2-core-primitives@String","params":{"content":"sound"}}},"component":"data2-core-types@ComponentParameter"}},"flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}}}}]},"component":"data2-core-types@ComponentParameter"}}},"component":"data2-core-types@FieldSet"}}},{"_id":"6a73ee571204b3d36b63499e","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"pixeldoomEditor","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const CELL = 32\nconst TOOLS = [\n  { id: '0', label: 'Vazio', sw: '#2f3542' },\n  { id: '1', label: 'Parede 1', sw: '#b33939' },\n  { id: '2', label: 'Parede 2', sw: '#4a69bd' },\n  { id: '3', label: 'Parede 3', sw: '#38ada9' },\n  { id: '4', label: 'Parede 4', sw: '#e58e26' },\n  { id: '5', label: 'Saída', sw: '#f6e58d' },\n  { id: 'player', label: 'Início Jogador', sw: '#00e6e6' },\n  { id: 'enemy', label: 'Inimigo', sw: '#e84118' },\n]\nconst SWATCH = { '0': '#2f3542', '1': '#b33939', '2': '#4a69bd', '3': '#38ada9', '4': '#e58e26', '5': '#f6e58d' }\n\nfunction esc(v) {\n  return String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;')\n}\nfunction blankGrid(w, h) { return new Array(w * h).fill(0) }\nfunction resizeGrid(oldGrid, oldW, oldH, newW, newH) {\n  const g = new Array(newW * newH).fill(0)\n  const mh = Math.min(oldH, newH), mw = Math.min(oldW, newW)\n  for (let y = 0; y < mh; y++) {\n    for (let x = 0; x < mw; x++) g[y * newW + x] = oldGrid[y * oldW + x] || 0\n  }\n  return g\n}\nfunction drawGrid(cv, s, draftGrid) {\n  if (!cv || !s || !s.width) return\n  const w = s.width, h = s.height\n  const grid = draftGrid || s.grid || []\n  const dpr = window.devicePixelRatio || 1\n  const cssW = w * CELL, cssH = h * CELL\n  const needW = Math.round(cssW * dpr), needH = Math.round(cssH * dpr)\n  if (cv.width !== needW || cv.height !== needH) { cv.width = needW; cv.height = needH }\n  const ctx = cv.getContext('2d')\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n  for (let y = 0; y < h; y++) {\n    for (let x = 0; x < w; x++) {\n      const v = grid[y * w + x] || 0\n      ctx.fillStyle = SWATCH[String(v)] || '#2f3542'\n      ctx.fillRect(x * CELL, y * CELL, CELL, CELL)\n      ctx.strokeStyle = 'rgba(0,0,0,0.25)'\n      ctx.strokeRect(x * CELL + 0.5, y * CELL + 0.5, CELL - 1, CELL - 1)\n    }\n  }\n  const things = s.things || []\n  ctx.font = `${Math.floor(CELL * 0.7)}px sans-serif`\n  ctx.textAlign = 'center'\n  ctx.textBaseline = 'middle'\n  things.forEach((t) => { if (t.type === 'enemy') ctx.fillText('👹', t.x * CELL, t.y * CELL) })\n  const ps = s.playerStart || { x: 1.5, y: 1.5, angle: 0 }\n  ctx.save()\n  ctx.translate(ps.x * CELL, ps.y * CELL)\n  ctx.rotate(ps.angle || 0)\n  ctx.fillStyle = '#00e6e6'\n  ctx.beginPath(); ctx.moveTo(12, 0); ctx.lineTo(-8, 8); ctx.lineTo(-8, -8); ctx.closePath(); ctx.fill()\n  ctx.restore()\n}\n\nexport default {\n  css: `\n    .app{position:absolute;inset:0;display:flex;font-family:'Courier New',monospace;background:#0d0f14;color:#e8e8ee;overflow:hidden}\n    .sidebar{width:300px;flex:0 0 300px;background:#161822;border-right:1px solid #2a2e3d;padding:16px;box-sizing:border-box;overflow-y:auto;display:flex;flex-direction:column;gap:12px}\n    .sidebar h1{font-size:15px;letter-spacing:2px;margin:0 0 4px;color:#f6e58d;text-transform:uppercase}\n    .field{display:flex;flex-direction:column;gap:4px;font-size:11px;color:#9aa0b4}\n    .field input{background:#0d0f14;border:1px solid #2a2e3d;color:#e8e8ee;padding:6px 8px;border-radius:4px;font-family:inherit;box-sizing:border-box;width:100%}\n    .row2{display:flex;gap:8px}\n    .row2 .field{flex:1}\n    .toolgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}\n    .toolbtn{display:flex;align-items:center;gap:6px;background:#1d2030;border:1px solid #2a2e3d;color:#cfd2e0;padding:6px 8px;border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit}\n    .toolbtn .sw{width:14px;height:14px;border-radius:3px;background:var(--c);flex:0 0 14px;display:inline-block}\n    .toolbtn.active{border-color:#f6e58d;background:#2a2410;color:#f6e58d}\n    .actions{display:flex;flex-direction:column;gap:6px;margin-top:4px}\n    .btn{background:#2a2e3d;border:1px solid #3a3f52;color:#e8e8ee;padding:8px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:12px}\n    .btn.primary{background:#f6e58d;color:#161822;font-weight:700;border-color:#f6e58d}\n    .msg{font-size:11px;color:#78e08f;min-height:14px}\n    .levelList{display:flex;flex-direction:column;gap:4px;font-size:11px;border-top:1px solid #2a2e3d;padding-top:10px}\n    .levelList label{color:#9aa0b4;text-transform:uppercase;font-size:10px;letter-spacing:1px}\n    .levelItem{background:#1d2030;border:1px solid #2a2e3d;border-radius:4px;padding:6px 8px;cursor:pointer}\n    .levelItem.active{border-color:#00e6e6;color:#00e6e6}\n    .canvasArea{flex:1;overflow:auto;padding:24px;box-sizing:border-box}\n    .canvasWrap{position:relative;box-shadow:0 0 0 2px #2a2e3d}\n    .grid{display:block;width:100%;height:100%;cursor:crosshair}\n  `,\n  render(d2) {\n    const s = d2.state\n    const width = s.width || 12\n    const height = s.height || 12\n    const tool = s.tool || '1'\n    const name = s.name != null ? s.name : 'Novo Nível'\n    const msg = s.msg || ''\n    const levelId = s.levelId || null\n    const ds = d2.data('levels')\n    const rows = ds && !ds.loading ? ds.records() : []\n    const listHtml = rows.length\n      ? rows.map((r) => `<div class=\"levelItem ${r._id === levelId ? 'active' : ''}\" data-key=\"${r._id}\" data-id=\"${r._id}\">${esc(r.name)} (${r.width}x${r.height})</div>`).join('')\n      : `<div style=\"color:#666\">Nenhum nível salvo ainda.</div>`\n    const wrapW = width * CELL, wrapH = height * CELL\n    return `<div class=\"app\">\n      <div class=\"sidebar\">\n        <h1>PixelDoom · Editor</h1>\n        <div class=\"field\"><label>Nome do nível</label><input class=\"nameIn\" value=\"${esc(name)}\"/></div>\n        <div class=\"row2\">\n          <div class=\"field\"><label>Largura</label><input class=\"widthIn\" type=\"number\" min=\"4\" max=\"40\" value=\"${width}\"/></div>\n          <div class=\"field\"><label>Altura</label><input class=\"heightIn\" type=\"number\" min=\"4\" max=\"40\" value=\"${height}\"/></div>\n        </div>\n        <div class=\"field\"><label>Ferramentas</label>\n          <div class=\"toolgrid\">\n            ${TOOLS.map((t) => `<button class=\"toolbtn ${tool === t.id ? 'active' : ''}\" data-tool=\"${t.id}\" style=\"--c:${t.sw}\"><span class=\"sw\"></span>${t.label}</button>`).join('')}\n          </div>\n        </div>\n        <div class=\"actions\">\n          <button class=\"btn newBtn\">＋ Novo Nível</button>\n          <button class=\"btn saveBtn\">💾 Salvar</button>\n          <button class=\"btn primary playBtn\">▶ Jogar</button>\n        </div>\n        <div class=\"msg\">${esc(msg)}</div>\n        <div class=\"levelList\">\n          <label>Níveis salvos</label>\n          ${listHtml}\n        </div>\n      </div>\n      <div class=\"canvasArea\">\n        <div class=\"canvasWrap\" style=\"width:${wrapW}px;height:${wrapH}px\">\n          <canvas class=\"grid\" data-d2-static></canvas>\n        </div>\n      </div>\n    </div>`\n  },\n  mounted(d2) {\n    if (d2.state.width === undefined) {\n      d2.setState({\n        levelId: null, name: 'Novo Nível', width: 12, height: 12,\n        grid: blankGrid(12, 12), things: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },\n        tool: '1', msg: 'Escolha uma ferramenta e desenhe o nível.',\n      })\n    }\n\n    let painting = false\n    let draft = null\n\n    const cellFromEvent = (e, cv) => {\n      const rect = cv.getBoundingClientRect()\n      const w = d2.state.width, h = d2.state.height\n      const cx = Math.floor(((e.clientX - rect.left) / rect.width) * w)\n      const cy = Math.floor(((e.clientY - rect.top) / rect.height) * h)\n      return { cx: Math.max(0, Math.min(w - 1, cx)), cy: Math.max(0, Math.min(h - 1, cy)) }\n    }\n\n    const applyTool = (cx, cy, gridRef) => {\n      const s = d2.state\n      const idx = cy * s.width + cx\n      if (s.tool === 'player') {\n        s.playerStart = { x: cx + 0.5, y: cy + 0.5, angle: (s.playerStart && s.playerStart.angle) || 0 }\n        return null\n      }\n      if (s.tool === 'enemy') {\n        const things = (s.things || []).slice()\n        const at = things.findIndex((t) => t.type === 'enemy' && Math.floor(t.x) === cx && Math.floor(t.y) === cy)\n        if (at >= 0) things.splice(at, 1)\n        else things.push({ x: cx + 0.5, y: cy + 0.5, type: 'enemy' })\n        s.things = things\n        return null\n      }\n      const val = Number(s.tool)\n      const g = gridRef.slice()\n      if (val === 5) { for (let i = 0; i < g.length; i++) if (g[i] === 5) g[i] = 0 }\n      g[idx] = val\n      return g\n    }\n\n    d2.on('.grid', 'mousedown', (e, cv) => {\n      const s = d2.state\n      if (s.tool === 'player' || s.tool === 'enemy') {\n        const { cx, cy } = cellFromEvent(e, cv)\n        applyTool(cx, cy, s.grid)\n        drawGrid(cv, d2.state, null)\n        return\n      }\n      painting = true\n      draft = s.grid.slice()\n      const { cx, cy } = cellFromEvent(e, cv)\n      const g2 = applyTool(cx, cy, draft)\n      if (g2) draft = g2\n      drawGrid(cv, d2.state, draft)\n    })\n    d2.on(window, 'mousemove', (e) => {\n      if (!painting) return\n      const cv = d2.$('.grid'); if (!cv) return\n      const { cx, cy } = cellFromEvent(e, cv)\n      const g2 = applyTool(cx, cy, draft)\n      if (g2) draft = g2\n      drawGrid(cv, d2.state, draft)\n    })\n    d2.on(window, 'mouseup', () => {\n      if (painting && draft) d2.state.grid = draft\n      painting = false\n      draft = null\n    })\n\n    d2.on('.toolbtn', 'click', (e, el) => { d2.state.tool = el.dataset.tool })\n    d2.on('.nameIn', 'input', (e, el) => { d2.state.name = el.value })\n    d2.on('.widthIn', 'change', (e, el) => {\n      const nw = Math.max(4, Math.min(40, parseInt(el.value, 10) || 12))\n      const s = d2.state\n      const ng = resizeGrid(s.grid, s.width, s.height, nw, s.height)\n      d2.setState({ grid: ng, width: nw })\n    })\n    d2.on('.heightIn', 'change', (e, el) => {\n      const nh = Math.max(4, Math.min(40, parseInt(el.value, 10) || 12))\n      const s = d2.state\n      const ng = resizeGrid(s.grid, s.width, s.height, s.width, nh)\n      d2.setState({ grid: ng, height: nh })\n    })\n\n    d2.on('.newBtn', 'click', () => {\n      d2.setState({\n        levelId: null, name: 'Novo Nível', width: 12, height: 12,\n        grid: blankGrid(12, 12), things: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },\n        tool: '1', msg: 'Novo nível — comece a desenhar as paredes.',\n      })\n    })\n\n    d2.on('.levelItem', 'click', (e, el) => {\n      const ds = d2.data('levels')\n      const rec = (ds.records() || []).find((r) => r._id === el.dataset.id)\n      if (!rec) return\n      d2.setState({\n        levelId: rec._id, name: rec.name, width: rec.width, height: rec.height,\n        grid: (rec.grid || []).slice(), things: (rec.things || []).map((t) => ({ ...t })),\n        playerStart: rec.playerStart ? { ...rec.playerStart } : { x: 1.5, y: 1.5, angle: 0 },\n        msg: `Carregado: ${rec.name}`,\n      })\n    })\n\n    const doSave = async () => {\n      const s = d2.state\n      const ds = d2.data('levels')\n      const payload = {\n        name: s.name || 'Nível sem nome', width: s.width, height: s.height,\n        grid: s.grid, things: s.things || [], playerStart: s.playerStart || { x: 1.5, y: 1.5, angle: 0 },\n        updatedAt: new Date().toISOString(),\n      }\n      d2.state.msg = 'Salvando…'\n      try {\n        if (s.levelId) {\n          await ds.update({ _id: s.levelId, ...payload })\n        } else {\n          const rec = await ds.create(payload)\n          let newId = (rec && rec._id) || (rec && rec.data && rec.data._id) || null\n          if (!newId) {\n            await ds.refresh()\n            const all = (ds.records() || []).slice().sort((a, b) => (a._id < b._id ? 1 : -1))\n            newId = all[0] && all[0]._id\n          }\n          d2.state.levelId = newId\n        }\n        d2.state.msg = 'Salvo!'\n      } catch (err) {\n        d2.state.msg = 'Erro ao salvar.'\n      }\n    }\n    d2.on('.saveBtn', 'click', () => { doSave() })\n    d2.on('.playBtn', 'click', async () => {\n      await doSave()\n      const store = d2.store('gameStore')\n      if (store) store.set('selectedLevelId', d2.state.levelId)\n      window.location.href = d2.param('playUrl', '/')\n    })\n  },\n  updated(d2) {\n    const cv = d2.$('.grid')\n    if (cv) drawGrid(cv, d2.state, null)\n  },\n}\n"}},"levels":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"levels"}}}},"gameStore":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gameStore"}}}},"playUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"playUrl"}}}}}}}}}},{"_id":"6a73ee81d5c61d2c1c9a94e7","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"pixeldoomPlay","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const WALL_COLORS = {\n  1: [179, 57, 57],\n  2: [74, 105, 189],\n  3: [56, 173, 169],\n  4: [229, 142, 38],\n}\nconst FLOOR_COLOR = [26, 26, 26]\nconst SKY_COLOR = [40, 46, 66]\nconst RENDER_W = 320\nconst RENDER_H = 200\nconst FOV = (66 * Math.PI) / 180\nconst MINI = 160\n\nfunction isWallCell(v) { return v >= 1 && v <= 4 }\nfunction clamp(v, a, b) { return Math.max(a, Math.min(b, v)) }\n\nexport default {\n  css: `\n    .app{position:absolute;inset:0;background:#000;overflow:hidden;font-family:'Courier New',monospace;color:#e8e8ee}\n    .scene{position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated;cursor:crosshair}\n    .mini{position:absolute;top:16px;right:16px;width:160px;height:160px;border:2px solid #444;background:#0d0f14;opacity:0.92}\n    .hud{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;gap:4px;pointer-events:none;text-shadow:0 1px 3px #000}\n    .hudName{font-size:14px;letter-spacing:2px;color:#f6e58d;text-transform:uppercase}\n    .hudKills{font-size:13px;color:#78e08f}\n    .crosshair{position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px;pointer-events:none}\n    .crosshair::before,.crosshair::after{content:'';position:absolute;background:#fff;opacity:.85}\n    .crosshair::before{left:6px;top:0;width:2px;height:14px}\n    .crosshair::after{left:0;top:6px;width:14px;height:2px}\n    .backBtn{position:absolute;bottom:16px;left:16px;background:#1d2030;border:1px solid #3a3f52;color:#e8e8ee;padding:8px 14px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:12px;z-index:5}\n    .loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#0d0f14;color:#9aa0b4;font-size:14px;letter-spacing:2px;z-index:9}\n    .loading.hide{display:none}\n    .victory{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;background:rgba(10,12,18,0.9);z-index:10}\n    .victory.show{display:flex}\n    .victory h2{font-size:32px;color:#f6e58d;letter-spacing:4px;margin:0}\n    .victory button{background:#f6e58d;color:#161822;font-weight:700;border:0;padding:10px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:13px}\n    .hint{position:absolute;bottom:16px;right:16px;font-size:10px;color:#666;text-align:right;pointer-events:none}\n  `,\n  render() {\n    return `<div class=\"app\">\n      <canvas class=\"scene\" data-d2-static></canvas>\n      <canvas class=\"mini\" data-d2-static></canvas>\n      <div class=\"crosshair\" data-d2-static></div>\n      <div class=\"hud\">\n        <div class=\"hudName\">Carregando…</div>\n        <div class=\"hudKills\">Abates: 0/0</div>\n      </div>\n      <div class=\"hint\">WASD/Setas move · Q/E strafe · Espaço/Click atira</div>\n      <button class=\"backBtn\">← Voltar ao editor</button>\n      <div class=\"loading\">Carregando nível…</div>\n      <div class=\"victory\">\n        <h2>NÍVEL COMPLETO</h2>\n        <button class=\"victoryBtn\">Voltar ao editor</button>\n      </div>\n    </div>`\n  },\n  mounted(d2) {\n    const keys = {}\n    let level = null\n    let player = { x: 1.5, y: 1.5, angle: 0 }\n    let things = []\n    let kills = 0\n    let totalEnemies = 0\n    let gameWon = false\n    let ready = false\n    let lastT = null\n    const depthBuffer = new Array(RENDER_W).fill(1e9)\n\n    const goEditor = () => { window.location.href = d2.param('editorUrl', '/') }\n    d2.on('.backBtn', 'click', goEditor)\n    d2.on('.victoryBtn', 'click', goEditor)\n\n    function cellAt(gx, gy) {\n      if (gx < 0 || gy < 0 || gx >= level.width || gy >= level.height) return 1\n      return level.grid[gy * level.width + gx] || 0\n    }\n    function canStand(x, y) { return !isWallCell(cellAt(Math.floor(x), Math.floor(y))) }\n\n    function castRay(px, py, angle) {\n      const dirX = Math.cos(angle), dirY = Math.sin(angle)\n      let mapX = Math.floor(px), mapY = Math.floor(py)\n      const deltaDistX = dirX === 0 ? 1e30 : Math.abs(1 / dirX)\n      const deltaDistY = dirY === 0 ? 1e30 : Math.abs(1 / dirY)\n      let stepX, stepY, sideDistX, sideDistY\n      if (dirX < 0) { stepX = -1; sideDistX = (px - mapX) * deltaDistX } else { stepX = 1; sideDistX = (mapX + 1 - px) * deltaDistX }\n      if (dirY < 0) { stepY = -1; sideDistY = (py - mapY) * deltaDistY } else { stepY = 1; sideDistY = (mapY + 1 - py) * deltaDistY }\n      let side = 0, cellVal = 0\n      for (let i = 0; i < 64; i++) {\n        if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0 } else { sideDistY += deltaDistY; mapY += stepY; side = 1 }\n        cellVal = cellAt(mapX, mapY)\n        if (isWallCell(cellVal)) break\n      }\n      let dist\n      if (side === 0) dist = (mapX - px + (1 - stepX) / 2) / (dirX || 1e-6)\n      else dist = (mapY - py + (1 - stepY) / 2) / (dirY || 1e-6)\n      return { dist: Math.max(dist, 0.0001), side, cellVal }\n    }\n\n    function shadeRGB(rgb, mult, fogT) {\n      const r = rgb[0] * mult, g = rgb[1] * mult, b = rgb[2] * mult\n      return [\n        r + (FLOOR_COLOR[0] - r) * fogT,\n        g + (FLOOR_COLOR[1] - g) * fogT,\n        b + (FLOOR_COLOR[2] - b) * fogT,\n      ]\n    }\n\n    function shoot() {\n      if (!ready || gameWon) return\n      const wallDist = castRay(player.x, player.y, player.angle).dist\n      let best = null, bestAngle = 999\n      for (const e of things) {\n        if (e.type !== 'enemy' || !e.alive) continue\n        const ex = e.x - player.x, ey = e.y - player.y\n        const dist = Math.hypot(ex, ey)\n        let ang = Math.atan2(ey, ex) - player.angle\n        while (ang > Math.PI) ang -= 2 * Math.PI\n        while (ang < -Math.PI) ang += 2 * Math.PI\n        if (Math.abs(ang) < 0.14 && dist < wallDist + 0.6 && Math.abs(ang) < bestAngle) { bestAngle = Math.abs(ang); best = e }\n      }\n      if (best) { best.alive = false; kills += 1 }\n    }\n    d2.on('.scene', 'click', shoot)\n    d2.on(window, 'keydown', (e) => {\n      const c = e.code\n      if (c === 'KeyW' || c === 'ArrowUp') keys.forward = true\n      else if (c === 'KeyS' || c === 'ArrowDown') keys.back = true\n      else if (c === 'KeyA' || c === 'ArrowLeft') keys.rotLeft = true\n      else if (c === 'KeyD' || c === 'ArrowRight') keys.rotRight = true\n      else if (c === 'KeyQ') keys.strafeLeft = true\n      else if (c === 'KeyE') keys.strafeRight = true\n      else if (c === 'Space') { e.preventDefault(); shoot() }\n    })\n    d2.on(window, 'keyup', (e) => {\n      const c = e.code\n      if (c === 'KeyW' || c === 'ArrowUp') keys.forward = false\n      else if (c === 'KeyS' || c === 'ArrowDown') keys.back = false\n      else if (c === 'KeyA' || c === 'ArrowLeft') keys.rotLeft = false\n      else if (c === 'KeyD' || c === 'ArrowRight') keys.rotRight = false\n      else if (c === 'KeyQ') keys.strafeLeft = false\n      else if (c === 'KeyE') keys.strafeRight = false\n    })\n\n    async function loadLevel() {\n      const store = d2.store('gameStore')\n      const ds = d2.data('levels')\n      await ds.refresh()\n      const selId = store ? store.get('selectedLevelId') : null\n      const rows = ds.records() || []\n      let rec = selId ? rows.find((r) => r._id === selId) : null\n      if (!rec) rec = rows[0]\n      if (!rec) {\n        const nameEl = d2.$('.hudName'); if (nameEl) nameEl.textContent = 'Nenhum nível encontrado'\n        return\n      }\n      level = { width: rec.width, height: rec.height, grid: rec.grid || [], name: rec.name || 'Nível' }\n      things = (rec.things || []).map((t) => ({ ...t, alive: true }))\n      const ps = rec.playerStart || { x: 1.5, y: 1.5, angle: 0 }\n      player = { x: ps.x, y: ps.y, angle: ps.angle || 0 }\n      kills = 0\n      totalEnemies = things.filter((t) => t.type === 'enemy').length\n      gameWon = false\n      const loadingEl = d2.$('.loading'); if (loadingEl) loadingEl.classList.add('hide')\n      const nameEl = d2.$('.hudName'); if (nameEl) nameEl.textContent = level.name\n      const killsEl = d2.$('.hudKills'); if (killsEl) killsEl.textContent = `Abates: 0/${totalEnemies}`\n      ready = true\n    }\n\n    function render3D(ctx) {\n      const img = ctx.createImageData(RENDER_W, RENDER_H)\n      for (let col = 0; col < RENDER_W; col++) {\n        const rayAngle = player.angle - FOV / 2 + (FOV * col) / RENDER_W\n        const { dist, side, cellVal } = castRay(player.x, player.y, rayAngle)\n        const perp = dist * Math.cos(rayAngle - player.angle)\n        depthBuffer[col] = perp\n        const lineH = Math.min(RENDER_H * 3, RENDER_H / Math.max(perp, 0.0001))\n        const drawStart = Math.floor(RENDER_H / 2 - lineH / 2)\n        const drawEnd = Math.floor(RENDER_H / 2 + lineH / 2)\n        const fogT = clamp(perp / 11, 0, 1)\n        const mult = side === 1 ? 1 : 0.7\n        const wc = shadeRGB(WALL_COLORS[cellVal] || WALL_COLORS[1], mult, fogT)\n        for (let y = 0; y < RENDER_H; y++) {\n          let color\n          if (y < drawStart) color = SKY_COLOR\n          else if (y > drawEnd) color = FLOOR_COLOR\n          else color = wc\n          const idx = (y * RENDER_W + col) * 4\n          img.data[idx] = color[0] | 0\n          img.data[idx + 1] = color[1] | 0\n          img.data[idx + 2] = color[2] | 0\n          img.data[idx + 3] = 255\n        }\n      }\n      ctx.putImageData(img, 0, 0)\n      const alive = things.filter((t) => t.type === 'enemy' && t.alive)\n      alive.sort((a, b) => {\n        const da = Math.hypot(a.x - player.x, a.y - player.y)\n        const db = Math.hypot(b.x - player.x, b.y - player.y)\n        return db - da\n      })\n      ctx.textAlign = 'center'\n      ctx.textBaseline = 'middle'\n      alive.forEach((e) => {\n        const ex = e.x - player.x, ey = e.y - player.y\n        const dist = Math.hypot(ex, ey)\n        let ang = Math.atan2(ey, ex) - player.angle\n        while (ang > Math.PI) ang -= 2 * Math.PI\n        while (ang < -Math.PI) ang += 2 * Math.PI\n        if (Math.abs(ang) > FOV / 2 + 0.4 || dist < 0.15) return\n        const screenX = (0.5 + Math.tan(ang) / Math.tan(FOV / 2) / 2) * RENDER_W\n        const col = Math.floor(screenX)\n        if (col < -20 || col > RENDER_W + 20) return\n        const cCol = clamp(col, 0, RENDER_W - 1)\n        if (dist > depthBuffer[cCol] + 0.35) return\n        const size = clamp(RENDER_H / dist, 10, RENDER_H * 1.6)\n        const fogT = clamp(dist / 11, 0, 1)\n        ctx.globalAlpha = 1 - fogT * 0.7\n        ctx.font = `${size}px sans-serif`\n        ctx.fillText('👹', screenX, RENDER_H / 2 + size * 0.08)\n        ctx.globalAlpha = 1\n      })\n    }\n\n    function renderMini(ctx) {\n      ctx.clearRect(0, 0, MINI, MINI)\n      ctx.fillStyle = '#0d0f14'\n      ctx.fillRect(0, 0, MINI, MINI)\n      if (!level) return\n      const cs = Math.min(MINI / level.width, MINI / level.height)\n      for (let y = 0; y < level.height; y++) {\n        for (let x = 0; x < level.width; x++) {\n          const v = level.grid[y * level.width + x] || 0\n          if (v === 0) continue\n          ctx.fillStyle = v === 5 ? 'rgb(246,229,141)' : '#666'\n          ctx.fillRect(x * cs, y * cs, cs, cs)\n        }\n      }\n      things.filter((t) => t.type === 'enemy' && t.alive).forEach((e) => {\n        ctx.fillStyle = '#e84118'\n        ctx.beginPath(); ctx.arc(e.x * cs, e.y * cs, Math.max(2, cs * 0.3), 0, 7); ctx.fill()\n      })\n      ctx.save()\n      ctx.translate(player.x * cs, player.y * cs)\n      ctx.rotate(player.angle)\n      ctx.fillStyle = '#00e6e6'\n      ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-4, 4); ctx.lineTo(-4, -4); ctx.closePath(); ctx.fill()\n      ctx.restore()\n    }\n\n    function checkWin() {\n      if (gameWon || !ready) return\n      if (kills >= totalEnemies) {\n        const onExit = cellAt(Math.floor(player.x), Math.floor(player.y)) === 5\n        if (onExit) {\n          gameWon = true\n          const v = d2.$('.victory'); if (v) v.classList.add('show')\n        }\n      }\n    }\n\n    const scene = d2.$('.scene')\n    const mini = d2.$('.mini')\n    if (!scene || !mini) return\n    scene.width = RENDER_W\n    scene.height = RENDER_H\n    const sctx = scene.getContext('2d')\n    mini.width = MINI\n    mini.height = MINI\n    const mctx = mini.getContext('2d')\n\n    loadLevel()\n\n    d2.raf((t) => {\n      if (lastT == null) lastT = t\n      const dt = Math.min((t - lastT) / 1000, 0.05)\n      lastT = t\n      if (!ready) return\n      if (!gameWon) {\n        const moveSpeed = 3, rotSpeed = 2.4, strafeSpeed = 2.6\n        let dx = 0, dy = 0\n        if (keys.forward) { dx += Math.cos(player.angle) * moveSpeed * dt; dy += Math.sin(player.angle) * moveSpeed * dt }\n        if (keys.back) { dx -= Math.cos(player.angle) * moveSpeed * dt; dy -= Math.sin(player.angle) * moveSpeed * dt }\n        if (keys.strafeLeft) { dx += Math.cos(player.angle - Math.PI / 2) * strafeSpeed * dt; dy += Math.sin(player.angle - Math.PI / 2) * strafeSpeed * dt }\n        if (keys.strafeRight) { dx += Math.cos(player.angle + Math.PI / 2) * strafeSpeed * dt; dy += Math.sin(player.angle + Math.PI / 2) * strafeSpeed * dt }\n        if (keys.rotLeft) player.angle -= rotSpeed * dt\n        if (keys.rotRight) player.angle += rotSpeed * dt\n        if (dx !== 0) { const nx = player.x + dx; if (canStand(nx + Math.sign(dx) * 0.2, player.y)) player.x = nx }\n        if (dy !== 0) { const ny = player.y + dy; if (canStand(player.x, ny + Math.sign(dy) * 0.2)) player.y = ny }\n        checkWin()\n      }\n      render3D(sctx)\n      renderMini(mctx)\n      const killsEl = d2.$('.hudKills')\n      if (killsEl) killsEl.textContent = `Abates: ${kills}/${totalEnemies}`\n    })\n  },\n}\n"}},"levels":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"levels"}}}},"gameStore":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gameStore"}}}},"editorUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"editorUrl"}}}}}}}}}},{"_id":"6abeecc5ba309d7a9face034","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"Rubik_s_Cube","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `\n    :host { display: block; width: 100%; height: 100%; }\n    .rb { position: relative; width: 100%; height: 100%; min-height: 480px; overflow: hidden;\n      background: radial-gradient(120% 80% at 50% 30%, #2a2f45 0%, #12141f 70%);\n      color: #f3f4f8; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n      touch-action: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n      display: flex; flex-direction: column; }\n    .hud { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 0; }\n    .title { font-weight: 700; font-size: 18px; letter-spacing: .3px; }\n    .stats { display: flex; gap: 16px; font-variant-numeric: tabular-nums; font-size: 14px; opacity: .85; }\n    .stats b { font-weight: 600; }\n    .stage { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; }\n    .cube { position: relative; width: 0; height: 0; transform-style: preserve-3d; }\n    .cubie { position: absolute; transform-style: preserve-3d; }\n    .f { position: absolute; left: 0; top: 0; width: var(--s); height: var(--s); margin: calc(var(--s) / -2) 0 0 calc(var(--s) / -2);\n      background: #0b0c10; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-sizing: border-box; }\n    .f.c::after { content: \"\"; position: absolute; inset: 6%; border-radius: 14%; background: var(--c);\n      box-shadow: inset 0 -2px 4px rgba(0,0,0,.25), inset 0 2px 3px rgba(255,255,255,.25); }\n    .hint { text-align: center; font-size: 13px; opacity: .55; padding: 0 24px; min-height: 18px; }\n    .bar { display: flex; gap: 10px; justify-content: center; padding: 14px 16px calc(18px + env(safe-area-inset-bottom)); }\n    .bar button { flex: 1; max-width: 140px; height: 48px; border: 0; border-radius: 14px; font-size: 15px; font-weight: 600;\n      color: #f3f4f8; background: rgba(255,255,255,.1); touch-action: manipulation; }\n    .bar button.primary { background: #4f7cff; }\n    .bar button:active { transform: scale(.97); }\n    .win { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; pointer-events: none; }\n    .win.show { display: flex; }\n    .win div { background: rgba(18,20,31,.85); padding: 18px 26px; border-radius: 18px; text-align: center; font-size: 15px;\n      box-shadow: 0 10px 40px rgba(0,0,0,.4); animation: pop .35s ease-out; }\n    .win strong { display: block; font-size: 22px; margin-bottom: 4px; }\n    @keyframes pop { from { transform: scale(.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }\n  `,\n\n  render() {\n    return `\n      <div class=\"rb\" data-d2-static>\n        <div class=\"hud\">\n          <div class=\"title\">Cube</div>\n          <div class=\"stats\"><span>Moves <b class=\"moves\">0</b></span><span><b class=\"time\">0:00</b></span></div>\n        </div>\n        <div class=\"stage\"><div class=\"cube\"></div><div class=\"win\"><div><strong>Solved!</strong><span class=\"winmsg\"></span></div></div></div>\n        <div class=\"hint\">Drag the background to spin · swipe a face to turn it</div>\n        <div class=\"bar\">\n          <button class=\"undo\">Undo</button>\n          <button class=\"reset\">Reset</button>\n          <button class=\"scramble primary\">Scramble</button>\n        </div>\n      </div>`;\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s);\n    const rb = $('.rb'), stage = $('.stage'), cubeEl = $('.cube');\n    if (!rb || rb.__init) return;\n    rb.__init = true;\n\n    // ---------- math (3x3 row-major, CSS coords: x right, y down, z toward viewer) ----------\n    const I = () => [[1,0,0],[0,1,0],[0,0,1]];\n    const mul = (A, B) => A.map((r) => [0,1,2].map((c) => r[0]*B[0][c] + r[1]*B[1][c] + r[2]*B[2][c]));\n    const mv = (A, v) => A.map((r) => r[0]*v[0] + r[1]*v[1] + r[2]*v[2]);\n    const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];\n    const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];\n    const rnd = (v) => v.map((x) => Math.round(x));\n    const rot = (a, t) => {\n      const [x, y, z] = a, c = Math.cos(t), s = Math.sin(t), C = 1 - c;\n      return [[c+x*x*C, x*y*C-z*s, x*z*C+y*s], [y*x*C+z*s, c+y*y*C, y*z*C-x*s], [z*x*C-y*s, z*y*C+x*s, c+z*z*C]];\n    };\n    const css = (M, t = [0,0,0]) =>\n      `matrix3d(${M[0][0]},${M[1][0]},${M[2][0]},0,${M[0][1]},${M[1][1]},${M[2][1]},0,${M[0][2]},${M[1][2]},${M[2][2]},0,${t[0]},${t[1]},${t[2]},1)`;\n\n    // ---------- cube model ----------\n    const FACES = [ // local normal, css placement, color, test on solved position\n      { n: [0,0,1],  t: 'translateZ(calc(var(--s)/2))',                  c: '#2ecc71', k: (p) => p[2] === 1 },\n      { n: [0,0,-1], t: 'rotateY(180deg) translateZ(calc(var(--s)/2))',  c: '#3498ff', k: (p) => p[2] === -1 },\n      { n: [1,0,0],  t: 'rotateY(90deg) translateZ(calc(var(--s)/2))',   c: '#ff4757', k: (p) => p[0] === 1 },\n      { n: [-1,0,0], t: 'rotateY(-90deg) translateZ(calc(var(--s)/2))',  c: '#ff9f1a', k: (p) => p[0] === -1 },\n      { n: [0,-1,0], t: 'rotateX(90deg) translateZ(calc(var(--s)/2))',   c: '#f5f6fa', k: (p) => p[1] === -1 },\n      { n: [0,1,0],  t: 'rotateX(-90deg) translateZ(calc(var(--s)/2))',  c: '#ffd32a', k: (p) => p[1] === 1 },\n    ];\n    let S = 60, cubies = [];\n    let view = mul(rot([1,0,0], -0.5), rot([0,1,0], -0.65));\n\n    const build = () => {\n      cubeEl.innerHTML = '';\n      cubies = [];\n      for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) {\n        if (!x && !y && !z) continue;\n        const el = document.createElement('div');\n        el.className = 'cubie';\n        const home = [x, y, z];\n        FACES.forEach((F, i) => {\n          const f = document.createElement('div');\n          const colored = F.k(home);\n          f.className = 'f' + (colored ? ' c' : '');\n          f.dataset.fi = i;\n          f.style.transform = F.t;\n          if (colored) f.style.setProperty('--c', F.c);\n          el.appendChild(f);\n        });\n        const cb = { pos: home.slice(), R: I(), el };\n        el.__cb = cb;\n        cubies.push(cb);\n        cubeEl.appendChild(el);\n      }\n      paintAll();\n    };\n    const paint = (cb, A = null) => {\n      const M = A ? mul(A, cb.R) : cb.R;\n      const p = A ? mv(A, cb.pos) : cb.pos;\n      cb.el.style.transform = css(M, p.map((v) => v * S));\n    };\n    const paintAll = () => cubies.forEach((c) => paint(c));\n    const paintView = () => { cubeEl.style.transform = css(view); };\n\n    const layout = () => {\n      const r = stage.getBoundingClientRect();\n      const span = Math.min(r.width * 0.62, r.height * 0.55, 420);\n      S = Math.max(24, span / 3);\n      rb.style.setProperty('--s', S + 'px');\n      stage.style.perspective = S * 14 + 'px';\n      paintAll();\n    };\n\n    // ---------- moves ----------\n    let busy = false, queue = [], history = [], moves = 0, startT = 0, timerId = 0, scrambled = false;\n    const movesEl = $('.moves'), timeEl = $('.time'), winEl = $('.win'), winMsg = $('.winmsg');\n    const fmt = (ms) => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };\n    const tick = () => { if (startT) timeEl.textContent = fmt(Date.now() - startT); };\n\n    const layerOf = (axis, idx) => cubies.filter((c) => Math.round(dot(c.pos, axis)) === idx);\n    const commit = (axis, idx, q) => {\n      const A = rot(axis, q * Math.PI / 2);\n      layerOf(axis, idx).forEach((c) => { c.pos = rnd(mv(A, c.pos)); c.R = mul(A, c.R).map(rnd); paint(c); });\n    };\n    const animate = (layer, axis, from, to, ms, done) => {\n      const t0 = performance.now();\n      const step = (now) => {\n        const k = Math.min(1, (now - t0) / ms), e = 1 - Math.pow(1 - k, 3);\n        const A = rot(axis, from + (to - from) * e);\n        layer.forEach((c) => paint(c, A));\n        if (k < 1) requestAnimationFrame(step); else done();\n      };\n      requestAnimationFrame(step);\n    };\n    // move = { axis, idx, q } ; q in {-2, -1, 1, 2}\n    const run = () => {\n      if (busy || !queue.length) return;\n      busy = true;\n      const m = queue.shift();\n      animate(layerOf(m.axis, m.idx), m.axis, m.from || 0, m.q * Math.PI / 2, m.ms || 200, () => {\n        commit(m.axis, m.idx, m.q);\n        busy = false;\n        if (m.user) onUserMove(m);\n        run();\n      });\n    };\n    const push = (m) => { queue.push(m); run(); };\n\n    const onUserMove = (m) => {\n      if (!m.undo) history.push(m);\n      moves += m.undo ? -1 : 1;\n      movesEl.textContent = Math.max(0, moves);\n      try { navigator.vibrate && navigator.vibrate(8); } catch (e) {}\n      if (scrambled && !startT && !m.undo) { startT = Date.now(); timerId = setInterval(tick, 250); }\n      checkSolved();\n    };\n    const isSolved = () => {\n      const seen = {};\n      for (const c of cubies) for (const [i, F] of FACES.entries()) {\n        if (!c.el.children[i].classList.contains('c')) continue;\n        const n = rnd(mv(c.R, F.n)).join(',');\n        (seen[n] = seen[n] || new Set()).add(F.c);\n      }\n      return Object.values(seen).every((s) => s.size === 1);\n    };\n    const checkSolved = () => {\n      if (!scrambled || !isSolved()) return;\n      scrambled = false;\n      clearInterval(timerId);\n      const took = startT ? Date.now() - startT : 0;\n      startT = 0;\n      winMsg.textContent = `${moves} moves · ${fmt(took)}`;\n      winEl.classList.add('show');\n      try { navigator.vibrate && navigator.vibrate([30, 40, 30]); } catch (e) {}\n      setTimeout(() => winEl.classList.remove('show'), 2600);\n    };\n\n    const AXES = [[1,0,0],[0,1,0],[0,0,1]];\n    const scramble = () => {\n      if (busy || queue.length) return;\n      let last = -1;\n      for (let i = 0; i < 22; i++) {\n        let a; do { a = Math.floor(Math.random() * 3); } while (a === last);\n        last = a;\n        const idx = [-1, 0, 1][Math.floor(Math.random() * 3)];\n        queue.push({ axis: AXES[a], idx, q: Math.random() < 0.5 ? 1 : -1, ms: 90 });\n      }\n      history = []; moves = 0; movesEl.textContent = '0';\n      clearInterval(timerId); startT = 0; timeEl.textContent = '0:00';\n      winEl.classList.remove('show');\n      scrambled = true;\n      run();\n    };\n    const reset = () => {\n      if (busy) return;\n      queue = []; history = []; moves = 0; scrambled = false;\n      clearInterval(timerId); startT = 0;\n      movesEl.textContent = '0'; timeEl.textContent = '0:00';\n      winEl.classList.remove('show');\n      build();\n    };\n    const undo = () => {\n      if (busy || queue.length || !history.length) return;\n      const m = history.pop();\n      push({ axis: m.axis, idx: m.idx, q: -m.q, ms: 180, user: true, undo: true });\n    };\n    $('.scramble').addEventListener('click', scramble);\n    $('.reset').addEventListener('click', reset);\n    $('.undo').addEventListener('click', undo);\n\n    // ---------- gestures ----------\n    let g = null;\n    const toScreen = (d) => { const v = mv(view, d); return [v[0], v[1]]; };\n\n    stage.addEventListener('pointerdown', (ev) => {\n      if (g) return;\n      const face = ev.target.closest && ev.target.closest('.f');\n      g = { id: ev.pointerId, x0: ev.clientX, y0: ev.clientY, x: ev.clientX, y: ev.clientY, mode: 'orbit' };\n      if (face && face.classList.contains('c') && !busy && !queue.length) {\n        const cb = face.parentElement.__cb;\n        const n = rnd(mv(cb.R, FACES[+face.dataset.fi].n));\n        g.mode = 'pending'; g.cb = cb; g.n = n;\n      }\n      try { stage.setPointerCapture(ev.pointerId); } catch (e) {}\n      ev.preventDefault();\n    });\n\n    stage.addEventListener('pointermove', (ev) => {\n      if (!g || ev.pointerId !== g.id) return;\n      const dx = ev.clientX - g.x, dy = ev.clientY - g.y;\n      g.x = ev.clientX; g.y = ev.clientY;\n      if (g.mode === 'orbit') {\n        const len = Math.hypot(dx, dy);\n        if (len > 0) { view = mul(rot([-dy / len, dx / len, 0], len * 0.009), view); paintView(); }\n        return;\n      }\n      const tx = ev.clientX - g.x0, ty = ev.clientY - g.y0;\n      if (g.mode === 'pending') {\n        if (Math.hypot(tx, ty) < 10) return;\n        // pick the in-face direction whose screen projection best matches the swipe\n        let best = null;\n        for (const d of AXES.concat(AXES.map((a) => a.map((v) => -v)))) {\n          if (Math.abs(dot(d, g.n)) > 0.5) continue;\n          const s = toScreen(d), sl = Math.hypot(s[0], s[1]) || 1;\n          const score = (tx * s[0] + ty * s[1]) / sl;\n          if (!best || score > best.score) best = { d, s: [s[0] / sl, s[1] / sl], sl, score };\n        }\n        const axis = cross(g.n, best.d); // rotating +θ about n×d moves the sticker toward +d\n        g.mode = 'turn';\n        g.axis = axis;\n        g.dir = best.s;\n        g.scale = best.sl; // screen shrink of that direction\n        g.idx = Math.round(dot(g.cb.pos, axis));\n        g.layer = layerOf(axis, g.idx);\n      }\n      if (g.mode === 'turn') {\n        const along = tx * g.dir[0] + ty * g.dir[1];\n        g.angle = along / (S * 2.2 * Math.max(g.scale, 0.35)) * (Math.PI / 2);\n        const A = rot(g.axis, g.angle);\n        g.layer.forEach((c) => paint(c, A));\n      }\n    });\n\n    const end = (ev) => {\n      if (!g || ev.pointerId !== g.id) return;\n      const cur = g; g = null;\n      try { stage.releasePointerCapture(ev.pointerId); } catch (e) {}\n      if (cur.mode !== 'turn') return;\n      const quarter = cur.angle / (Math.PI / 2);\n      let q = Math.round(quarter);\n      if (q === 0 && Math.abs(quarter) > 0.28) q = Math.sign(quarter); // a short flick still turns\n      q = Math.max(-2, Math.min(2, q));\n      if (q === 0) {\n        busy = true;\n        animate(cur.layer, cur.axis, cur.angle, 0, 140, () => { busy = false; paintAll(); run(); });\n        return;\n      }\n      queue.unshift({ axis: cur.axis, idx: cur.idx, q, from: cur.angle, ms: 160, user: true });\n      run();\n    };\n    stage.addEventListener('pointerup', end);\n    stage.addEventListener('pointercancel', end);\n\n    build();\n    paintView();\n    layout();\n    if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(stage);\n    else window.addEventListener('resize', layout);\n  },\n};\n"}}}}}}}},{"_id":"6abf00de492457561460ce5d","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"Star_Fox_Mobile_Player","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// Mobile SNES player for Star Fox: the player picks their own ROM on the device (it never\n// leaves the browser), EmulatorJS + snes9x (Super FX) run from the \"starfox-emulator\" Drive,\n// the phone's tilt steers the D-pad and touching the screen fires.\nconst MEDIA = 'https://lucca-playground.data2.media/file/';\nconst FILES = {\n  'loader.js': 'sjn4NtjRSdexz2quJ5Icu',\n  'emulator.js': 'H9sfJ9h2M4sL9l0V8lTRF',\n  'GameManager.js': '7-WiYcktgqc2iE805n4WB',\n  'compression.js': 'Z_xhOCdoDAvuDa0e8_MoL',\n  'gamepad.js': 'eQ7YsA53xxlQhATIzf1Lk',\n  'nipplejs.js': 'tQ3GRf6D_m2B_DgJ5FwCv',\n  'shaders.js': 'QSnBt1zhdtx9pOlWWit7k',\n  'storage.js': 'D59vCRKTT6jeJNnN2g0UB',\n  'socket.io.min.js': 'UUZqWxMwZg2qimoR3Nw8b',\n  'emulator.css': 'MNqXIt8RLPj-pB2WuqG7Q',\n  'snes9x-wasm.data': 'pVUBC-nZEN82gyuTpPsRw',\n  'snes9x-legacy-wasm.data': 'myfEzZFCCUes_kFsjUUCd',\n  'snes9x.json': 'YNbaUeVsno2mDYsEOtp39',\n  // the core .data files are 7z archives; these workers unpack cores and zipped ROMs\n  'extract7z.js': 'CPfp383FjI0u02qrXlbzl',\n  'extractzip.js': 'iqumdLN_ZjOfnsttklMob',\n  'libunrar.js': 'chOV7VBt3LvWNwsFSwxGk',\n  'libunrar.wasm': '8ftfUbTkQF-wO5oIGWIt1',\n};\n\n// libretro joypad ids as EmulatorJS's simulateInput expects them\nconst BTN = { B: 0, Y: 1, SELECT: 2, START: 3, UP: 4, DOWN: 5, LEFT: 6, RIGHT: 7, A: 8, X: 9, L: 10, R: 11 };\n\nconst DEAD_ZONE = 4;   // degrees of tilt ignored around neutral\nconst FULL_TILT = 20;  // degrees of tilt that hold the direction permanently\nconst YAW_GAIN = 1.0;      // degrees of steering per degree of yaw (turning the phone left/right)\nconst YAW_RECENTER = 1.2;  // s: yaw leaks back to neutral, so you never have to hold your body turned\nconst STEER_SMOOTH = 0.06; // s: low-pass time constant on the combined steering angle\nconst INVERT_STEER = true; // horizontal axis flipped: rolling/turning right steers left\nconst INVERT_YAW = false;  // flip yaw alone if it ever disagrees with roll on a device\nconst INVERT_ROLL = true;  // flip roll alone (tilting like a steering wheel)\n\nconst SHOOT_OPTIONS = ['B', 'Y', 'A', 'X'];\n\nfunction emulatorPage(romUrl, romName) {\n  const paths = {};\n  for (const [name, ref] of Object.entries(FILES)) paths[name] = MEDIA + ref;\n  const cfg = {\n    EJS_player: '#game',\n    EJS_core: 'snes',\n    EJS_gameName: romName.replace(/\\.[^.]+$/, ''),\n    EJS_gameUrl: romUrl,\n    EJS_pathtodata: MEDIA,\n    EJS_paths: paths,\n    EJS_DEBUG_XX: true,          // load the unminified sources individually (that's what the Drive holds)\n    EJS_startOnLoaded: true,\n    EJS_language: 'en-US',\n    EJS_disableAutoLang: false,  // loader's flag is inverted: false skips the localization fetch\n    EJS_threads: false,\n    EJS_color: '#ff6a2b',\n    EJS_backgroundColor: '#000',\n    EJS_Buttons: { netplay: false, saveState: false, loadState: false, screenRecord: false, screenshot: false, cacheManager: false, gamepad: false },\n  };\n  const assigns = Object.entries(cfg).map(([k, v]) => `window.${k}=${JSON.stringify(v)};`).join('');\n  return `<!doctype html><html><head><meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\">\n<style>\nhtml,body{margin:0;height:100%;background:#000;overflow:hidden;touch-action:none}\n#game{width:100%;height:100%}\n.ejs_virtualGamepad_parent,.ejs_menu_bar,.ejs_context_menu{display:none!important}\n</style></head><body><div id=\"game\"></div>\n<script>${assigns}</script>\n<script src=\"${paths['loader.js']}\"></script>\n</body></html>`;\n}\n\nexport default {\n  css: `\n    :host{display:block;width:100%;height:100%}\n    .sf{position:relative;width:100%;height:100%;min-height:320px;overflow:hidden;background:#05060d;color:#e9ecff;\n      font-family:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,sans-serif;touch-action:none;user-select:none;-webkit-user-select:none}\n    .sf:fullscreen{width:100vw;height:100vh}\n    .stage{position:absolute;inset:0}\n    .emu{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;background:#000}\n    .touch{position:absolute;inset:0;z-index:2;display:none}\n    .playing .touch{display:block}\n    .hud{position:absolute;inset:0;z-index:3;pointer-events:none;display:none}\n    .playing .hud{display:block}\n    .hb{pointer-events:auto;position:absolute;border:1px solid rgba(255,255,255,.35);background:rgba(10,12,30,.45);color:#fff;\n      font-family:inherit;font-size:12px;font-weight:600;line-height:1;letter-spacing:.08em;border-radius:999px;padding:10px 14px;-webkit-tap-highlight-color:transparent}\n    .hb:active,.hb.on{background:rgba(255,106,43,.75);border-color:#ff6a2b}\n    .hb.start{top:max(10px,env(safe-area-inset-top));left:50%;transform:translateX(-50%)}\n    .hb.cal{top:max(10px,env(safe-area-inset-top));left:max(10px,env(safe-area-inset-left))}\n    .hb.bomb{bottom:max(14px,env(safe-area-inset-bottom));right:max(14px,env(safe-area-inset-right));width:68px;height:68px;padding:0;border-radius:50%}\n    .tilt{position:absolute;bottom:max(14px,env(safe-area-inset-bottom));left:max(14px,env(safe-area-inset-left));width:64px;height:64px;\n      border-radius:50%;border:1px solid rgba(255,255,255,.3);background:rgba(10,12,30,.35)}\n    .tilt i{position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:#ff6a2b;\n      box-shadow:0 0 10px #ff6a2b;transform:translate(0,0)}\n    .intro{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;\n      padding:24px;text-align:center;background:radial-gradient(ellipse at 50% 120%,#2a1240 0%,#0b0b22 45%,#05060d 75%)}\n    .intro::before{content:\"\";position:absolute;inset:0;pointer-events:none;opacity:.7;\n      background-image:radial-gradient(1px 1px at 12% 22%,#fff 50%,transparent 51%),radial-gradient(1px 1px at 72% 14%,#fff 50%,transparent 51%),\n      radial-gradient(1.5px 1.5px at 38% 68%,#cfd6ff 50%,transparent 51%),radial-gradient(1px 1px at 86% 58%,#fff 50%,transparent 51%),\n      radial-gradient(1px 1px at 22% 84%,#fff 50%,transparent 51%),radial-gradient(1.5px 1.5px at 58% 36%,#ffd9c7 50%,transparent 51%),\n      radial-gradient(1px 1px at 92% 88%,#fff 50%,transparent 51%),radial-gradient(1px 1px at 6% 52%,#cfd6ff 50%,transparent 51%)}\n    .playing .intro{display:none}\n    .intro > *{position:relative}\n    h1{margin:0;font-size:26px;letter-spacing:.18em;font-weight:800}\n    .sub{margin:0;font-size:13px;opacity:.7;max-width:340px;line-height:1.45}\n    .btn{appearance:none;border:0;border-radius:12px;padding:14px 22px;min-width:220px;font-family:inherit;font-size:15px;font-weight:700;line-height:1;letter-spacing:.06em;cursor:pointer;\n      -webkit-tap-highlight-color:transparent}\n    .pick{background:#1b1f3f;color:#fff;border:1px solid rgba(255,255,255,.25)}\n    .go{background:#ff6a2b;color:#140700}\n    .go[disabled]{opacity:.35}\n    .msg{min-height:18px;font-size:13px;color:#ffb38f;max-width:340px}\n    .help{list-style:none;margin:6px 0 0;padding:0;font-size:12px;opacity:.65;line-height:1.7}\n    .file{display:none}\n    .toast{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);max-width:60%;font-size:12px;\n      text-align:center;color:#ffb38f;background:rgba(5,6,13,.7);border-radius:8px;padding:6px 10px;pointer-events:none}\n    .toast:empty{display:none}\n  `,\n\n  render() {\n    return `<div class=\"sf\">\n      <div class=\"stage\" data-d2-static></div>\n      <div class=\"touch\" data-d2-static></div>\n      <div class=\"hud\" data-d2-static>\n        <button class=\"hb cal\" type=\"button\">LEVEL</button>\n        <button class=\"hb start\" type=\"button\">START</button>\n        <button class=\"hb bomb\" type=\"button\">BOMB</button>\n        <div class=\"tilt\"><i></i></div>\n        <div class=\"toast\"></div>\n      </div>\n      <div class=\"intro\" data-d2-static>\n        <h1>FLIGHT DECK</h1>\n        <p class=\"sub\">Load your own Star Fox (SNES) ROM. The file stays on this device and is never uploaded.</p>\n        <input class=\"file\" type=\"file\" accept=\".sfc,.smc,.zip,.7z,.bin\">\n        <button class=\"btn pick\" type=\"button\">Choose ROM</button>\n        <button class=\"btn go\" type=\"button\" disabled>Launch</button>\n        <p class=\"msg\"></p>\n        <ul class=\"help\">\n          <li>Tilt the phone to steer — hold it the way you like, it levels on launch</li>\n          <li>Touch anywhere to shoot (hold for rapid fire)</li>\n          <li>START pauses &middot; BOMB fires a bomb &middot; LEVEL re-centres the tilt</li>\n        </ul>\n      </div>\n    </div>`;\n  },\n\n  mounted(d2) {\n    const sf = d2.$('.sf');\n    const stage = d2.$('.stage');\n    const touch = d2.$('.touch');\n    const fileInput = d2.$('.file');\n    const goBtn = d2.$('.go');\n    const msg = d2.$('.msg');\n    const dot = d2.$('.tilt i');\n    const toast = d2.$('.toast');\n\n    const shootName = String(d2.param('shootButton') || 'Y').toUpperCase();\n    const SHOOT = BTN[SHOOT_OPTIONS.includes(shootName) ? shootName : 'Y'];\n\n    let rom = null;          // { url, name }\n    let frame = null;\n    let firing = 0;          // active touches on the play surface\n    let fireTick = 0;\n    const held = new Set();  // buttons currently pressed by this component\n    const tilt = { roll: 0, rollWeight: 0, pitch: 0, yaw: 0, ok: false };\n    const neutral = { roll: 0, pitch: 0 };\n    let prevR = null, prevT = 0;\n    let steer = 0, lastFrame = 0;\n    let needsLevel = true;\n    const acc = { x: 0, y: 0 };\n\n    const say = (t) => { msg.textContent = t || ''; toast.textContent = sf.classList.contains('playing') ? (t || '') : ''; };\n\n    const gm = () => {\n      try { return frame && frame.contentWindow && frame.contentWindow.EJS_emulator && frame.contentWindow.EJS_emulator.gameManager; }\n      catch (e) { return null; }\n    };\n    const press = (id, down) => {\n      const g = gm();\n      if (!g) return;\n      if (down === held.has(id)) return;\n      down ? held.add(id) : held.delete(id);\n      try { g.simulateInput(0, id, down ? 1 : 0); } catch (e) {}\n    };\n    const resumeAudio = () => {\n      try {\n        const ctx = frame.contentWindow.EJS_emulator.Module.AL.currentCtx.audioCtx;\n        if (ctx && ctx.state !== 'running') ctx.resume();\n      } catch (e) {}\n    };\n\n    // --- ROM selection: read locally, hand the emulator a blob: URL ---\n    d2.on('.pick', 'click', () => fileInput.click());\n    fileInput.addEventListener('change', () => {\n      const f = fileInput.files && fileInput.files[0];\n      if (!f) return;\n      if (rom) URL.revokeObjectURL(rom.url);\n      rom = { url: URL.createObjectURL(f), name: f.name };\n      goBtn.disabled = false;\n      say(f.name + ' ready');\n    });\n\n    // --- Tilt: device orientation -> gravity in screen coordinates ---\n    const screenAngle = () => {\n      const a = (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : (window.orientation || 0);\n      return ((a % 360) + 360) % 360;\n    };\n    const wrap = (deg) => ((deg + 540) % 360) - 180;\n    // W3C device->earth rotation matrix (Z-X'-Y'' Euler: alpha, beta, gamma)\n    const rotation = (a, b, g) => {\n      const cA = Math.cos(a), sA = Math.sin(a), cB = Math.cos(b), sB = Math.sin(b), cG = Math.cos(g), sG = Math.sin(g);\n      return [\n        [cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],\n        [cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],\n        [-cB * sG, sB, cB * cG],\n      ];\n    };\n    const onOrient = (e) => {\n      if (e.beta == null || e.gamma == null) return;\n      const r = Math.PI / 180;\n      const R = rotation((e.alpha || 0) * r, e.beta * r, e.gamma * r);\n      // \"up\" in device coordinates is the earth-z row of R\n      const ux = R[2][0], uy = R[2][1];\n\n      // Roll = steering-wheel rotation about the screen normal, read from where gravity points in the\n      // screen plane. It does not depend on the reported screen angle, and fades out as the phone goes\n      // flat (gravity leaves the screen plane), where yaw takes over the same motion.\n      const inPlane = Math.hypot(ux, uy);\n      const wheel = Math.atan2(uy, ux) / r;   // grows when the phone turns clockwise, as seen by the player\n      tilt.rollWeight = Math.max(0, Math.min(1, (inPlane - 0.2) / 0.3));\n\n      // Pitch = elevation of the screen's top edge (unchanged behaviour)\n      const t = screenAngle() * r;\n      const sy = ux * Math.sin(t) + uy * Math.cos(t);\n      tilt.pitch = Math.asin(Math.max(-1, Math.min(1, sy))) / r;\n\n      // Yaw = rotation about the earth's vertical between two samples, from the incremental rotation\n      // R_t * R_(t-1)^T. Integrated with a leak so compass drift and a turned body both re-centre.\n      const now = e.timeStamp || performance.now();\n      if (prevR && e.alpha != null) {\n        const dt = Math.max(0, Math.min(0.25, (now - prevT) / 1000));\n        let d10 = 0, d01 = 0, d00 = 0, d11 = 0;\n        for (let k = 0; k < 3; k++) {\n          d10 += R[1][k] * prevR[0][k]; d01 += R[0][k] * prevR[1][k];\n          d00 += R[0][k] * prevR[0][k]; d11 += R[1][k] * prevR[1][k];\n        }\n        const step = Math.atan2(d10 - d01, d00 + d11) / r;  // + = counter-clockwise seen from above (turning left)\n        if (Math.abs(step) < 30) tilt.yaw = (tilt.yaw - step) * Math.exp(-dt / YAW_RECENTER);  // + = turned right\n        tilt.yaw = Math.max(-45, Math.min(45, tilt.yaw));\n      }\n      prevR = R; prevT = now;\n\n      if (needsLevel) {\n        neutral.roll = wheel; neutral.pitch = tilt.pitch; tilt.yaw = 0; steer = 0; needsLevel = false;\n      }\n      tilt.roll = wrap(wheel - neutral.roll);\n      tilt.ok = true;\n    };\n    window.addEventListener('deviceorientation', onOrient);\n\n    const axis = (deg) => {\n      const m = Math.abs(deg);\n      if (m < DEAD_ZONE) return 0;\n      return Math.sign(deg) * Math.min(1, (m - DEAD_ZONE) / (FULL_TILT - DEAD_ZONE));\n    };\n    // The SNES pad is digital: pulse the direction with a duty cycle proportional to the tilt.\n    const pulse = (v, k) => {\n      const m = Math.abs(v);\n      if (m === 0) { acc[k] = 0; return 0; }\n      if (m >= 0.97) return Math.sign(v);\n      acc[k] += m;\n      if (acc[k] >= 1) { acc[k] -= 1; return Math.sign(v); }\n      return 0;\n    };\n\n    d2.raf(() => {\n      if (!sf.isConnected) return;\n      if (!gm()) return;\n      const invert = !!d2.param('invertPitch');\n      // horizontal: roll + yaw, low-passed so the pulses into the digital pad follow a smooth curve\n      const nowMs = performance.now();\n      const dt = lastFrame ? Math.min(0.1, (nowMs - lastFrame) / 1000) : 1 / 60;\n      lastFrame = nowMs;\n      const yawSign = INVERT_YAW ? -1 : 1;\n      const rollSign = INVERT_ROLL ? -1 : 1;\n      const target = tilt.ok ? rollSign * tilt.roll * tilt.rollWeight + YAW_GAIN * yawSign * tilt.yaw : 0;\n      steer += (target - steer) * (1 - Math.exp(-dt / STEER_SMOOTH));\n      const x = axis(INVERT_STEER ? -steer : steer);\n      // tilting the top edge away (nose forward) presses UP, like pushing a flight stick\n      let y = tilt.ok ? axis(neutral.pitch - tilt.pitch) : 0;\n      if (invert) y = -y;\n      dot.style.transform = `translate(${(x * 22).toFixed(1)}px,${(-y * 22).toFixed(1)}px)`;\n      const px = pulse(x, 'x'), py = pulse(y, 'y');\n      press(BTN.RIGHT, px > 0); press(BTN.LEFT, px < 0);\n      press(BTN.UP, py > 0); press(BTN.DOWN, py < 0);\n      // shooting: held touch = rapid fire (press 4 frames, release 4 frames)\n      if (firing > 0) {\n        const auto = d2.param('autoFire') !== false;\n        fireTick++;\n        press(SHOOT, auto ? (fireTick % 8) < 4 : true);\n      } else {\n        fireTick = 0;\n        press(SHOOT, false);\n      }\n    });\n\n    // --- Touch surface: any touch shoots ---\n    const touchDown = (e) => { e.preventDefault(); resumeAudio(); firing = e.touches ? e.touches.length : 1; };\n    const touchUp = (e) => { e.preventDefault(); firing = e.touches ? e.touches.length : 0; };\n    touch.addEventListener('touchstart', touchDown, { passive: false });\n    touch.addEventListener('touchend', touchUp, { passive: false });\n    touch.addEventListener('touchcancel', touchUp, { passive: false });\n    touch.addEventListener('mousedown', (e) => { resumeAudio(); firing = 1; e.preventDefault(); });\n    window.addEventListener('mouseup', () => { if (firing && !('ontouchstart' in window)) firing = 0; });\n\n    // --- HUD buttons: held for as long as the finger is down ---\n    const holdButton = (sel, id) => {\n      const el = d2.$(sel);\n      const down = (e) => { e.preventDefault(); e.stopPropagation(); resumeAudio(); el.classList.add('on'); press(id, true); };\n      const up = (e) => { e.preventDefault(); e.stopPropagation(); el.classList.remove('on'); press(id, false); };\n      el.addEventListener('touchstart', down, { passive: false });\n      el.addEventListener('touchend', up, { passive: false });\n      el.addEventListener('touchcancel', up, { passive: false });\n      el.addEventListener('mousedown', down);\n      el.addEventListener('mouseup', up);\n      el.addEventListener('mouseleave', up);\n    };\n    holdButton('.hb.start', BTN.START);\n    holdButton('.hb.bomb', BTN.A);\n    d2.on('.hb.cal', 'click', () => { needsLevel = true; });\n\n    // --- Launch: permissions need this user gesture, then boot the emulator in an iframe ---\n    d2.on('.go', 'click', () => {\n      if (!rom) return;\n      const DOE = window.DeviceOrientationEvent;\n      if (DOE && typeof DOE.requestPermission === 'function') {\n        DOE.requestPermission().then((s) => { if (s !== 'granted') say('Motion access denied — steering is disabled. Reload to try again.'); }).catch(() => {});\n      }\n      try { if (sf.requestFullscreen) sf.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}); } catch (e) {}\n      try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(() => {}); } catch (e) {}\n\n      stage.innerHTML = '';\n      frame = document.createElement('iframe');\n      frame.className = 'emu';\n      frame.title = 'SNES emulator';\n      frame.setAttribute('allow', 'autoplay; fullscreen; gamepad; accelerometer; gyroscope');\n      frame.srcdoc = emulatorPage(rom.url, rom.name);\n      stage.appendChild(frame);\n      needsLevel = true;\n      sf.classList.add('playing');\n      setTimeout(() => { if (!tilt.ok) say('No tilt sensor reported — steering needs a phone with a gyroscope.'); }, 3000);\n    });\n  },\n};\n"}},"autoFire":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"autoFire"}}}},"invertPitch":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertPitch"}}}},"shootButton":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"shootButton"}}}}}}}}}},{"_id":"6abff75fce21b725b3165a70","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"dataFox2_Player","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// dataFox2 — runs the Starship (SF64 PC port) WebAssembly build in a WebGL2 canvas,\n// with phone controls: gyroscope steering, a touch stick, and touch buttons.\n//\n// Input reaches the engine as a *virtual gamepad*: navigator.getGamepads() is wrapped so\n// SDL's emscripten joystick backend sees one \"standard\"-mapped pad whose axes come from the\n// gyro / touch stick and whose buttons come from the on-screen controls. Starship's default\n// SDL mapping then turns that into N64 input (A, B, Z, R, Start, C-buttons, stick).\n\nconst BUTTON = { A: 0, CDOWN: 1, B: 2, CLEFT: 3, Z: 4, R: 5, BACK: 8, START: 9, DUP: 12, DDOWN: 13, DLEFT: 14, DRIGHT: 15 }\n\nfunction makePad() {\n  const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }))\n  return {\n    id: 'dataFox2 virtual pad (standard)',\n    index: 0,\n    connected: true,\n    mapping: 'standard',\n    axes: [0, 0, 0, 0],\n    buttons,\n    timestamp: performance.now(),\n    vibrationActuator: null,\n  }\n}\n\nfunction installVirtualPad(pad) {\n  const nav = window.navigator\n  const original = nav.getGamepads ? nav.getGamepads.bind(nav) : () => []\n  const patched = () => {\n    pad.timestamp = performance.now()\n    const real = Array.from(original() || []).filter(Boolean)\n    return [pad, ...real.map((g, i) => g && { ...g, index: i + 1 })]\n  }\n  try {\n    Object.defineProperty(nav, 'getGamepads', { configurable: true, value: patched })\n  } catch (e) {\n    nav.getGamepads = patched\n  }\n  const ev = new Event('gamepadconnected')\n  ev.gamepad = pad\n  window.dispatchEvent(ev)\n  return () => {\n    try { Object.defineProperty(nav, 'getGamepads', { configurable: true, value: original }) } catch (e) {}\n  }\n}\n\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))\n\nexport default {\n  css: `\n    /* No long-press magnifier / callout / selection / tap flash anywhere in the game UI. */\n    .wrap, .wrap * { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n      -webkit-tap-highlight-color: transparent; touch-action: none; }\n    .wrap .login input { -webkit-user-select: text; user-select: text; touch-action: manipulation; }\n    .wrap { position: relative; display: block; width: 100%; height: 100%; background: #05060a;\n      overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e8ecf4; }\n    /* !important: SDL writes inline css sizes on resize; the canvas must always fill the component. */\n    canvas.game { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; outline: none; }\n    .crash { position: absolute; inset: 0; z-index: 9; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 12px; padding: 24px; box-sizing: border-box; background: #05060af2; text-align: center; }\n    .crash[hidden] { display: none; }\n    .crash pre { max-width: 92%; max-height: 40%; overflow: auto; margin: 0; padding: 10px; border-radius: 8px;\n      background: #0c1222; color: #ffb4b4; font: 11px/1.4 ui-monospace, Menlo, monospace; text-align: left; white-space: pre-wrap; }\n    .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 14px; background: radial-gradient(circle at 50% 40%, #16203a 0%, #05060a 70%);\n      text-align: center; padding: 24px; box-sizing: border-box; z-index: 5; }\n    .overlay[hidden] { display: none; }\n    .title { font-size: 28px; font-weight: 800; letter-spacing: 0.08em; }\n    .sub { font-size: 14px; opacity: 0.75; max-width: 420px; line-height: 1.4; }\n    .bar { width: min(320px, 70vw); height: 6px; border-radius: 3px; background: #1d2740; overflow: hidden; }\n    .bar > i { display: block; height: 100%; width: 0%; background: #5aa2ff; transition: width 0.2s; }\n    .go { appearance: none; border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 700;\n      background: #5aa2ff; color: #05060a; cursor: pointer; }\n    .go[disabled] { opacity: 0.4; }\n    .err { color: #ff8a8a; font-size: 13px; max-width: 460px; white-space: pre-wrap; }\n    .login { display: flex; flex-direction: column; gap: 10px; width: min(300px, 80vw); }\n    .login[hidden] { display: none; }\n    .login input { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 10px;\n      padding: 12px 14px; font-size: 16px; outline: none; }\n    .login input:focus { border-color: #5aa2ff; }\n    .login .go { width: 100%; }\n\n    .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }\n    .hud[hidden] { display: none; }\n    .btn { position: absolute; pointer-events: auto; display: flex; align-items: center; justify-content: center;\n      border-radius: 50%; background: rgba(255,255,255,0.14); border: 2px solid rgba(255,255,255,0.35);\n      color: #fff; font-weight: 800; font-size: 18px; box-sizing: border-box; backdrop-filter: blur(2px); }\n    .btn.on { background: rgba(90,162,255,0.55); border-color: #9cc6ff; }\n    .btn.pill { border-radius: 14px; font-size: 13px; letter-spacing: 0.05em; }\n    .a { width: 74px; height: 74px; right: calc(26px + env(safe-area-inset-right)); bottom: 34px; }\n    .b { width: 60px; height: 60px; right: calc(112px + env(safe-area-inset-right)); bottom: 22px; }\n    .r { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 124px; }\n    .z { width: 64px; height: 46px; left: calc(22px + env(safe-area-inset-left)); bottom: 186px; }\n    .boost { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 104px; font-size: 12px; }\n    .brake { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 24px; font-size: 12px; }\n    .start { width: 78px; height: 34px; left: 50%; transform: translateX(-50%); bottom: 14px; }\n    .top { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right));\n      display: flex; gap: 8px; pointer-events: auto; }\n    .chip { pointer-events: auto; appearance: none; border: 1px solid rgba(255,255,255,0.35); background: rgba(0,0,0,0.45);\n      color: #fff; border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 600; }\n    .chip.on { background: rgba(90,162,255,0.55); }\n    .chip { transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .chip.flash { background: rgba(90,162,255,0.85); border-color: #cfe3ff; transform: scale(0.92); }\n    .toast { position: absolute; top: calc(52px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);\n      padding: 6px 14px; border-radius: 999px; background: rgba(0,0,0,0.6); color: #fff; font-size: 13px; font-weight: 700;\n      letter-spacing: 0.04em; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* Swipe trail feedback (maneuvers / menu steps). */\n    .gesture-hint { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); font-size: 44px;\n      font-weight: 900; color: #fff; opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none;\n      text-shadow: 0 2px 12px rgba(0,0,0,0.6); }\n    .gesture-hint.show { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }\n    /* The hint glyphs, drawn (nearly invisibly) on the loading screen. iOS has no U+21B6 (the U-turn\n       arrow) in the system font, and the first time it is shown it loads a fallback font, which\n       froze the first U-turn of each session. Showing it here does that load while we wait. */\n    .glyph-warm { position: absolute; left: -9999px; top: 0; font-size: 44px; font-weight: 900;\n      pointer-events: none; user-select: none; }\n    .knob-base { position: absolute; width: 120px; height: 120px; margin: -60px 0 0 -60px; border-radius: 50%;\n      border: 2px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.06); display: none; }\n    .knob { position: absolute; width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;\n      background: rgba(255,255,255,0.35); display: none; }\n    .rotate { position: absolute; inset: 0; z-index: 8; display: none; align-items: center; justify-content: center;\n      background: #05060aee; font-size: 16px; text-align: center; padding: 24px; }\n    @media (orientation: portrait) and (pointer: coarse) { .rotate.armed { display: flex; } }\n  `,\n\n  // Render once; everything after mount is imperative (canvas + engine) so re-renders never touch it.\n  render() {\n    return `<div class=\"wrap\" data-d2-static>\n      <canvas class=\"game\" id=\"canvas\" tabindex=\"-1\"></canvas>\n      <div class=\"hud\" hidden>\n        <div class=\"gesture-hint\"></div>\n        <div class=\"btn a\" data-b=\"A\">A</div>\n        <div class=\"btn b\" data-b=\"B\">B</div>\n        <div class=\"btn pill r\" data-b=\"R\">R</div>\n        <div class=\"btn pill z\" data-b=\"Z\">Z</div>\n        <div class=\"btn boost\" data-b=\"CLEFT\">BOOST</div>\n        <div class=\"btn brake\" data-b=\"CDOWN\">BRAKE</div>\n        <div class=\"btn pill start\" data-b=\"START\">START</div>\n        <div class=\"toast\"></div>\n        <div class=\"top\">\n          <button class=\"chip gyro on\" data-act=\"gyro\">Gyro</button>\n          <button class=\"chip\" data-act=\"sens\">Tilt: Med</button>\n          <button class=\"chip\" data-act=\"center\">Center</button>\n          <button class=\"chip\" data-act=\"hud\">Hide</button>\n        </div>\n      </div>\n      <div class=\"overlay\">\n        <div class=\"glyph-warm\" aria-hidden=\"true\">↻↶▲▼▶◀</div>\n        <div class=\"title\">dataFox2</div>\n        <div class=\"sub status\">Preparing…</div>\n        <div class=\"bar\"><i></i></div>\n        <form class=\"login\" hidden autocomplete=\"on\">\n          <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\n          <input name=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n          <button class=\"go signin\" type=\"submit\">Sign in</button>\n        </form>\n        <button class=\"go start-btn\" disabled>Start</button>\n        <div class=\"err\"></div>\n      </div>\n      <div class=\"rotate\">Rotate your phone to landscape</div>\n      <div class=\"crash\" hidden>\n        <div class=\"title\">The game stopped</div>\n        <div class=\"sub crash-why\"></div>\n        <pre class=\"crash-log\"></pre>\n        <button class=\"go restart\">Restart</button>\n      </div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const canvas = $('canvas.game')\n    const overlay = $('.overlay')\n    const statusEl = $('.status')\n    const barEl = $('.bar > i')\n    const goBtn = $('.start-btn')\n    const loginForm = $('.login')\n    const errEl = $('.err')\n    const hud = $('.hud')\n\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const refs = {\n      js: d2.param('engineJs', ''),\n      wasm: d2.param('engineWasm', ''),\n      main: d2.param('mainO2r', ''),\n      port: d2.param('portO2r', ''),\n    }\n\n    const setStatus = (t) => { statusEl.textContent = t }\n    const setProgress = (f) => { barEl.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%` }\n    const fail = (msg) => { errEl.textContent = msg; setStatus('Could not start'); goBtn.disabled = true }\n\n    // Two ways in: a builder session (the Drive's builder-only policy) or a player session from\n    // the login strategy. Both live in localStorage; each request carries whichever exist.\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(`__data2-session-${systemDomain}`)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    class Refused extends Error {}\n\n    const progress = { total: 0, loaded: {} }\n    // When a download dies at the network level ('Load failed' / 'Failed to fetch'), find out which\n    // hop broke: the authorised request to the server, or the redirect it answers with.\n    const diagnose = async (ref) => {\n      try {\n        const r = await fetch(`${filesBase}/file/${ref}`, { headers: authHeaders(), redirect: 'manual' })\n        if (r.type === 'opaqueredirect') return 'server accepted the session; the browser blocked the redirect to the file'\n        return `server answered HTTP ${r.status} without redirecting`\n      } catch (e) {\n        return `the request to the server itself failed (${e && e.message})`\n      }\n    }\n\n    // Downloads go out as plain GETs with the key in the query string (the Drive has a policy that\n    // accepts it). A request with no custom headers needs no CORS preflight, and WebKit (every\n    // iPhone browser) refuses to follow the server's redirect to the signed file URL after one.\n    // The key itself is read through the session-gated keys DataSet, which involves no redirect.\n    const keysDataset = String(d2.param('keysDataset', 'datafox2keysDataSet'))\n    const getDownloadKey = async () => {\n      const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (res.status === 401 || res.status === 403) throw new Refused(`keys: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`keys: HTTP ${res.status}`)\n      const body = await res.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const k = rows[0] && rows[0].key\n      if (!k) throw new Refused('keys: no key readable for this session')\n      return k\n    }\n    let downloadKey = ''\n\n    const fetchBytes = async (key, ref) => {\n      if (!ref) throw new Error(`Missing file reference for ${key}`)\n      let res\n      try {\n        res = await fetch(`${filesBase}/file/${ref}?t=${encodeURIComponent(downloadKey)}`)\n      } catch (e) {\n        const ua = (navigator.userAgent.match(/(iPhone|iPad|Android|Macintosh|Windows)[^;)]*/) || [''])[0]\n        throw new Error(`${key}: ${e && e.message} | ${await diagnose(ref)} | ${ua}`)\n      }\n      if (res.status === 401 || res.status === 403) throw new Refused(`${key}: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`)\n      const len = Number(res.headers.get('content-length')) || 0\n      progress.total += len\n      if (!res.body || !len) return new Uint8Array(await res.arrayBuffer())\n      const reader = res.body.getReader()\n      const chunks = []\n      let got = 0\n      for (;;) {\n        const { done, value } = await reader.read()\n        if (done) break\n        chunks.push(value)\n        got += value.length\n        progress.loaded[key] = got\n        const sum = Object.values(progress.loaded).reduce((a, b) => a + b, 0)\n        setProgress(progress.total ? sum / progress.total : 0)\n      }\n      const out = new Uint8Array(got)\n      let o = 0\n      for (const c of chunks) { out.set(c, o); o += c.length }\n      return out\n    }\n\n    // ---- input state ---------------------------------------------------------------------\n    const pad = makePad()\n    let uninstallPad = null\n    // gyro: exact steering (-1..1, +x right, +y down). gyroPad: the same, pushed past\n    // libultraship's 20% stick deadzone, for engines without the direct stick export.\n    const input = { gyroOn: true, stickActive: false, stick: [0, 0], gyro: [0, 0], gyroPad: [0, 0], neutral: null, lastOri: null }\n\n    // Motion steering goes straight into the engine's stick (exact values, no gamepad deadzone or\n    // notch snapping) through web_set_stick (engine v10+). The touch stick, and older engines,\n    // go through the virtual gamepad.\n    const syncAxes = () => {\n      const direct = !!(engine && typeof engine._web_set_stick === 'function' && !crashed)\n      // Only once motion data is actually arriving (desktop keeps keyboard/gamepad sticks), and\n      // only while flying: in menus the tilt would wander the cursor.\n      const useGyro = input.gyroOn && !input.stickActive && !!input.hasMotion && uiMode() === 0\n      // On motion-controlled devices the maneuvers belong to the swipes: switch off the classic\n      // pull-back + Boost/Brake trigger so hard tilts never loop by accident (desktop keeps it).\n      if (input.hasMotion && !input.touchManeuvers && engine && typeof engine._web_set_touch_maneuvers === 'function' && !crashed) {\n        try { engine._web_set_touch_maneuvers(1); input.touchManeuvers = true } catch (e) {}\n      }\n      if (direct && input.pulse) {\n        // Gesture pulses (maneuvers, menu steps) are full-strength stick pushes, +y up.\n        try { engine._web_set_stick(1, input.pulse[0], input.pulse[1]) } catch (e) {}\n        pad.axes[0] = 0\n        pad.axes[1] = 0\n        return\n      }\n      if (direct) {\n        // Star Fox ignores raw stick values within +-16 (of 85) and saturates at 76, per axis\n        // (sys_joybus.c). Map our 0..1 onto exactly that live band, so the smallest tilt past our\n        // own tremor filter already turns the ship and full tilt is full turn.\n        const live = (v) => (v === 0 ? 0 : Math.sign(v) * (16.5 + Math.min(1, Math.abs(v)) * (76 - 16.5)) / 85)\n        try { engine._web_set_stick(useGyro ? 1 : 0, live(input.gyro[0]), live(-input.gyro[1])) } catch (e) {}\n      }\n      const src = input.pulse ? [input.pulse[0], -input.pulse[1]] : input.stickActive ? input.stick : (useGyro && !direct ? input.gyroPad : [0, 0])\n      pad.axes[0] = src[0]\n      pad.axes[1] = src[1]\n    }\n\n    const setButton = (name, down) => {\n      const i = BUTTON[name]\n      if (i == null) return\n      pad.buttons[i] = { pressed: down, touched: down, value: down ? 1 : 0 }\n    }\n\n    // Buttons: multi-touch, each pointer tracked to the button it started on.\n    // The engine samples the pad once per frame, so a quick tap is held for MIN_HOLD_MS —\n    // otherwise down+up can both land between two polls and the press is never seen.\n    const MIN_HOLD_MS = 80\n    const held = new Map()\n    d2.on('.btn', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      el.setPointerCapture?.(e.pointerId)\n      held.set(e.pointerId, { el, at: performance.now() })\n      el.classList.add('on')\n      setButton(el.dataset.b, true)\n      if (navigator.vibrate) navigator.vibrate(8)\n    })\n    const release = (e) => {\n      const h = held.get(e.pointerId)\n      if (!h) return\n      held.delete(e.pointerId)\n      const up = () => {\n        h.el.classList.remove('on')\n        setButton(h.el.dataset.b, false)\n      }\n      const left = MIN_HOLD_MS - (performance.now() - h.at)\n      if (left > 0) setTimeout(up, left)\n      else up()\n    }\n    d2.on(root, 'pointerup', release)\n    d2.on(root, 'pointercancel', release)\n\n    // ---- touch gestures on the game screen ------------------------------------------------\n    // Flying:  swipe up = somersault (pull back + Boost), swipe down = U-turn (pull back + Brake).\n    //          The game only triggers them from that combination inside a few frames, so the\n    //          swipe plays it for ~160 ms. Barrel roll stays on double-tapping Z / R.\n    // Menus:   tap = select (main menu cards are hit-tested by the engine; elsewhere a tap is A,\n    //          and START on the title screen); swipes step the cursor one notch.\n    // The engine says which applies (web_ui_mode); motion steering is off in menus.\n    const uiMode = () => {\n      try { return engine && typeof engine._web_ui_mode === 'function' ? engine._web_ui_mode() : 0 } catch (e) { return 0 }\n    }\n    const pressFor = (name, ms) => {\n      setButton(name, true)\n      setTimeout(() => setButton(name, false), ms)\n    }\n    const pulse = (x, y, ms) => {\n      input.pulse = [x, y]\n      syncAxes()\n      clearTimeout(input.pulseTimer)\n      input.pulseTimer = setTimeout(() => { input.pulse = null; syncAxes() }, ms)\n    }\n    let hintTimer = null\n    const hint = (glyph) => {\n      const h = $('.gesture-hint')\n      if (!h) return\n      h.textContent = glyph\n      h.classList.add('show')\n      clearTimeout(hintTimer)\n      hintTimer = setTimeout(() => h.classList.remove('show'), 350)\n    }\n    const maneuver = (kind, button, glyph) => {\n      // The engine starts the maneuver itself, under the game's own rules (full boost meter;\n      // U-turn only in all-range). No input macro: when it is not possible, nothing happens.\n      if (typeof engine._web_maneuver !== 'function') return\n      let ok = 0\n      try { ok = engine._web_maneuver(kind) } catch (e) { return }\n      if (!ok) return // not possible right now (boost recharging, on rails for a U-turn, ...)\n      hint(glyph)\n      try { navigator.vibrate && navigator.vibrate(15) } catch (e) {}\n    }\n    // Canvas point -> the game's 320x240 2D space (a 4:3 area centred in the canvas).\n    const toGame = (cx, cy) => {\n      const r = canvas.getBoundingClientRect()\n      const w = (r.height * 4) / 3\n      return [((cx - r.left - (r.width - w) / 2) / w) * 320, ((cy - r.top) / r.height) * 240]\n    }\n    const gestures = new Map()\n    const gestureTarget = (t) => !(t && t.closest && t.closest('.btn, .chip, .overlay, .crash, input'))\n    d2.on(root, 'pointerdown', (e) => {\n      if (!engine || crashed || !gestureTarget(e.target)) return\n      gestures.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() })\n    })\n    const endGesture = (e) => {\n      const g = gestures.get(e.pointerId)\n      if (!g) return\n      gestures.delete(e.pointerId)\n      if (e.type === 'pointercancel' || !engine || crashed) return\n      const dx = e.clientX - g.x\n      const dy = e.clientY - g.y\n      const dt = performance.now() - g.t\n      const dist = Math.hypot(dx, dy)\n      const mode = uiMode()\n      if (dist < 14 && dt < 400) {\n        if (mode !== 1) return // taps do nothing while flying\n        const [gx, gy] = toGame(e.clientX, e.clientY)\n        let code = 0\n        try { code = typeof engine._web_tap === 'function' ? engine._web_tap(gx, gy) : 0 } catch (err) { code = 0 }\n        if (code === 0) pressFor('A', 120)\n        else if (code === 2) pressFor('START', 120)\n        return\n      }\n      if (dist < 40 || dt > 600) return\n      const vertical = Math.abs(dy) > 1.3 * Math.abs(dx)\n      const horizontal = Math.abs(dx) > 1.3 * Math.abs(dy)\n      if (mode === 1) {\n        // One cursor step per swipe, as a short full stick push.\n        if (vertical) { pulse(0, dy < 0 ? 1 : -1, 130); hint(dy < 0 ? '▲' : '▼') }\n        else if (horizontal) { pulse(dx > 0 ? 1 : -1, 0, 130); hint(dx > 0 ? '▶' : '◀') }\n        return\n      }\n      if (vertical) {\n        if (dy < 0) maneuver(1, 'CLEFT', '↻') // up: somersault\n        else maneuver(2, 'CDOWN', '↶')        // down: U-turn\n      }\n    }\n    d2.on(root, 'pointerup', endGesture)\n    d2.on(root, 'pointercancel', endGesture)\n\n    // ---- motion steering: 6-axis sensor fusion -------------------------------------------\n    // The gyroscope (rotationRate, deg/s) is the primary signal: fast and clean, so no heavy\n    // smoothing (and no lag). The accelerometer only anchors it: a complementary filter pulls the\n    // gyro-propagated gravity estimate toward the measured one, trusting the measurement less\n    // when its magnitude is far from 1 g (hand jolts, not tilt).\n    //   vertical   = pitch: tipping the top edge toward / away     (absolute, gravity-anchored)\n    //   horizontal = roll (turning the phone like a wheel)         (absolute, gravity-anchored)\n    //              + yaw  (turning it like a tray, about the real-world vertical, whatever the\n    //                      phone's tilt). Yaw has no absolute reference: it holds while you\n    //                      hold the turn, fades back over ~20 s, and Center zeroes it.\n    // Angles are relative to a neutral pose captured at start, on rotation, and by Center.\n    const SENS = [{ label: 'Low', deg: 28 }, { label: 'Med', deg: 20 }, { label: 'High', deg: 14 }]\n    let sensIdx = 1\n    // The Tilt chip owns sensitivity; the instance's gyroRangeDeg is only a hint for the start level.\n    const hinted = Number(d2.param('gyroRangeDeg', 0)) || 0\n    if (hinted) sensIdx = SENS.reduce((best, s, i) => (Math.abs(s.deg - hinted) < Math.abs(SENS[best].deg - hinted) ? i : best), 0)\n    const rollRange = () => SENS[sensIdx].deg        // degrees of wheel-turn for full stick\n    const pitchRange = () => SENS[sensIdx].deg       // degrees of tip for full stick\n    const yawRange = () => SENS[sensIdx].deg * 1.5   // degrees of tray-turn for full stick\n    // Yaw holds while the phone is held turned (like roll), and fades back only slowly so leftover\n    // gyro drift can never build up. (It was 2 s: the ship turned, then drifted back mid-hold.)\n    const YAW_RECENTER_S = 20\n    const YAW_RATE_DEAD = 0.3   // deg/s: ignore residual rate when still\n    const YAW_BIAS_S = 5        // gyro bias learning time constant\n    const YAW_BIAS_GATE = 1.5   // deg/s: only learn bias while (nearly) not turning\n    const DEAD = 0.0075                              // soft deadzone (hand tremor), stick units\n    const LUS_DEAD = 0.21                            // only for the virtual-pad fallback path\n    const G = 9.81\n    const DEG = 180 / Math.PI\n\n    const screenAngle = () => {\n      const a = screen.orientation && typeof screen.orientation.angle === 'number' ? screen.orientation.angle : (window.orientation || 0)\n      return ((a % 360) + 360) % 360\n    }\n    // Device x/y (portrait frame) -> screen x (right) / y (up) for the current rotation; z unchanged.\n    const toScreen = (x, y) => {\n      switch (screenAngle()) {\n        case 90: return [y, -x]\n        case 180: return [-x, -y]\n        case 270: return [-y, x]\n        default: return [x, y]\n      }\n    }\n    const wrapDeg = (d) => ((d + 540) % 360) - 180\n    const clamp1 = (v) => clamp(v, -1, 1)\n    // Soft deadzone + mild curve: tremor vanishes, precision near centre, full range kept.\n    const shapeAxis = (v) => {\n      const a = Math.abs(v)\n      if (a <= DEAD) return 0\n      const n = Math.min(1, (a - DEAD) / (1 - DEAD))\n      return Math.sign(v) * n * (0.85 + 0.15 * n)\n    }\n    // Tiered smoothing: only tiny changes are smoothed; real motions pass straight through.\n    const out = [0, 0]\n    const smoothTiered = (i, v) => {\n      const d = v - out[i]\n      const w = 0.35 + 0.65 * Math.min(1, Math.abs(d) / 0.06)\n      out[i] += w * d\n      return out[i]\n    }\n\n    const fusion = { f: null, t: 0, yaw: 0, yawBias: 0, neutralRef: null }\n    const norm = (v) => { const m = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / m, v[1] / m, v[2] / m] }\n    // A world-fixed vector seen from the device turns opposite to the device: dv = -w x v dt.\n    const rotStep = (v, w, dt) => norm([\n      v[0] - (w[1] * v[2] - w[2] * v[1]) * dt,\n      v[1] - (w[2] * v[0] - w[0] * v[2]) * dt,\n      v[2] - (w[0] * v[1] - w[1] * v[0]) * dt,\n    ])\n\n    // Gyro axis order. The spec says rotationRate alpha/beta/gamma are rates about the device's\n    // z/x/y axes; WebKit (every iPhone browser) reports them about x/y/z. Read the wrong way,\n    // pitching leaks into yaw and the ship moves diagonally. Rather than trust either, keep\n    // checking which mapping (and sign) predicts the accelerometer's view of gravity best and use\n    // that one; the platform only sets the starting guess.\n    const GYRO_MAPS = [\n      (r) => [r.beta, r.gamma, r.alpha],\n      (r) => [r.alpha, r.beta, r.gamma],\n      (r) => [-r.beta, -r.gamma, -r.alpha],\n      (r) => [-r.alpha, -r.beta, -r.gamma],\n    ]\n    const isWebKitMobile = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n    const cal = { score: GYRO_MAPS.map(() => 0), best: isWebKitMobile ? 1 : 0, q: null, n: 0 }\n    const calibrate = (cands, m, dt, mag) => {\n      // Only while the accelerometer reads clean gravity (no jolts).\n      if (!cal.q || Math.abs(mag - G) > 0.08 * G) { cal.q = cands.map(() => m); cal.n = 0; return }\n      cal.q = cal.q.map((q, i) => rotStep(q, cands[i], dt))\n      if (++cal.n < 8) return\n      // Over ~130 ms windows with real rotation (> ~20 deg/s), score each mapping's prediction.\n      if (Math.hypot(cands[0][0], cands[0][1], cands[0][2]) > 0.35) {\n        cal.q.forEach((q, i) => { cal.score[i] = cal.score[i] * 0.97 + (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 })\n        let best = cal.best\n        cal.score.forEach((s, i) => { if (s < cal.score[best] * 0.6) best = i })\n        cal.best = best\n      }\n      cal.q = cands.map(() => m)\n      cal.n = 0\n    }\n\n    const publish = (h, v) => {\n      // h: +right, v: +down (gamepad convention). Default pitch: tipping the top edge away climbs\n      // (flight-sim style); invertY flips it.\n      const invertY = !!d2.param('invertY', false)\n      const sx = smoothTiered(0, shapeAxis(clamp1(h)))\n      const sy = smoothTiered(1, shapeAxis(clamp1(v)) * (invertY ? 1 : -1))\n      input.gyro = [sx, sy]\n      const fb = (x) => (x === 0 ? 0 : Math.sign(x) * (LUS_DEAD + (1 - LUS_DEAD) * Math.abs(x)))\n      input.gyroPad = [fb(sx), fb(sy)]\n      syncAxes()\n    }\n\n    let gotMotion = false\n    d2.on(window, 'devicemotion', (e) => {\n      const a = e.accelerationIncludingGravity\n      if (!a || a.x == null || a.y == null || a.z == null) return\n      gotMotion = true\n      input.hasMotion = true\n      const now = performance.now()\n      const dt = fusion.t ? Math.min(0.1, Math.max(0.001, (now - fusion.t) / 1000)) : (e.interval > 1 ? e.interval / 1000 : (e.interval || 0.016))\n      fusion.t = now\n\n      // Everything in the screen frame: x right, y up, z out of the screen.\n      const [ax, ay] = toScreen(a.x, a.y)\n      const meas = [ax, ay, a.z]\n      const mag = Math.hypot(meas[0], meas[1], meas[2])\n      const r = e.rotationRate\n      const haveGyro = !!(r && r.alpha != null && r.beta != null && r.gamma != null)\n      let w = [0, 0, 0]\n      if (haveGyro) {\n        // Each candidate mapping as a device-frame rate vector, then into the screen frame (rad/s).\n        const cands = GYRO_MAPS.map((fn) => {\n          const d = fn(r)\n          const [sx, sy] = toScreen(d[0], d[1])\n          return [sx / DEG, sy / DEG, d[2] / DEG]\n        })\n        calibrate(cands, norm(meas), dt, mag)\n        w = cands[cal.best]\n      }\n\n      if (!fusion.f || !haveGyro) {\n        fusion.f = norm(meas)\n      } else {\n        const pred = rotStep(fusion.f, w, dt)\n        // Trust the accelerometer only when it reads ~1 g AND the phone is not rotating: turning the\n        // phone swings it on an arc, and that sideways push read as a tilt (a phantom roll).\n        const spin = Math.hypot(w[0], w[1], w[2]) * DEG\n        const trust = Math.exp(-Math.pow((mag - G) / (0.15 * G), 2)) * Math.exp(-Math.pow(spin / 45, 2))\n        const k = Math.min(1, 0.5 * dt) * trust                     // ~2 s time constant at rest\n        const m = norm(meas)\n        fusion.f = norm([pred[0] + k * (m[0] - pred[0]), pred[1] + k * (m[1] - pred[1]), pred[2] + k * (m[2] - pred[2])])\n      }\n      const f = fusion.f\n\n      // Absolute angles from the fused gravity (either sign convention works: we use deltas).\n      const rollDeg = Math.atan2(f[0], -f[1]) * DEG\n      const pitchDeg = Math.atan2(f[2], -f[1]) * DEG\n      input.lastOri = [rollDeg, pitchDeg]\n      if (!input.neutral) input.neutral = [rollDeg, pitchDeg]\n      if (fusion.neutralRef !== input.neutral) { fusion.neutralRef = input.neutral; fusion.yaw = 0 }\n\n      // Yaw about the real-world vertical: project the rotation rate onto 'up'. 'Up' is the fused\n      // gravity axis, signed so it points toward the top of the screen / out of a tipped-back screen,\n      // which is independent of each browser's accelerometer sign convention.\n      if (haveGyro) {\n        const s = (f[1] + f[2]) >= 0 ? 1 : -1\n        const yawRate = (w[0] * f[0] + w[1] * f[1] + w[2] * f[2]) * s * DEG // deg/s, + = turning left\n        // Sign confirmed on a real phone (twice): this way, turning the phone right steers right.\n        // (The desktop motion simulator suggests the opposite; trust the device.)\n        let yr = yawRate\n        if (Math.abs(yr - fusion.yawBias) < YAW_BIAS_GATE) fusion.yawBias += (yr - fusion.yawBias) * Math.min(1, dt / YAW_BIAS_S)\n        yr -= fusion.yawBias\n        if (Math.abs(yr) < YAW_RATE_DEAD) yr = 0\n        fusion.yaw = (fusion.yaw + yr * dt) * Math.exp(-dt / YAW_RECENTER_S)\n      }\n\n      const roll = wrapDeg(rollDeg - input.neutral[0])\n      const pitch = wrapDeg(pitchDeg - input.neutral[1])\n      publish(roll / rollRange() + fusion.yaw / yawRange(), pitch / pitchRange())\n    })\n    // Fallback for browsers that only expose orientation angles (no gyro rate, no yaw).\n    d2.on(window, 'deviceorientation', (e) => {\n      if (gotMotion || e.beta == null || e.gamma == null) return\n      input.hasMotion = true\n      const [x, y] = toScreen(e.gamma, -e.beta)\n      input.lastOri = [x, -y]\n      if (!input.neutral) input.neutral = [x, -y]\n      publish(wrapDeg(x - input.neutral[0]) / rollRange(), wrapDeg(-y - input.neutral[1]) / pitchRange())\n    })\n    d2.on(window, 'orientationchange', () => { input.neutral = null; fusion.f = null; cal.q = null })\n\n    // Press feedback for the chips, like the game buttons: a flash, a tick of vibration where the\n    // browser supports it, and a short message for actions with no other visible effect.\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      if (!t) return\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1100)\n    }\n    d2.on('.chip', 'pointerdown', (e, el) => {\n      el.classList.add('flash')\n      setTimeout(() => el.classList.remove('flash'), 160)\n      try { navigator.vibrate && navigator.vibrate(10) } catch (err) {}\n    })\n\n    d2.on('.chip', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'gyro') {\n        input.gyroOn = !input.gyroOn\n        el.classList.toggle('on', input.gyroOn)\n        syncAxes()\n      } else if (act === 'sens') {\n        sensIdx = (sensIdx + 1) % SENS.length\n        el.textContent = `Tilt: ${SENS[sensIdx].label}`\n        toast(`Tilt sensitivity: ${SENS[sensIdx].label}`)\n      } else if (act === 'center') {\n        input.neutral = input.lastOri ? [...input.lastOri] : null\n        toast(input.hasMotion ? 'Centered' : 'No motion sensor')\n      } else if (act === 'hud') {\n        const hidden = root.querySelectorAll('.btn, .stickzone')\n        const now = el.textContent === 'Hide'\n        hidden.forEach((n) => { n.style.visibility = now ? 'hidden' : '' })\n        el.textContent = now ? 'Show' : 'Hide'\n      }\n    })\n\n    // ---- engine boot ---------------------------------------------------------------------\n    let blobs = []\n    let started = false\n    let files = null\n    let engine = null\n    let crashed = false\n\n    // Last engine log lines, shown on the crash screen so a failure can be reported.\n    const logTail = []\n    const remember = (line) => { logTail.push(String(line).slice(0, 300)); if (logTail.length > 40) logTail.shift() }\n\n    // SDL's web audio lives on Module.SDL2.audioContext. When the engine dies, its audio callback\n    // keeps replaying the last buffer (the stuck shooting sound), so close the context outright.\n    const audioContext = () => (engine && engine.SDL2 && engine.SDL2.audioContext) || null\n    const stopAudio = () => { try { const ac = audioContext(); if (ac && ac.state !== 'closed') ac.close() } catch (e) {} }\n    const resumeAudio = () => {\n      if (crashed) return\n      try { const ac = audioContext() || unlockedAudio; if (ac && ac.state !== 'running' && ac.state !== 'closed') ac.resume() } catch (e) {}\n    }\n    // Created and started inside the Start tap, then given to the engine.\n    let unlockedAudio = null\n    const unlockAudio = () => {\n      // Play through the iPhone silent switch, like a game or video app (Safari 16.4+).\n      try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      try {\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx || unlockedAudio) return\n        // The game synthesises at 32 kHz: running the context at that rate lets the browser resample\n        // natively instead of SDL doing it in WebAssembly every audio callback.\n        try { unlockedAudio = new Ctx({ sampleRate: 32000 }) } catch (e) { unlockedAudio = new Ctx() }\n        unlockedAudio.resume()\n        // A one-sample silent buffer started within the gesture fully unlocks older iOS.\n        const src = unlockedAudio.createBufferSource()\n        src.buffer = unlockedAudio.createBuffer(1, 1, 22050)\n        src.connect(unlockedAudio.destination)\n        src.start(0)\n      } catch (e) { unlockedAudio = null }\n    }\n\n    const crash = (why, stack) => {\n      if (crashed) return\n      crashed = true\n      stopAudio()\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      const box = $('.crash')\n      box.querySelector('.crash-why').textContent = String(why || 'Unknown error').slice(0, 300)\n      // Stack first (the engine is built with function names), then the engine's last log lines.\n      const frames = String(stack || '').split('\\n').map((l) => l.trim()).filter(Boolean).slice(0, 14)\n      box.querySelector('.crash-log').textContent = [...frames, frames.length ? '----' : '', ...logTail.slice(-15)].filter(Boolean).join('\\n')\n      box.hidden = false\n      console.error('[dataFox2] crashed:', why)\n    }\n    d2.on('.restart', 'click', () => location.reload())\n\n    // Only engine failures count: the host page can raise its own unrelated errors.\n    const isEngineError = (err, filename) =>\n      String(filename || '').startsWith('blob:') ||\n      (err && (err instanceof WebAssembly.RuntimeError || /Aborted|RuntimeError|wasm|unreachable|memory access out of bounds/i.test(String(err.message || err))))\n    d2.on(window, 'error', (e) => {\n      if (!engine || !isEngineError(e.error || e.message, e.filename)) return\n      // Name the source too: the engine and this component can both be blob: scripts.\n      const where = `at ${String(e.filename || '?').slice(-60)}:${e.lineno || 0}:${e.colno || 0}`\n      crash(e.message || e.error, [where, (e.error && e.error.stack) || ''].join('\\n'))\n    })\n    d2.on(window, 'unhandledrejection', (e) => { if (engine && isEngineError(e.reason)) crash(e.reason && (e.reason.message || e.reason), e.reason && e.reason.stack) })\n    d2.on(canvas, 'webglcontextlost', (e) => {\n      e.preventDefault()\n      crash('The browser dropped the graphics context (usually low memory on the phone).')\n    })\n    // Hidden tab: silence it; closing/leaving: shut audio down so nothing outlives the page.\n    d2.on(document, 'visibilitychange', () => {\n      const ac = audioContext()\n      if (!ac || crashed) return\n      try { document.hidden ? ac.suspend() : ac.resume() } catch (e) {}\n    })\n    // Leaving for good closes the audio; a page kept in memory (home-screen app switched away, back/\n    // forward cache) only suspends it, so it can play again when the player returns.\n    d2.on(window, 'pagehide', (e) => {\n      if (e && e.persisted) { try { const ac = audioContext(); if (ac && ac.state === 'running') ac.suspend() } catch (err) {} }\n      else stopAudio()\n    })\n    d2.on(window, 'pageshow', () => resumeAudio())\n    // Any tap is a chance to (re)start audio iOS suspended or interrupted (calls, Siri, other apps).\n    d2.on(root, 'pointerup', () => resumeAudio())\n    d2.on(root, 'pointerdown', () => resumeAudio())\n    d2.onUnmount(stopAudio)\n\n    const showLogin = (msg) => {\n      loginForm.hidden = false\n      goBtn.hidden = true\n      errEl.textContent = msg || ''\n      setStatus('Sign in to play')\n    }\n\n    d2.on(loginForm, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(loginForm)\n      const btn = loginForm.querySelector('.signin')\n      btn.disabled = true\n      errEl.textContent = ''\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        if (!auth) throw new Error('Login is not configured')\n        // AuthenticationStrategy.login takes { credentials, metadata? } and merges credentials in.\n        await auth.login({\n          credentials: { email: String(fd.get('email') || '').trim(), password: String(fd.get('password') || '') },\n        })\n        loginForm.hidden = true\n        goBtn.hidden = false\n        progress.total = 0\n        progress.loaded = {}\n        preload()\n      } catch (err) {\n        errEl.textContent = 'Sign-in failed. Check your email and password.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    const preload = async () => {\n      try {\n        if (!window.WebGL2RenderingContext) throw new Error('This browser has no WebGL2.')\n        setStatus('Downloading game files…')\n        errEl.textContent = ''\n        downloadKey = await getDownloadKey()\n        const [js, wasm, main, port] = await Promise.all([\n          fetchBytes('js', refs.js), fetchBytes('wasm', refs.wasm),\n          fetchBytes('main', refs.main), fetchBytes('port', refs.port),\n        ])\n        files = { js, wasm, main, port }\n        setProgress(1)\n        setStatus('Ready. Tap Start (sound and motion need a tap).')\n        goBtn.disabled = false\n      } catch (e) {\n        if (e instanceof Refused) showLogin()\n        else fail(String(e && e.message || e))\n      }\n    }\n\n    const askMotionPermission = async () => {\n      // iOS gates motion behind a prompt that must come from a tap; ask for both event types.\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') {\n          try { await Ev.requestPermission() } catch (e) { /* declined; the touch stick still works */ }\n        }\n      }\n    }\n\n    const enterFullscreen = async () => {\n      const el = d2.root.host || root\n      try { if (el.requestFullscreen && !document.fullscreenElement) await el.requestFullscreen({ navigationUI: 'hide' }) } catch (e) {}\n      try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n      try { await navigator.wakeLock?.request?.('screen') } catch (e) {}\n    }\n\n    // ---- warm-up list learned on this device --------------------------------------------\n    const WARM_KEY = 'datafox2.warm.v1'\n    const learned = new Set()\n    try { (localStorage.getItem(WARM_KEY) || '').split('\\n').filter(Boolean).forEach((l) => learned.add(l)) } catch (e) {}\n    let warmSaveTimer = null\n    let warming = false // items the warm-up itself loads are already listed: don't re-learn them\n    const learnWarm = (line) => {\n      if (warming || learned.has(line) || learned.size > 30000) return\n      learned.add(line)\n      clearTimeout(warmSaveTimer)\n      warmSaveTimer = setTimeout(() => { try { localStorage.setItem(WARM_KEY, [...learned].join('\\n')) } catch (e) {} }, 3000)\n    }\n    const warmUp = (M) => new Promise((resolve) => {\n      if (typeof M._web_warm_step !== 'function') return resolve()\n      const started = performance.now()\n      const done = () => { warming = false; resolve() }\n      warming = true\n      const step = () => {\n        let left = 0\n        try { left = M._web_warm_step(14) } catch (e) { return done() }\n        if (left > 0 && performance.now() - started < 8000) requestAnimationFrame(step)\n        else done()\n      }\n      requestAnimationFrame(step)\n    })\n\n    const boot = async () => {\n      if (started || !files) return\n      started = true\n      goBtn.disabled = true\n      // Must run synchronously inside the Start tap: iOS only lets audio start from a user gesture,\n      // and the engine creates its output several awaits later (activation can be gone by then).\n      unlockAudio()\n      setStatus('Starting engine…')\n      await Promise.all([askMotionPermission(), enterFullscreen()])\n      $('.rotate').classList.add('armed')\n\n      const jsUrl = URL.createObjectURL(new Blob([files.js], { type: 'text/javascript' }))\n      blobs.push(jsUrl)\n      try {\n        // Always load this build's loader: a createStarship left on the page by an earlier mount\n        // (a resumed home-screen app, a remount without reload) belongs to an older engine and\n        // would pair old JS glue with the new wasm, hiding the new exports.\n        try { delete window.createStarship } catch (e) { window.createStarship = undefined }\n        await new Promise((resolve, reject) => {\n          // A private blob, not a CDN library — d2.lib does not apply here.\n          const s = document.createElement('script')\n          s.src = jsUrl\n          s.onload = resolve\n          s.onerror = () => reject(new Error('Engine script failed to load'))\n          document.head.appendChild(s)\n        })\n        const factory = window.createStarship\n        if (typeof factory !== 'function') throw new Error('Engine factory missing')\n\n        const Module = await factory({\n          canvas,\n          noInitialRun: true,\n          wasmBinary: files.wasm,\n          print: (t) => { remember(t); console.log('[starship]', t) },\n          printErr: (t) => { remember(t); console.warn('[starship]', t) },\n          onNewShader: (lo, hi, id1) => { diag.shd++; learnWarm(`S ${lo} ${hi} ${id1}`) },\n          onNewResource: (path) => { diag.res.push(path); learnWarm(`R ${path}`) },\n          onAbort: (what) => crash(`Engine aborted: ${what}`, new Error('abort').stack),\n          preRun: [(M) => {\n            // SDL addresses its canvas as '#canvas'; the canvas lives in a shadow root that\n            // document.querySelector cannot see, so map the selector to the element directly.\n            if (M.specialHTMLTargets) M.specialHTMLTargets['#canvas'] = canvas\n            // Hand SDL the context unlocked by the Start tap (SDL reuses Module.SDL2.audioContext).\n            if (unlockedAudio) { M.SDL2 = M.SDL2 || {}; M.SDL2.audioContext = unlockedAudio }\n            try { M.FS.writeFile('/warmup.txt', [...learned].join('\\n')) } catch (e) {}\n            M.FS.writeFile('/sf64.o2r', files.main)\n            M.FS.writeFile('/starship.o2r', files.port)\n          }],\n        })\n        engine = Module\n        files = null // let the download buffers go; the engine holds its own copies\n        watchFrames()\n        setStatus('Preparing graphics…')\n        // Install before main so SDL's joystick enumeration at init already sees the pad.\n        uninstallPad = installVirtualPad(pad)\n        canvas.focus()\n        try {\n          Module.callMain([])\n        } catch (e) {\n          // emscripten_set_main_loop(..., simulate_infinite_loop=1) unwinds out of main.\n          if (!(e === 'unwind' || (e && e.name === 'ExitStatus'))) throw e\n        }\n        // And announce it again now that SDL's gamepadconnected listener is registered.\n        const ev = new Event('gamepadconnected')\n        ev.gamepad = pad\n        window.dispatchEvent(ev)\n        // Behind the loading screen, compile the shaders and load the resources gameplay will ask\n        // for (the engine's own list plus what this device learned), so first use does not stall.\n        await warmUp(Module)\n        overlay.hidden = true\n        hud.hidden = false\n      } catch (e) {\n        if (e && (e.name === 'ExitStatus' || e === 'unwind')) return\n        overlay.hidden = false\n        fail(String(e && e.message || e))\n      }\n    }\n\n    // Freeze watchdog: a frozen engine raises no error, so the crash screen never came up and the\n    // last audio buffer kept looping. The renderer clears its target once per frame, so stamp that\n    // (one call per frame, not per draw); no frame for a few seconds while visible = frozen.\n    let lastDraw = 0\n    // Hitch recorder (diagnostics for first-use stalls on phones). Per frame it counts what the\n    // engine created and times the WebGL calls and rAF callbacks; a frame over 150 ms while flying\n    // is reported on screen (and kept in localStorage 'datafox2.hitches').\n    const diag = { res: [], shd: 0, n: {}, slow: ['', 0], cpu: 0, last: 0, reports: 0 }\n    const diagReset = () => { diag.res = []; diag.shd = 0; diag.n = {}; diag.slow = ['', 0]; diag.cpu = 0 }\n    const diagBox = () => {\n      let el = root.querySelector('.diag')\n      if (!el) {\n        el = document.createElement('div')\n        el.className = 'diag'\n        el.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:9;max-width:92%;padding:6px 10px;border-radius:8px;background:rgba(0,0,0,0.75);color:#ffd27a;font:600 11px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap'\n        root.querySelector('.wrap').appendChild(el)\n      }\n      return el\n    }\n    const diagFrame = (now) => {\n      const gap = now - diag.last\n      if (diag.last && gap > 150 && !document.hidden && uiMode() === 0 && diag.reports < 6) {\n        diag.reports++\n        const n = diag.n\n        const parts = [`Hitch ${Math.round(gap)}ms`, `js ${Math.round(diag.cpu)}ms`,\n          `slowest gl: ${diag.slow[0] || '-'} ${Math.round(diag.slow[1])}ms`,\n          `draws ${n.drawArrays || 0}`, `newTex ${n.createTexture || 0}`, `texUpload ${n.texImage2D || 0}`,\n          `shaders ${diag.shd}/${n.linkProgram || 0}`, `readback ${(n.readPixels || 0) + (n.blitFramebuffer || 0)}`,\n          `res ${diag.res.length}${diag.res.length ? ' (' + diag.res.slice(0, 3).map((p) => p.split('/').pop()).join(', ') + ')' : ''}`]\n        const msg = parts.join(' · ')\n        const box = diagBox()\n        box.textContent = msg\n        box.style.display = 'block'\n        clearTimeout(diag.timer)\n        diag.timer = setTimeout(() => { box.style.display = 'none' }, 8000)\n        try {\n          const prev = JSON.parse(localStorage.getItem('datafox2.hitches') || '[]')\n          prev.push({ at: new Date().toISOString(), msg })\n          localStorage.setItem('datafox2.hitches', JSON.stringify(prev.slice(-20)))\n        } catch (e) {}\n      }\n      diag.last = now\n      diagReset()\n    }\n    const watchFrames = () => {\n      const gl = canvas.getContext('webgl2')\n      if (gl) {\n        for (const fn of ['clear']) {\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => { lastDraw = performance.now(); diagFrame(lastDraw); return orig(...a) }\n        }\n        for (const fn of ['drawArrays', 'bufferData', 'texImage2D', 'texSubImage2D', 'createTexture', 'useProgram',\n          'compileShader', 'linkProgram', 'readPixels', 'blitFramebuffer', 'bindFramebuffer', 'uniform1iv']) {\n          if (typeof gl[fn] !== 'function') continue\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => {\n            const t = performance.now()\n            const r = orig(...a)\n            const d = performance.now() - t\n            diag.n[fn] = (diag.n[fn] || 0) + 1\n            if (d > diag.slow[1]) diag.slow = [fn, d]\n            return r\n          }\n        }\n      }\n      const raf = window.requestAnimationFrame.bind(window)\n      window.requestAnimationFrame = (cb) => raf((ts) => {\n        const t = performance.now()\n        try { return cb(ts) } finally { diag.cpu += performance.now() - t }\n      })\n      d2.onUnmount(() => { window.requestAnimationFrame = raf })\n      lastDraw = performance.now()\n      const id = setInterval(() => {\n        if (crashed || !engine) return clearInterval(id)\n        if (document.hidden) { lastDraw = performance.now(); return }\n        if (performance.now() - lastDraw > 4000) {\n          crash('The game froze (no frames for 4 seconds).', '')\n          clearInterval(id)\n        }\n      }, 1000)\n      d2.onUnmount(() => clearInterval(id))\n    }\n\n    // ---- page takeover (phones) ----------------------------------------------------------\n    // Edge to edge: extend the page under the notch/home-indicator areas (viewport-fit=cover),\n    // paint everything dark, and pin the component over the whole screen. The HUD already keeps\n    // its buttons inside env(safe-area-inset-*). Skipped in the builder so its UI is not covered.\n    // Gestures: iOS turns a second finger into pinch-zoom, double taps into zoom and long presses\n    // into the magnifier; cancel those on everything except the form fields and the chips/buttons\n    // that rely on click.\n    const undo = []\n    const takeOverPage = () => {\n      const inBuilder = !/\\.data2\\.app$/.test(location.hostname) // only the published app takes over the page\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      if (!inBuilder) {\n        setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n        setMeta('apple-mobile-web-app-capable', 'yes')\n        setMeta('mobile-web-app-capable', 'yes')\n        setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n        setMeta('theme-color', '#05060a')\n        const styleOf = (el, css) => {\n          const prev = el.getAttribute('style')\n          el.style.cssText += css\n          undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n        }\n        styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n        styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n        const host = d2.root && d2.root.host\n        if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n      }\n      const allowed = (t) => t && t.closest && t.closest('input, .chip, .go, .crash, .overlay')\n      const on = (target, type, fn) => {\n        target.addEventListener(type, fn, { passive: false })\n        undo.push(() => target.removeEventListener(type, fn, { passive: false }))\n      }\n      const block = (e) => { if (!allowed(e.target)) e.preventDefault() }\n      // Pointer events (buttons, stick) still fire when touchstart is cancelled.\n      on(root, 'touchstart', block)\n      on(root, 'touchmove', block)\n      on(root, 'touchend', (e) => { if (!allowed(e.target) && e.touches.length) e.preventDefault() })\n      on(root, 'contextmenu', (e) => e.preventDefault())\n      on(root, 'selectstart', block)\n      on(root, 'dblclick', (e) => e.preventDefault())\n      for (const g of ['gesturestart', 'gesturechange', 'gestureend']) on(document, g, (e) => e.preventDefault())\n    }\n    takeOverPage()\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n\n    d2.on('.start-btn', 'click', boot)\n\n    // SDL owns the canvas size: it follows the canvas's CSS box on every window resize.\n\n    d2.onUnmount(() => {\n      uninstallPad?.()\n      blobs.forEach((u) => URL.revokeObjectURL(u))\n    })\n\n    preload()\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"engineJs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineJs"}}}},"engineWasm":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineWasm"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"gyroRangeDeg":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gyroRangeDeg"}}}},"invertY":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertY"}}}},"mainO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"mainO2r"}}}},"portO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"portO2r"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac6b49fd0e6a463e356f93f","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Player","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF player (PRD) — promoted from TFSF_Player_DEV, where all player changes are made first and tested\n// on the tfsf-dev app; this is a copy of that source with the DEV markers\n// (badge, runtime/storage keys, log tag) switched back.\n//\n// A pure player: no menus. It runs the Starship (SF64 PC port) WebAssembly engine in a WebGL2 canvas\n// with phone controls (motion steering, touch buttons, swipe maneuvers). The block's parameters say\n// which engine build and archives to load; the route's query says what to play:\n//   /play?cmd=new&expert=0|1   new main game from Corneria\n//   /play?cmd=level&level=N    one level (LevelId) from a fresh game state\n//   /play?cmd=training         training\n//   /play?cmd=continue|retry|alt   after a level: next planet / replay it (-1 life) / other branch\n// The engine runs in \"director\" mode (engine v18+): it never shows the game's own title, menu, map\n// or game-over screens. When it would, it holds and reports (Module.onDirector), and the player\n// navigates to the menu routes (/map, /results, /), which belong to TFSF_Menu.\n//\n// One engine per page: it lives in window[RT_KEY] and survives route changes. Leaving /play pauses\n// it and parks its canvas; coming back adopts the canvas again, so later levels start instantly.\n//\n// Input reaches the engine as a *virtual gamepad*: navigator.getGamepads() is wrapped so\n// SDL's emscripten joystick backend sees one \"standard\"-mapped pad whose axes come from the\n// gyro / touch stick and whose buttons come from the on-screen controls. Starship's default\n// SDL mapping then turns that into N64 input (A, B, Z, R, Start, C-buttons, stick).\n\nconst RT_KEY = '__TFSF__'          // the per-page runtime shared with TFSF_Menu\nconst STORE = 'tfsf'               // localStorage / sessionStorage prefix\nconst TAG = '[TFSF]'\n\nconst BUTTON = { A: 0, CDOWN: 1, B: 2, CLEFT: 3, Z: 4, R: 5, BACK: 8, START: 9, DUP: 12, DDOWN: 13, DLEFT: 14, DRIGHT: 15 }\n\nfunction makePad() {\n  const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }))\n  return {\n    id: 'dataFox2 virtual pad (standard)',\n    index: 0,\n    connected: true,\n    mapping: 'standard',\n    axes: [0, 0, 0, 0],\n    buttons,\n    timestamp: performance.now(),\n    vibrationActuator: null,\n  }\n}\n\nfunction installVirtualPad(pad) {\n  const nav = window.navigator\n  const original = nav.getGamepads ? nav.getGamepads.bind(nav) : () => []\n  const patched = () => {\n    pad.timestamp = performance.now()\n    const real = Array.from(original() || []).filter(Boolean)\n    return [pad, ...real.map((g, i) => g && { ...g, index: i + 1 })]\n  }\n  try {\n    Object.defineProperty(nav, 'getGamepads', { configurable: true, value: patched })\n  } catch (e) {\n    nav.getGamepads = patched\n  }\n  const ev = new Event('gamepadconnected')\n  ev.gamepad = pad\n  window.dispatchEvent(ev)\n  return () => {\n    try { Object.defineProperty(nav, 'getGamepads', { configurable: true, value: original }) } catch (e) {}\n  }\n}\n\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))\n\nexport default {\n  css: `\n    /* No long-press magnifier / callout / selection / tap flash anywhere in the game UI. */\n    .wrap, .wrap * { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n      -webkit-tap-highlight-color: transparent; touch-action: none; }\n    .wrap { position: relative; display: block; width: 100%; height: 100%; background: #05060a;\n      overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e8ecf4; }\n    /* !important: SDL writes inline css sizes on resize; the canvas must always fill the component. */\n    canvas.game { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; outline: none; }\n    .crash { position: absolute; inset: 0; z-index: 9; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 12px; padding: 24px; box-sizing: border-box; background: #05060af2; text-align: center; }\n    .crash[hidden] { display: none; }\n    .crash pre { max-width: 92%; max-height: 40%; overflow: auto; margin: 0; padding: 10px; border-radius: 8px;\n      background: #0c1222; color: #ffb4b4; font: 11px/1.4 ui-monospace, Menlo, monospace; text-align: left; white-space: pre-wrap; }\n    .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 14px; background: radial-gradient(circle at 50% 40%, #16203a 0%, #05060a 70%);\n      text-align: center; padding: 24px; box-sizing: border-box; z-index: 5; }\n    .overlay[hidden] { display: none; }\n    .title { font-size: 28px; font-weight: 800; letter-spacing: 0.08em; }\n    .sub { font-size: 14px; opacity: 0.75; max-width: 420px; line-height: 1.4; }\n    .bar { width: min(320px, 70vw); height: 6px; border-radius: 3px; background: #1d2740; overflow: hidden; }\n    .bar > i { display: block; height: 100%; width: 0%; background: #5aa2ff; transition: width 0.2s; }\n    .go { appearance: none; border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 700;\n      background: #5aa2ff; color: #05060a; cursor: pointer; }\n    .go[disabled] { opacity: 0.4; }\n    .err { color: #ff8a8a; font-size: 13px; max-width: 460px; white-space: pre-wrap; }\n    .go[hidden] { display: none; }\n    .chip.quit.armed { background: rgba(255,90,90,0.75); border-color: #ffc4c4; }\n\n    .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }\n    .hud[hidden] { display: none; }\n    .btn { position: absolute; pointer-events: auto; display: flex; align-items: center; justify-content: center;\n      border-radius: 50%; background: rgba(255,255,255,0.14); border: 2px solid rgba(255,255,255,0.35);\n      color: #fff; font-weight: 800; font-size: 18px; box-sizing: border-box; backdrop-filter: blur(2px); }\n    .btn.on { background: rgba(90,162,255,0.55); border-color: #9cc6ff; }\n    .btn.pill { border-radius: 14px; font-size: 13px; letter-spacing: 0.05em; }\n    .a { width: 74px; height: 74px; right: calc(26px + env(safe-area-inset-right)); bottom: 34px; }\n    .b { width: 60px; height: 60px; right: calc(112px + env(safe-area-inset-right)); bottom: 22px; }\n    .r { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 124px; }\n    .z { width: 64px; height: 46px; left: calc(22px + env(safe-area-inset-left)); bottom: 186px; }\n    .boost { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 104px; font-size: 12px; }\n    .brake { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 24px; font-size: 12px; }\n    .talk { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 182px; font-size: 12px; }\n    .talk[hidden] { display: none; }\n    .talk.on { background: rgba(255,210,60,0.6); border-color: #ffe27a; color: #05060a; }\n    .kbm .btn.talk:not([hidden]) { display: flex; bottom: 20px; right: calc(20px + env(safe-area-inset-right)); width: auto; padding: 0 12px; }\n    .start { width: 78px; height: 34px; left: 50%; transform: translateX(-50%); bottom: 14px; }\n    .top { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right));\n      display: flex; gap: 8px; pointer-events: auto; }\n    .chip { pointer-events: auto; appearance: none; border: 1px solid rgba(255,255,255,0.35); background: rgba(0,0,0,0.45);\n      color: #fff; border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 600; }\n    .chip.on { background: rgba(90,162,255,0.55); }\n    .chip { transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .chip.flash { background: rgba(90,162,255,0.85); border-color: #cfe3ff; transform: scale(0.92); }\n    .toast { position: absolute; top: calc(52px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);\n      padding: 6px 14px; border-radius: 999px; background: rgba(0,0,0,0.6); color: #fff; font-size: 13px; font-weight: 700;\n      letter-spacing: 0.04em; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* Swipe trail feedback (maneuvers / menu steps). */\n    .gesture-hint { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); font-size: 44px;\n      font-weight: 900; color: #fff; opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none;\n      text-shadow: 0 2px 12px rgba(0,0,0,0.6); }\n    .gesture-hint.show { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }\n    /* The hint glyphs, drawn (nearly invisibly) on the loading screen. iOS has no U+21B6 (the U-turn\n       arrow) in the system font, and the first time it is shown it loads a fallback font, which\n       froze the first U-turn of each session. Showing it here does that load while we wait. */\n    .glyph-warm { position: absolute; left: -9999px; top: 0; font-size: 44px; font-weight: 900;\n      pointer-events: none; user-select: none; }\n    /* Mouse & keys: no on-screen buttons, no motion chips. */\n    .kbm .btn, .kbm .chip.gyro, .kbm .chip[data-act=\"sens\"], .kbm .chip[data-act=\"center\"] { display: none; }\n    .knob-base { position: absolute; width: 120px; height: 120px; margin: -60px 0 0 -60px; border-radius: 50%;\n      border: 2px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.06); display: none; }\n    .knob { position: absolute; width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;\n      background: rgba(255,255,255,0.35); display: none; }\n    .rotate { position: absolute; inset: 0; z-index: 8; display: none; align-items: center; justify-content: center;\n      background: #05060aee; font-size: 16px; text-align: center; padding: 24px; }\n    @media (orientation: portrait) and (pointer: coarse) { .rotate.armed { display: flex; } }\n  `,\n\n  // Render once; everything after mount is imperative (canvas + engine) so re-renders never touch it.\n  render() {\n    return `<div class=\"wrap\" data-d2-static>\n      <div class=\"hud\" hidden>\n        <div class=\"gesture-hint\"></div>\n        <div class=\"btn a\" data-b=\"A\">A</div>\n        <div class=\"btn b\" data-b=\"B\">B</div>\n        <div class=\"btn pill r\" data-b=\"R\">R</div>\n        <div class=\"btn pill z\" data-b=\"Z\">Z</div>\n        <div class=\"btn boost\" data-b=\"CLEFT\">BOOST</div>\n        <div class=\"btn brake\" data-b=\"CDOWN\">BRAKE</div>\n        <div class=\"btn pill start\" data-b=\"START\">START</div>\n        <div class=\"btn pill talk\" hidden>TALK</div>\n        <div class=\"toast\"></div>\n        <div class=\"top\">\n          <button class=\"chip gyro on\" data-act=\"gyro\">Gyro</button>\n          <button class=\"chip\" data-act=\"sens\">Tilt: Med</button>\n          <button class=\"chip\" data-act=\"center\">Center</button>\n          <button class=\"chip\" data-act=\"hud\">Hide</button>\n          <button class=\"chip quit\" data-act=\"quit\">Menu</button>\n        </div>\n      </div>\n      <div class=\"overlay\">\n        <div class=\"glyph-warm\" aria-hidden=\"true\">↻↶▲▼▶◀</div>\n        <div class=\"title\">TFSF</div>\n        <div class=\"sub status\">Preparing…</div>\n        <div class=\"bar\"><i></i></div>\n        <button class=\"go start-btn\" hidden>Start</button>\n        <button class=\"go menu-btn\" hidden>Main menu</button>\n        <div class=\"err\"></div>\n      </div>\n      <div class=\"rotate\">Rotate your phone to landscape</div>\n      <div class=\"crash\" hidden>\n        <div class=\"title\">The game stopped</div>\n        <div class=\"sub crash-why\"></div>\n        <pre class=\"crash-log\"></pre>\n        <button class=\"go restart\">Back to the menu</button>\n      </div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const wrap = $('.wrap')\n    const overlay = $('.overlay')\n    const statusEl = $('.status')\n    const barEl = $('.bar > i')\n    const goBtn = $('.start-btn')\n    const menuBtn = $('.menu-btn')\n    const errEl = $('.err')\n    const hud = $('.hud')\n\n    // ---- the page runtime: one engine, one canvas, one pad, kept across route changes -----\n    const rt = window[RT_KEY] || (window[RT_KEY] = {})\n    if (!rt.canvas) {\n      rt.canvas = document.createElement('canvas')\n      rt.canvas.className = 'game'\n      rt.canvas.id = 'canvas'\n      rt.canvas.tabIndex = -1\n    }\n    const canvas = rt.canvas\n    wrap.insertBefore(canvas, wrap.firstChild)\n    if (!rt.pad) rt.pad = makePad()\n    const pad = rt.pad\n    let mountedHere = true\n\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const refs = {\n      js: d2.param('engineJs', ''),\n      wasm: d2.param('engineWasm', ''),\n      main: d2.param('mainO2r', ''),\n      port: d2.param('portO2r', ''),\n    }\n    // The engine lives as long as the tab: one started before a new engine was published would keep\n    // running. If this page's engine is not the configured one, reload to get the new one.\n    if (rt.engine && rt.engineRef && rt.engineRef !== `${refs.js}|${refs.wasm}`) {\n      location.reload()\n      return\n    }\n\n    const setStatus = (t) => { statusEl.textContent = t }\n    const setProgress = (f) => { barEl.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%` }\n    const fail = (msg) => { errEl.textContent = msg; setStatus('Could not start'); goBtn.hidden = true; menuBtn.hidden = false }\n\n    // ---- navigation ------------------------------------------------------------------------\n    // The Router swaps artboards; this component only ever leaves /play. d2.router() is the\n    // runtime's own navigation; if it is not reachable, push the URL and let the router hear it.\n    const routerRef = String(d2.param('routerRef', '') || '')\n    const nav = (to) => {\n      try {\n        const r = routerRef ? d2.router(routerRef) : d2.router()\n        if (r && typeof r.goTo === 'function') { r.goTo(to); return }\n      } catch (e) {}\n      try { history.pushState(null, '', to); window.dispatchEvent(new PopStateEvent('popstate', { state: null })) } catch (e) { location.href = to }\n    }\n    const query = new URLSearchParams(location.search)\n    const cmd = { name: query.get('cmd') || 'new', expert: query.get('expert') === '1', level: Number(query.get('level')) }\n\n    // Director reports: the last one is what the menus show (also kept for a reload).\n    const remember = (ev) => {\n      rt.lastEvent = ev\n      try { sessionStorage.setItem(`${STORE}.lastEvent`, JSON.stringify(ev)) } catch (e) {}\n    }\n\n    // Two ways in: a builder session (the Drive's builder-only policy) or a player session from\n    // the login strategy. Both live in localStorage; each request carries whichever exist.\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(`__data2-session-${systemDomain}`)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    class Refused extends Error {}\n\n    const progress = { total: 0, loaded: {} }\n    // When a download dies at the network level ('Load failed' / 'Failed to fetch'), find out which\n    // hop broke: the authorised request to the server, or the redirect it answers with.\n    const diagnose = async (ref) => {\n      try {\n        const r = await fetch(`${filesBase}/file/${ref}`, { headers: authHeaders(), redirect: 'manual' })\n        if (r.type === 'opaqueredirect') return 'server accepted the session; the browser blocked the redirect to the file'\n        return `server answered HTTP ${r.status} without redirecting`\n      } catch (e) {\n        return `the request to the server itself failed (${e && e.message})`\n      }\n    }\n\n    // Downloads go out as plain GETs with the key in the query string (the Drive has a policy that\n    // accepts it). A request with no custom headers needs no CORS preflight, and WebKit (every\n    // iPhone browser) refuses to follow the server's redirect to the signed file URL after one.\n    // The key itself is read through the session-gated keys DataSet, which involves no redirect.\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const getDownloadKey = async () => {\n      const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (res.status === 401 || res.status === 403) throw new Refused(`keys: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`keys: HTTP ${res.status}`)\n      const body = await res.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const k = rows[0] && rows[0].key\n      if (!k) throw new Refused('keys: no key readable for this session')\n      return k\n    }\n    let downloadKey = ''\n\n    const fetchBytes = async (key, ref) => {\n      if (!ref) throw new Error(`Missing file reference for ${key}`)\n      let res\n      try {\n        res = await fetch(`${filesBase}/file/${ref}?t=${encodeURIComponent(downloadKey)}`)\n      } catch (e) {\n        const ua = (navigator.userAgent.match(/(iPhone|iPad|Android|Macintosh|Windows)[^;)]*/) || [''])[0]\n        throw new Error(`${key}: ${e && e.message} | ${await diagnose(ref)} | ${ua}`)\n      }\n      if (res.status === 401 || res.status === 403) throw new Refused(`${key}: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`)\n      const len = Number(res.headers.get('content-length')) || 0\n      progress.total += len\n      if (!res.body || !len) return new Uint8Array(await res.arrayBuffer())\n      const reader = res.body.getReader()\n      const chunks = []\n      let got = 0\n      for (;;) {\n        const { done, value } = await reader.read()\n        if (done) break\n        chunks.push(value)\n        got += value.length\n        progress.loaded[key] = got\n        const sum = Object.values(progress.loaded).reduce((a, b) => a + b, 0)\n        setProgress(progress.total ? sum / progress.total : 0)\n      }\n      const out = new Uint8Array(got)\n      let o = 0\n      for (const c of chunks) { out.set(c, o); o += c.length }\n      return out\n    }\n\n    // ---- input state ---------------------------------------------------------------------\n    // gyro: exact steering (-1..1, +x right, +y down). gyroPad: the same, pushed past\n    // libultraship's 20% stick deadzone, for engines without the direct stick export.\n    const input = { gyroOn: true, stickActive: false, stick: [0, 0], gyro: [0, 0], gyroPad: [0, 0], neutral: null, lastOri: null }\n    // The control scheme picked in the menu (/solo → Controls): 'touch' (on-screen stick) and 'mouse'\n    // (mouse & keyboard) start with motion steering off.\n    const SCHEME = readKey(`${STORE}.controls`) || 'motion'\n    input.gyroOn = SCHEME === 'motion'\n    input.keyStick = null\n    $('.chip.gyro').classList.toggle('on', input.gyroOn)\n\n    // Motion steering goes straight into the engine's stick (exact values, no gamepad deadzone or\n    // notch snapping) through web_set_stick (engine v10+). The touch stick, and older engines,\n    // go through the virtual gamepad.\n    const syncAxes = () => {\n      const direct = !!(engine && typeof engine._web_set_stick === 'function' && !crashed)\n      // Only once motion data is actually arriving (desktop keeps keyboard/gamepad sticks), and\n      // only while flying: in menus the tilt would wander the cursor.\n      const useGyro = input.gyroOn && !input.stickActive && !!input.hasMotion && uiMode() === 0\n      // On motion-controlled devices the maneuvers belong to the swipes: switch off the classic\n      // pull-back + Boost/Brake trigger so hard tilts never loop by accident (desktop keeps it).\n      if (input.hasMotion && !input.touchManeuvers && engine && typeof engine._web_set_touch_maneuvers === 'function' && !crashed) {\n        try { engine._web_set_touch_maneuvers(1); input.touchManeuvers = true } catch (e) {}\n      }\n      if (direct && input.pulse) {\n        // Gesture pulses (maneuvers, menu steps) are full-strength stick pushes, +y up.\n        try { engine._web_set_stick(1, input.pulse[0], input.pulse[1]) } catch (e) {}\n        pad.axes[0] = 0\n        pad.axes[1] = 0\n        return\n      }\n      // What steers right now (+x right, +y down): the touch stick, the keys, else motion.\n      const steer = input.stickActive ? input.stick : input.keyStick ? input.keyStick : useGyro ? input.gyro : null\n      if (direct) {\n        // Star Fox ignores raw stick values within +-16 (of 85) and saturates at 76, per axis\n        // (sys_joybus.c). Map our 0..1 onto exactly that live band, so the smallest tilt past our\n        // own tremor filter already turns the ship and full tilt is full turn.\n        const live = (v) => (v === 0 ? 0 : Math.sign(v) * (16.5 + Math.min(1, Math.abs(v)) * (76 - 16.5)) / 85)\n        try { engine._web_set_stick(steer ? 1 : 0, live(steer ? steer[0] : 0), live(steer ? -steer[1] : 0)) } catch (e) {}\n      }\n      const src = input.pulse ? [input.pulse[0], -input.pulse[1]] : direct ? [0, 0] : input.stickActive ? input.stick : input.keyStick ? input.keyStick : (useGyro ? input.gyroPad : [0, 0])\n      pad.axes[0] = src[0]\n      pad.axes[1] = src[1]\n    }\n\n    const setButton = (name, down) => {\n      const i = BUTTON[name]\n      if (i == null) return\n      pad.buttons[i] = { pressed: down, touched: down, value: down ? 1 : 0 }\n    }\n\n    // Buttons: multi-touch, each pointer tracked to the button it started on.\n    // The engine samples the pad once per frame, so a quick tap is held for MIN_HOLD_MS —\n    // otherwise down+up can both land between two polls and the press is never seen.\n    const MIN_HOLD_MS = 80\n    const held = new Map()\n    d2.on('.btn', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      el.setPointerCapture?.(e.pointerId)\n      held.set(e.pointerId, { el, at: performance.now() })\n      el.classList.add('on')\n      if (el.classList.contains('talk')) return comms.talk(true)\n      setButton(el.dataset.b, true)\n      if (navigator.vibrate) navigator.vibrate(8)\n    })\n    const release = (e) => {\n      const h = held.get(e.pointerId)\n      if (!h) return\n      held.delete(e.pointerId)\n      if (h.el.classList.contains('talk')) { h.el.classList.remove('on'); return comms.talk(false) }\n      const up = () => {\n        h.el.classList.remove('on')\n        setButton(h.el.dataset.b, false)\n      }\n      const left = MIN_HOLD_MS - (performance.now() - h.at)\n      if (left > 0) setTimeout(up, left)\n      else up()\n    }\n    d2.on(root, 'pointerup', release)\n    d2.on(root, 'pointercancel', release)\n\n    // ---- touch gestures on the game screen ------------------------------------------------\n    // Flying:  swipe up = somersault (pull back + Boost), swipe down = U-turn (pull back + Brake).\n    //          The game only triggers them from that combination inside a few frames, so the\n    //          swipe plays it for ~160 ms. Barrel roll stays on double-tapping Z / R.\n    // Menus:   tap = select (main menu cards are hit-tested by the engine; elsewhere a tap is A,\n    //          and START on the title screen); swipes step the cursor one notch.\n    // The engine says which applies (web_ui_mode); motion steering is off in menus.\n    const uiMode = () => {\n      try { return engine && typeof engine._web_ui_mode === 'function' ? engine._web_ui_mode() : 0 } catch (e) { return 0 }\n    }\n    const pressFor = (name, ms) => {\n      setButton(name, true)\n      setTimeout(() => setButton(name, false), ms)\n    }\n    const pulse = (x, y, ms) => {\n      input.pulse = [x, y]\n      syncAxes()\n      clearTimeout(input.pulseTimer)\n      input.pulseTimer = setTimeout(() => { input.pulse = null; syncAxes() }, ms)\n    }\n    let hintTimer = null\n    const hint = (glyph) => {\n      const h = $('.gesture-hint')\n      if (!h) return\n      h.textContent = glyph\n      h.classList.add('show')\n      clearTimeout(hintTimer)\n      hintTimer = setTimeout(() => h.classList.remove('show'), 350)\n    }\n    const maneuver = (kind, button, glyph) => {\n      // The engine starts the maneuver itself, under the game's own rules (full boost meter;\n      // U-turn only in all-range). No input macro: when it is not possible, nothing happens.\n      if (typeof engine._web_maneuver !== 'function') return\n      let ok = 0\n      try { ok = engine._web_maneuver(kind) } catch (e) { return }\n      if (!ok) return // not possible right now (boost recharging, on rails for a U-turn, ...)\n      hint(glyph)\n      try { navigator.vibrate && navigator.vibrate(15) } catch (e) {}\n    }\n    // Canvas point -> the game's 320x240 2D space (a 4:3 area centred in the canvas).\n    const toGame = (cx, cy) => {\n      const r = canvas.getBoundingClientRect()\n      const w = (r.height * 4) / 3\n      return [((cx - r.left - (r.width - w) / 2) / w) * 320, ((cy - r.top) / r.height) * 240]\n    }\n    const gestures = new Map()\n    const gestureTarget = (t) => !(t && t.closest && t.closest('.btn, .chip, .overlay, .crash, input'))\n    d2.on(root, 'pointerdown', (e) => {\n      if (!engine || crashed || !gestureTarget(e.target)) return\n      if (stickStart(e)) return // the left half steers (touch stick); swipes stay on the right\n      gestures.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() })\n    })\n    const endGesture = (e) => {\n      const g = gestures.get(e.pointerId)\n      if (!g) return\n      gestures.delete(e.pointerId)\n      if (e.type === 'pointercancel' || !engine || crashed) return\n      const dx = e.clientX - g.x\n      const dy = e.clientY - g.y\n      const dt = performance.now() - g.t\n      const dist = Math.hypot(dx, dy)\n      const mode = uiMode()\n      if (dist < 14 && dt < 400) {\n        if (mode !== 1) return // taps do nothing while flying\n        const [gx, gy] = toGame(e.clientX, e.clientY)\n        let code = 0\n        try { code = typeof engine._web_tap === 'function' ? engine._web_tap(gx, gy) : 0 } catch (err) { code = 0 }\n        if (code === 0) pressFor('A', 120)\n        else if (code === 2) pressFor('START', 120)\n        return\n      }\n      if (dist < 40 || dt > 600) return\n      const vertical = Math.abs(dy) > 1.3 * Math.abs(dx)\n      const horizontal = Math.abs(dx) > 1.3 * Math.abs(dy)\n      if (mode === 1) {\n        // One cursor step per swipe, as a short full stick push.\n        if (vertical) { pulse(0, dy < 0 ? 1 : -1, 130); hint(dy < 0 ? '▲' : '▼') }\n        else if (horizontal) { pulse(dx > 0 ? 1 : -1, 0, 130); hint(dx > 0 ? '▶' : '◀') }\n        return\n      }\n      if (vertical) {\n        if (dy < 0) maneuver(1, 'CLEFT', '↻') // up: somersault\n        else maneuver(2, 'CDOWN', '↶')        // down: U-turn\n      }\n    }\n    d2.on(root, 'pointerup', endGesture)\n    d2.on(root, 'pointercancel', endGesture)\n\n    // ---- touch stick (motion steering off: the Touch scheme, or the Gyro chip turned off) --------\n    // A floating stick: it appears where the thumb lands on the left half of the screen (away from\n    // the buttons) and steers until it lifts. Vertical is inverted: drag up climbs, drag down dives.\n    const STICK_R = 52 // px of drag for full stick\n    const knobBase = document.createElement('div')\n    knobBase.className = 'knob-base'\n    const knob = document.createElement('div')\n    knob.className = 'knob'\n    hud.appendChild(knobBase)\n    hud.appendChild(knob)\n    const stick = { id: null, x0: 0, y0: 0 }\n    const stickOn = () => SCHEME !== 'mouse' && uiMode() === 0 && !(input.gyroOn && input.hasMotion)\n    const placeKnob = (el, x, y) => { el.style.left = `${x}px`; el.style.top = `${y}px`; el.style.display = 'block' }\n    const stickStart = (e) => {\n      if (stick.id !== null || !stickOn()) return false\n      const r = root.getBoundingClientRect()\n      if (e.clientX - r.left > r.width * 0.5) return false\n      stick.id = e.pointerId\n      stick.x0 = e.clientX - r.left\n      stick.y0 = e.clientY - r.top\n      placeKnob(knobBase, stick.x0, stick.y0)\n      placeKnob(knob, stick.x0, stick.y0)\n      input.stickActive = true\n      input.stick = [0, 0]\n      syncAxes()\n      return true\n    }\n    d2.on(root, 'pointermove', (e) => {\n      if (e.pointerId !== stick.id) return\n      const r = root.getBoundingClientRect()\n      let dx = e.clientX - r.left - stick.x0\n      let dy = e.clientY - r.top - stick.y0\n      const d = Math.hypot(dx, dy)\n      if (d > STICK_R) { dx *= STICK_R / d; dy *= STICK_R / d }\n      placeKnob(knob, stick.x0 + dx, stick.y0 + dy)\n      input.stick = [dx / STICK_R, -dy / STICK_R] // vertical inverted: drag up = pull back = climb\n      syncAxes()\n    })\n    const stickEnd = (e) => {\n      if (e.pointerId !== stick.id) return\n      stick.id = null\n      knobBase.style.display = knob.style.display = 'none'\n      input.stickActive = false\n      input.stick = [0, 0]\n      syncAxes()\n    }\n    d2.on(root, 'pointerup', stickEnd)\n    d2.on(root, 'pointercancel', stickEnd)\n\n    // ---- mouse & keyboard (the Mouse & keys scheme) --------------------------------------------\n    //   W/A/S/D or arrows  steer (W climbs, S dives)    left click   laser (hold: lock-on)\n    //   Space              boost                        right click  bomb\n    //   Shift              brake                        Q / E        bank left / right (double tap: roll)\n    //   X                  somersault                   C            U-turn (all-range)\n    //   Enter              start\n    // The keys go straight to the virtual pad; the engine's own keyboard mapping never sees them.\n    if (SCHEME === 'mouse') {\n      wrap.classList.add('kbm')\n      const keys = new Set()\n      const STEER = { KeyW: 'up', ArrowUp: 'up', KeyS: 'down', ArrowDown: 'down', KeyA: 'left', ArrowLeft: 'left', KeyD: 'right', ArrowRight: 'right' }\n      const PRESS = { Space: 'CLEFT', ShiftLeft: 'CDOWN', ShiftRight: 'CDOWN', KeyQ: 'Z', KeyE: 'R', Enter: 'START' }\n      const updateKeys = () => {\n        const has = (dir) => [...keys].some((k) => STEER[k] === dir)\n        const x = (has('right') ? 1 : 0) - (has('left') ? 1 : 0)\n        const y = (has('up') ? 1 : 0) - (has('down') ? 1 : 0) // +y down on the stick = pull back = climb\n        const m = x && y ? Math.SQRT1_2 : 1\n        input.keyStick = x || y ? [x * m, y * m] : null\n        syncAxes()\n      }\n      const onKey = (e) => {\n        const down = e.type === 'keydown'\n        const code = e.code\n        if (code === 'KeyV') { e.preventDefault(); e.stopImmediatePropagation(); if (!e.repeat) comms.talk(down); return }\n        if (!(code in STEER) && !(code in PRESS) && code !== 'KeyX' && code !== 'KeyC') return\n        e.preventDefault()\n        e.stopImmediatePropagation()\n        if (code in STEER) { down ? keys.add(code) : keys.delete(code); return updateKeys() }\n        if (code in PRESS) return setButton(PRESS[code], down)\n        if (down && !e.repeat && engine && uiMode() === 0) maneuver(code === 'KeyX' ? 1 : 2, code === 'KeyX' ? 'CLEFT' : 'CDOWN', code === 'KeyX' ? '↻' : '↶')\n      }\n      const MOUSE = { 0: 'A', 2: 'B' }\n      const onMouse = (e) => {\n        if (!(e.button in MOUSE) || (e.target && e.target.closest && e.target.closest('.chip, .overlay, .crash, .talk'))) return\n        e.preventDefault()\n        setButton(MOUSE[e.button], e.type === 'mousedown')\n      }\n      const noMenu = (e) => e.preventDefault()\n      const blurAll = () => { comms.talk(false); keys.clear(); updateKeys(); for (const b of Object.values(PRESS).concat(['A', 'B'])) setButton(b, false) }\n      window.addEventListener('keydown', onKey, true)\n      window.addEventListener('keyup', onKey, true)\n      root.addEventListener('mousedown', onMouse)\n      window.addEventListener('mouseup', onMouse)\n      root.addEventListener('contextmenu', noMenu)\n      window.addEventListener('blur', blurAll)\n      d2.onUnmount(() => {\n        window.removeEventListener('keydown', onKey, true)\n        window.removeEventListener('keyup', onKey, true)\n        root.removeEventListener('mousedown', onMouse)\n        window.removeEventListener('mouseup', onMouse)\n        root.removeEventListener('contextmenu', noMenu)\n        window.removeEventListener('blur', blurAll)\n        input.keyStick = null\n      })\n    }\n\n    // ---- motion steering: 6-axis sensor fusion -------------------------------------------\n    // The gyroscope (rotationRate, deg/s) is the primary signal: fast and clean, so no heavy\n    // smoothing (and no lag). The accelerometer only anchors it: a complementary filter pulls the\n    // gyro-propagated gravity estimate toward the measured one, trusting the measurement less\n    // when its magnitude is far from 1 g (hand jolts, not tilt).\n    //   vertical   = pitch: tipping the top edge toward / away     (absolute, gravity-anchored)\n    //   horizontal = roll (turning the phone like a wheel)         (absolute, gravity-anchored)\n    //              + yaw  (turning it like a tray, about the real-world vertical, whatever the\n    //                      phone's tilt). Yaw has no absolute reference: it holds while you\n    //                      hold the turn, fades back over ~20 s, and Center zeroes it.\n    // Angles are relative to a neutral pose captured at start, on rotation, and by Center.\n    const SENS = [{ label: 'Low', deg: 28 }, { label: 'Med', deg: 20 }, { label: 'High', deg: 14 }]\n    let sensIdx = 1\n    // The Tilt chip owns sensitivity; the instance's gyroRangeDeg is only a hint for the start level.\n    const hinted = Number(d2.param('gyroRangeDeg', 0)) || 0\n    if (hinted) sensIdx = SENS.reduce((best, s, i) => (Math.abs(s.deg - hinted) < Math.abs(SENS[best].deg - hinted) ? i : best), 0)\n    const rollRange = () => SENS[sensIdx].deg        // degrees of wheel-turn for full stick\n    const pitchRange = () => SENS[sensIdx].deg       // degrees of tip for full stick\n    const yawRange = () => SENS[sensIdx].deg * 1.5   // degrees of tray-turn for full stick\n    // Yaw holds while the phone is held turned (like roll), and fades back only slowly so leftover\n    // gyro drift can never build up. (It was 2 s: the ship turned, then drifted back mid-hold.)\n    const YAW_RECENTER_S = 20\n    const YAW_RATE_DEAD = 0.3   // deg/s: ignore residual rate when still\n    const YAW_BIAS_S = 5        // gyro bias learning time constant\n    const YAW_BIAS_GATE = 1.5   // deg/s: only learn bias while (nearly) not turning\n    const DEAD = 0.0075                              // soft deadzone (hand tremor), stick units\n    const LUS_DEAD = 0.21                            // only for the virtual-pad fallback path\n    const G = 9.81\n    const DEG = 180 / Math.PI\n\n    const screenAngle = () => {\n      const a = screen.orientation && typeof screen.orientation.angle === 'number' ? screen.orientation.angle : (window.orientation || 0)\n      return ((a % 360) + 360) % 360\n    }\n    // Device x/y (portrait frame) -> screen x (right) / y (up) for the current rotation; z unchanged.\n    const toScreen = (x, y) => {\n      switch (screenAngle()) {\n        case 90: return [y, -x]\n        case 180: return [-x, -y]\n        case 270: return [-y, x]\n        default: return [x, y]\n      }\n    }\n    const wrapDeg = (d) => ((d + 540) % 360) - 180\n    const clamp1 = (v) => clamp(v, -1, 1)\n    // Soft deadzone + mild curve: tremor vanishes, precision near centre, full range kept.\n    const shapeAxis = (v) => {\n      const a = Math.abs(v)\n      if (a <= DEAD) return 0\n      const n = Math.min(1, (a - DEAD) / (1 - DEAD))\n      return Math.sign(v) * n * (0.85 + 0.15 * n)\n    }\n    // Tiered smoothing: only tiny changes are smoothed; real motions pass straight through.\n    const out = [0, 0]\n    const smoothTiered = (i, v) => {\n      const d = v - out[i]\n      const w = 0.35 + 0.65 * Math.min(1, Math.abs(d) / 0.06)\n      out[i] += w * d\n      return out[i]\n    }\n\n    const fusion = { f: null, t: 0, yaw: 0, yawBias: 0, neutralRef: null }\n    const norm = (v) => { const m = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / m, v[1] / m, v[2] / m] }\n    // A world-fixed vector seen from the device turns opposite to the device: dv = -w x v dt.\n    const rotStep = (v, w, dt) => norm([\n      v[0] - (w[1] * v[2] - w[2] * v[1]) * dt,\n      v[1] - (w[2] * v[0] - w[0] * v[2]) * dt,\n      v[2] - (w[0] * v[1] - w[1] * v[0]) * dt,\n    ])\n\n    // Gyro axis order. The spec says rotationRate alpha/beta/gamma are rates about the device's\n    // z/x/y axes; WebKit (every iPhone browser) reports them about x/y/z. Read the wrong way,\n    // pitching leaks into yaw and the ship moves diagonally. Rather than trust either, keep\n    // checking which mapping (and sign) predicts the accelerometer's view of gravity best and use\n    // that one; the platform only sets the starting guess.\n    const GYRO_MAPS = [\n      (r) => [r.beta, r.gamma, r.alpha],\n      (r) => [r.alpha, r.beta, r.gamma],\n      (r) => [-r.beta, -r.gamma, -r.alpha],\n      (r) => [-r.alpha, -r.beta, -r.gamma],\n    ]\n    const isWebKitMobile = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n    const cal = { score: GYRO_MAPS.map(() => 0), best: isWebKitMobile ? 1 : 0, q: null, n: 0 }\n    const calibrate = (cands, m, dt, mag) => {\n      // Only while the accelerometer reads clean gravity (no jolts).\n      if (!cal.q || Math.abs(mag - G) > 0.08 * G) { cal.q = cands.map(() => m); cal.n = 0; return }\n      cal.q = cal.q.map((q, i) => rotStep(q, cands[i], dt))\n      if (++cal.n < 8) return\n      // Over ~130 ms windows with real rotation (> ~20 deg/s), score each mapping's prediction.\n      if (Math.hypot(cands[0][0], cands[0][1], cands[0][2]) > 0.35) {\n        cal.q.forEach((q, i) => { cal.score[i] = cal.score[i] * 0.97 + (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 })\n        let best = cal.best\n        cal.score.forEach((s, i) => { if (s < cal.score[best] * 0.6) best = i })\n        cal.best = best\n      }\n      cal.q = cands.map(() => m)\n      cal.n = 0\n    }\n\n    const publish = (h, v) => {\n      // h: +right, v: +down (gamepad convention). Default pitch: tipping the top edge away climbs\n      // (flight-sim style); invertY flips it.\n      const invertY = !!d2.param('invertY', false)\n      const sx = smoothTiered(0, shapeAxis(clamp1(h)))\n      const sy = smoothTiered(1, shapeAxis(clamp1(v)) * (invertY ? 1 : -1))\n      input.gyro = [sx, sy]\n      const fb = (x) => (x === 0 ? 0 : Math.sign(x) * (LUS_DEAD + (1 - LUS_DEAD) * Math.abs(x)))\n      input.gyroPad = [fb(sx), fb(sy)]\n      syncAxes()\n    }\n\n    let gotMotion = false\n    d2.on(window, 'devicemotion', (e) => {\n      const a = e.accelerationIncludingGravity\n      if (!a || a.x == null || a.y == null || a.z == null) return\n      gotMotion = true\n      input.hasMotion = true\n      const now = performance.now()\n      const dt = fusion.t ? Math.min(0.1, Math.max(0.001, (now - fusion.t) / 1000)) : (e.interval > 1 ? e.interval / 1000 : (e.interval || 0.016))\n      fusion.t = now\n\n      // Everything in the screen frame: x right, y up, z out of the screen.\n      const [ax, ay] = toScreen(a.x, a.y)\n      const meas = [ax, ay, a.z]\n      const mag = Math.hypot(meas[0], meas[1], meas[2])\n      const r = e.rotationRate\n      const haveGyro = !!(r && r.alpha != null && r.beta != null && r.gamma != null)\n      let w = [0, 0, 0]\n      if (haveGyro) {\n        // Each candidate mapping as a device-frame rate vector, then into the screen frame (rad/s).\n        const cands = GYRO_MAPS.map((fn) => {\n          const d = fn(r)\n          const [sx, sy] = toScreen(d[0], d[1])\n          return [sx / DEG, sy / DEG, d[2] / DEG]\n        })\n        calibrate(cands, norm(meas), dt, mag)\n        w = cands[cal.best]\n      }\n\n      if (!fusion.f || !haveGyro) {\n        fusion.f = norm(meas)\n      } else {\n        const pred = rotStep(fusion.f, w, dt)\n        // Trust the accelerometer only when it reads ~1 g AND the phone is not rotating: turning the\n        // phone swings it on an arc, and that sideways push read as a tilt (a phantom roll).\n        const spin = Math.hypot(w[0], w[1], w[2]) * DEG\n        const trust = Math.exp(-Math.pow((mag - G) / (0.15 * G), 2)) * Math.exp(-Math.pow(spin / 45, 2))\n        const k = Math.min(1, 0.5 * dt) * trust                     // ~2 s time constant at rest\n        const m = norm(meas)\n        fusion.f = norm([pred[0] + k * (m[0] - pred[0]), pred[1] + k * (m[1] - pred[1]), pred[2] + k * (m[2] - pred[2])])\n      }\n      const f = fusion.f\n\n      // Absolute angles from the fused gravity (either sign convention works: we use deltas).\n      const rollDeg = Math.atan2(f[0], -f[1]) * DEG\n      const pitchDeg = Math.atan2(f[2], -f[1]) * DEG\n      input.lastOri = [rollDeg, pitchDeg]\n      if (!input.neutral) input.neutral = [rollDeg, pitchDeg]\n      if (fusion.neutralRef !== input.neutral) { fusion.neutralRef = input.neutral; fusion.yaw = 0 }\n\n      // Yaw about the real-world vertical: project the rotation rate onto 'up'. 'Up' is the fused\n      // gravity axis, signed so it points toward the top of the screen / out of a tipped-back screen,\n      // which is independent of each browser's accelerometer sign convention.\n      if (haveGyro) {\n        const s = (f[1] + f[2]) >= 0 ? 1 : -1\n        const yawRate = (w[0] * f[0] + w[1] * f[1] + w[2] * f[2]) * s * DEG // deg/s, + = turning left\n        // Sign confirmed on a real phone (twice): this way, turning the phone right steers right.\n        // (The desktop motion simulator suggests the opposite; trust the device.)\n        let yr = yawRate\n        if (Math.abs(yr - fusion.yawBias) < YAW_BIAS_GATE) fusion.yawBias += (yr - fusion.yawBias) * Math.min(1, dt / YAW_BIAS_S)\n        yr -= fusion.yawBias\n        if (Math.abs(yr) < YAW_RATE_DEAD) yr = 0\n        fusion.yaw = (fusion.yaw + yr * dt) * Math.exp(-dt / YAW_RECENTER_S)\n      }\n\n      const roll = wrapDeg(rollDeg - input.neutral[0])\n      const pitch = wrapDeg(pitchDeg - input.neutral[1])\n      publish(roll / rollRange() + fusion.yaw / yawRange(), pitch / pitchRange())\n    })\n    // Fallback for browsers that only expose orientation angles (no gyro rate, no yaw).\n    d2.on(window, 'deviceorientation', (e) => {\n      if (gotMotion || e.beta == null || e.gamma == null) return\n      input.hasMotion = true\n      const [x, y] = toScreen(e.gamma, -e.beta)\n      input.lastOri = [x, -y]\n      if (!input.neutral) input.neutral = [x, -y]\n      publish(wrapDeg(x - input.neutral[0]) / rollRange(), wrapDeg(-y - input.neutral[1]) / pitchRange())\n    })\n    d2.on(window, 'orientationchange', () => { input.neutral = null; fusion.f = null; cal.q = null })\n\n    // Press feedback for the chips, like the game buttons: a flash, a tick of vibration where the\n    // browser supports it, and a short message for actions with no other visible effect.\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      if (!t) return\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1100)\n    }\n    d2.on('.chip', 'pointerdown', (e, el) => {\n      el.classList.add('flash')\n      setTimeout(() => el.classList.remove('flash'), 160)\n      try { navigator.vibrate && navigator.vibrate(10) } catch (err) {}\n    })\n\n    d2.on('.chip', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'gyro') {\n        input.gyroOn = !input.gyroOn\n        el.classList.toggle('on', input.gyroOn)\n        syncAxes()\n      } else if (act === 'sens') {\n        sensIdx = (sensIdx + 1) % SENS.length\n        el.textContent = `Tilt: ${SENS[sensIdx].label}`\n        toast(`Tilt sensitivity: ${SENS[sensIdx].label}`)\n      } else if (act === 'center') {\n        input.neutral = input.lastOri ? [...input.lastOri] : null\n        toast(input.hasMotion ? 'Centered' : 'No motion sensor')\n      } else if (act === 'hud') {\n        const hidden = root.querySelectorAll('.btn, .stickzone')\n        const now = el.textContent === 'Hide'\n        hidden.forEach((n) => { n.style.visibility = now ? 'hidden' : '' })\n        el.textContent = now ? 'Show' : 'Hide'\n      } else if (act === 'quit') {\n        // Two taps: the first arms it, a second within 2.5 s leaves the level (no progress saved).\n        if (!el.classList.contains('armed')) {\n          el.classList.add('armed')\n          el.textContent = 'Quit?'\n          toast('Tap again to quit to the menu')\n          clearTimeout(rt.quitTimer)\n          rt.quitTimer = setTimeout(() => { el.classList.remove('armed'); el.textContent = 'Menu' }, 2500)\n          return\n        }\n        clearTimeout(rt.quitTimer)\n        el.classList.remove('armed')\n        el.textContent = 'Menu'\n        try {\n          if (engine && typeof engine._web_quit === 'function' && !engine._web_holding()) engine._web_quit()\n          else nav('/')\n        } catch (err) { nav('/') }\n      }\n    })\n\n    // ---- engine --------------------------------------------------------------------------\n    let engine = rt.engine || null\n    let crashed = !!rt.crashed\n\n    // Last engine log lines, shown on the crash screen so a failure can be reported.\n    const logTail = rt.logTail || (rt.logTail = [])\n    const logLine = (line) => { logTail.push(String(line).slice(0, 300)); if (logTail.length > 40) logTail.shift() }\n\n    // SDL's web audio lives on Module.SDL2.audioContext (the context unlocked by a tap, rt.audio).\n    // When the engine dies, its audio callback keeps replaying the last buffer (the stuck shooting\n    // sound), so close the context outright then.\n    const audioContext = () => (engine && engine.SDL2 && engine.SDL2.audioContext) || rt.audio || null\n    const stopAudio = () => { try { const ac = audioContext(); if (ac && ac.state !== 'closed') ac.close() } catch (e) {} }\n    const suspendAudio = () => { try { const ac = audioContext(); if (ac && ac.state === 'running') ac.suspend() } catch (e) {} }\n    const resumeAudio = () => {\n      if (crashed || !mountedHere) return\n      try { const ac = audioContext(); if (ac && ac.state !== 'running' && ac.state !== 'closed') ac.resume() } catch (e) {}\n    }\n    // Normally the menu unlocks audio on the tap that leads here (TFSF_Menu, rt.audio). When\n    // /play is opened directly, the Start button does it. Must run synchronously inside the tap:\n    // iOS only lets audio start from a user gesture.\n    const unlockAudio = () => {\n      // Play through the iPhone silent switch, like a game or video app (Safari 16.4+).\n      try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      try {\n        if (rt.audio && rt.audio.state !== 'closed') { rt.audio.resume(); return }\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx) return\n        // The game synthesises at 32 kHz: running the context at that rate lets the browser resample\n        // natively instead of SDL doing it in WebAssembly every audio callback.\n        let ac\n        try { ac = new Ctx({ sampleRate: 32000 }) } catch (e) { ac = new Ctx() }\n        ac.resume()\n        // A one-sample silent buffer started within the gesture fully unlocks older iOS.\n        const src = ac.createBufferSource()\n        src.buffer = ac.createBuffer(1, 1, 22050)\n        src.connect(ac.destination)\n        src.start(0)\n        rt.audio = ac\n      } catch (e) {}\n    }\n\n    const crash = (why, stack) => {\n      if (crashed) return\n      crashed = rt.crashed = true\n      stopAudio()\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      const box = $('.crash')\n      box.querySelector('.crash-why').textContent = String(why || 'Unknown error').slice(0, 300)\n      // Stack first (the engine is built with function names), then the engine's last log lines.\n      const frames = String(stack || '').split('\\n').map((l) => l.trim()).filter(Boolean).slice(0, 14)\n      box.querySelector('.crash-log').textContent = [...frames, frames.length ? '----' : '', ...logTail.slice(-15)].filter(Boolean).join('\\n')\n      box.hidden = false\n      console.error(`${TAG} crashed:`, why)\n    }\n    // A crashed engine cannot be reused: reload the page at the menu.\n    d2.on('.restart', 'click', () => { location.href = '/' })\n    d2.on('.menu-btn', 'click', () => nav('/'))\n\n    // Only engine failures count: the host page can raise its own unrelated errors.\n    const isEngineError = (err, filename) =>\n      String(filename || '').startsWith('blob:') ||\n      (err && (err instanceof WebAssembly.RuntimeError || /Aborted|RuntimeError|wasm|unreachable|memory access out of bounds/i.test(String(err.message || err))))\n    d2.on(window, 'error', (e) => {\n      if (!engine || !isEngineError(e.error || e.message, e.filename)) return\n      // Name the source too: the engine and this component can both be blob: scripts.\n      const where = `at ${String(e.filename || '?').slice(-60)}:${e.lineno || 0}:${e.colno || 0}`\n      crash(e.message || e.error, [where, (e.error && e.error.stack) || ''].join('\\n'))\n    })\n    d2.on(window, 'unhandledrejection', (e) => { if (engine && isEngineError(e.reason)) crash(e.reason && (e.reason.message || e.reason), e.reason && e.reason.stack) })\n    d2.on(canvas, 'webglcontextlost', (e) => {\n      e.preventDefault()\n      crash('The browser dropped the graphics context (usually low memory on the phone).')\n    })\n    // Hidden tab: silence it; closing/leaving: shut audio down so nothing outlives the page.\n    d2.on(document, 'visibilitychange', () => {\n      if (!audioContext() || crashed) return\n      if (document.hidden) suspendAudio()\n      else resumeAudio()\n    })\n    // Leaving for good closes the audio; a page kept in memory (home-screen app switched away, back/\n    // forward cache) only suspends it, so it can play again when the player returns.\n    d2.on(window, 'pagehide', (e) => { if (e && e.persisted) suspendAudio(); else stopAudio() })\n    d2.on(window, 'pageshow', () => resumeAudio())\n    // Any tap is a chance to (re)start audio iOS suspended or interrupted (calls, Siri, other apps).\n    // Any touch on the game asks for motion again while it is not on (it must come from a tap).\n    d2.on(root, 'pointerup', () => { resumeAudio(); if (!input.hasMotion) askMotionPermission() })\n    d2.on(root, 'pointerdown', () => resumeAudio())\n\n    const askMotionPermission = async () => {\n      // iOS gates motion behind a prompt that must come from a tap; ask for both event types.\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') {\n          try { await Ev.requestPermission() } catch (e) { /* declined; the touch stick still works */ }\n        }\n      }\n    }\n\n    // The whole document goes fullscreen (not this component), so it stays fullscreen across routes.\n    const enterFullscreen = async () => {\n      const el = document.documentElement\n      try { if (el.requestFullscreen && !document.fullscreenElement) await el.requestFullscreen({ navigationUI: 'hide' }) } catch (e) {}\n      try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n      try { await navigator.wakeLock?.request?.('screen') } catch (e) {}\n    }\n\n    // ---- warm-up list learned on this device --------------------------------------------\n    const WARM_KEY = `${STORE}.warm.v1`\n    if (!rt.learned) {\n      rt.learned = new Set()\n      try { (localStorage.getItem(WARM_KEY) || '').split('\\n').filter(Boolean).forEach((l) => rt.learned.add(l)) } catch (e) {}\n    }\n    const learned = rt.learned\n    const learnWarm = (line) => {\n      if (rt.warming || learned.has(line) || learned.size > 30000) return // warm-up items are already listed\n      learned.add(line)\n      clearTimeout(rt.warmSaveTimer)\n      rt.warmSaveTimer = setTimeout(() => { try { localStorage.setItem(WARM_KEY, [...learned].join('\\n')) } catch (e) {} }, 3000)\n    }\n    const warmUp = (M) => new Promise((resolve) => {\n      if (typeof M._web_warm_step !== 'function') return resolve()\n      const t0 = performance.now()\n      const step = () => {\n        let left = 0\n        try { left = M._web_warm_step(14) } catch (e) { return resolve() }\n        if (left > 0 && performance.now() - t0 < 8000) requestAnimationFrame(step)\n        else resolve()\n      }\n      requestAnimationFrame(step)\n    })\n\n    // ---- director: what to play, and where to go when the engine stops -----------------------\n    const showPlaying = () => {\n      overlay.hidden = true\n      hud.hidden = false\n      $('.rotate').classList.add('armed')\n      canvas.focus()\n    }\n    const leave = (to) => {\n      hud.hidden = true\n      nav(to)\n    }\n    const runCommand = () => {\n      if (!engine || crashed || !mountedHere) return\n      if (typeof engine._web_holding !== 'function') return fail('This engine build has no director mode (needs engine v18+).')\n      // Coming back to a level that is still running (browser back/forward): just play on.\n      if (!engine._web_holding()) return showPlaying()\n      // Netplay: every phone starts the level together, when the host says go.\n      if (netplay.on && !netplay.went) return netplay.wait()\n      const last = rt.lastEvent && rt.lastEvent.event\n      try { netplay.setup() } catch (e) {}\n      try {\n        switch (cmd.name) {\n          case 'continue':\n          case 'retry':\n          case 'alt':\n            // Only right after a level (the engine holds the route state for it).\n            if (last !== 'level-end') return nav('/')\n            if (cmd.name === 'alt') { rt.pendingContinue = true; engine._web_route_choice(2) }\n            else engine._web_route_choice(cmd.name === 'retry' ? 1 : 0)\n            break\n          case 'level':\n            engine._web_play_level(Number.isFinite(cmd.level) ? cmd.level : 0)\n            break\n          case 'training':\n            engine._web_training()\n            break\n          default:\n            engine._web_new_game(cmd.expert ? 1 : 0)\n        }\n      } catch (e) {\n        return crash(`Engine command failed: ${e && e.message}`, e && e.stack)\n      }\n      remember({ event: 'playing', cmd: cmd.name })\n      showPlaying()\n    }\n    const onDirector = (ev) => {\n      if (ev.event === 'ready') {\n        // Boot finished; the command runs once the warm-up is done too.\n        rt.ready = true\n        if (!rt.warming) runCommand()\n        return\n      }\n      if (ev.event === 'level-end' && rt.pendingContinue) {\n        // 'alt': the route switched branch; now fly it.\n        rt.pendingContinue = false\n        try { engine._web_route_choice(0) } catch (e) {}\n        remember({ event: 'playing', cmd: 'alt' })\n        return\n      }\n      remember(ev)\n      if (ev.event === 'level-end') leave('/map')\n      else if (ev.event === 'game-over' || ev.event === 'ending') leave('/results')\n      else leave('/')\n    }\n    rt.onDirector = onDirector\n\n    // ---- co-op netplay: lockstep (engine v27+) ------------------------------------------------------\n    // After a lobby departure (/play?…&net=1) every phone runs the whole level: the same seed, and\n    // every pilot's controller input applied on the same tick, so every phone computes the same game.\n    // The other pilots are real Players in the engine (gPlayer[1..3]), flown by their owners' input.\n    // Only inputs travel, as binary packets over the lobby's WebRTC links (rt.net, a star around the\n    // host, which relays):\n    //   input: u8 1, u8 slot, u8 session, u8 n, u32 first tick, then n x (u16 buttons, i8 x, i8 y)\n    //   hash:  u8 2, u8 slot, u8 session, u8 0, u32 tick, u32 state hash (desync check)\n    // Each tick the engine hands over this phone's pad for tick + COOP_DELAY (Module.coopSend); the\n    // packet repeats the last COOP_REDUNDANCY ticks, so a lost packet costs nothing. A tick runs only\n    // once every pilot's input for it is here (the engine waits in its frame loop).\n    const FACES = { fox: 1, falco: 2, slippy: 3, peppy: 4 }\n    const PILOT_NAMES = { fox: 'Fox', falco: 'Falco', slippy: 'Slippy', peppy: 'Peppy' }\n    const COOP_DELAY = 3        // ticks (at 30 Hz: 100 ms) between a press and the tick it lands on\n    const COOP_REDUNDANCY = 8   // each packet also repeats this many earlier ticks\n    const COOP_DROP_MS = 6000   // host: a pilot silent this long is dropped so the others fly on\n    const LS_GONE = 0x0040      // the engine's \"pilot left\" button bit\n    const netplay = { on: query.get('net') === '1' && !!(rt.net && rt.mp), slots: [], seed: 0, session: 0, history: [], hashes: new Map(), lastRx: new Map(), maxTick: new Map(), dropped: new Map(), went: false }\n    const coopEngine = () => !!(engine && !crashed && typeof engine._web_coop_setup === 'function')\n    // Slot order: the host first (slot 0 anchors the world, as Fox does), then the others by email.\n    netplay.order = () => {\n      const pilots = rt.mp.pilots || {}\n      const host = rt.mp.host || rt.net.host\n      const rest = Object.keys(pilots).filter((e) => e !== host).sort()\n      return (host && pilots[host] !== undefined ? [host] : []).concat(rest).slice(0, 4)\n    }\n    netplay.local = () => netplay.slots.indexOf(rt.mp.me)\n    netplay.setup = () => {\n      if (!coopEngine()) return\n      if (!netplay.on) return engine._web_coop_setup(0, 0, 0, 0, 0)\n      let faces = 0\n      netplay.slots.forEach((email, i) => { faces |= (FACES[(rt.mp.pilots || {})[email]] || i + 1) << (4 * i) })\n      engine._web_coop_setup(netplay.slots.length, Math.max(0, netplay.local()), netplay.seed | 0, COOP_DELAY, faces)\n      netplay.session = netplay.seed & 255\n      netplay.history = []\n      netplay.hashes.clear()\n      engine.coopSend = coopSend\n      engine.coopHash = coopHash\n      comms.names()\n    }\n    // ---- packets ----\n    const packInputs = (slot, first, list) => {\n      const buf = new ArrayBuffer(8 + list.length * 4)\n      const v = new DataView(buf)\n      v.setUint8(0, 1); v.setUint8(1, slot); v.setUint8(2, netplay.session); v.setUint8(3, list.length); v.setUint32(4, first, true)\n      list.forEach((p, i) => { v.setUint16(8 + i * 4, p.b, true); v.setInt8(10 + i * 4, p.x); v.setInt8(11 + i * 4, p.y) })\n      return buf\n    }\n    // Engine -> page, once per tick: this phone's pad for tick t.\n    const coopSend = (t, b, x, y) => {\n      const h = netplay.history\n      h.push({ t, b, x, y })\n      if (h.length > COOP_REDUNDANCY) h.splice(0, h.length - COOP_REDUNDANCY)\n      try { rt.net.sendBin(packInputs(netplay.local(), h[0].t, h)) } catch (e) {}\n    }\n    const coopHash = (t, hash) => {\n      netplay.hashes.set(t, hash)\n      if (netplay.hashes.size > 64) netplay.hashes.delete(netplay.hashes.keys().next().value)\n      const peer = netplay.peerHashes && netplay.peerHashes.get(t)\n      if (peer !== undefined && peer.hash !== hash) desync(t, peer.slot)\n      const buf = new ArrayBuffer(12)\n      const v = new DataView(buf)\n      v.setUint8(0, 2); v.setUint8(1, netplay.local()); v.setUint8(2, netplay.session); v.setUint8(3, 0); v.setUint32(4, t, true); v.setUint32(8, hash, true)\n      try { rt.net.sendBin(buf) } catch (e) {}\n    }\n    netplay.peerHashes = new Map()\n    const desync = (t, slot) => {\n      if (netplay.desyncAt !== undefined) return\n      netplay.desyncAt = t\n      rt.coopDesync = { tick: t, slot }\n      console.warn('[coop] desync at tick', t, 'vs slot', slot)\n    }\n    // Page <- network. Returns false for a packet to drop (stale session, dropped pilot).\n    const receive = (buf, from) => {\n      if (!(buf instanceof ArrayBuffer) || buf.byteLength < 8 || !coopEngine()) return false\n      const v = new DataView(buf)\n      const type = v.getUint8(0)\n      const slot = v.getUint8(1)\n      if (v.getUint8(2) !== netplay.session || slot >= netplay.slots.length || slot === netplay.local()) return false\n      if (rt.net.isHost() && netplay.dropped.has(slot)) return false // the host speaks for it now\n      if (type === 1) {\n        const n = v.getUint8(3)\n        const first = v.getUint32(4, true)\n        if (buf.byteLength < 8 + n * 4) return false\n        for (let i = 0; i < n; i++) engine._web_coop_input(slot, first + i, v.getUint16(8 + i * 4, true), v.getInt8(10 + i * 4), v.getInt8(11 + i * 4))\n        netplay.lastRx.set(slot, performance.now())\n        netplay.maxTick.set(slot, Math.max(netplay.maxTick.get(slot) || 0, first + n - 1))\n        return true\n      }\n      if (type === 3) { comms.receive(slot, v, buf); return true }\n      if (type === 2 && buf.byteLength >= 12) {\n        const t = v.getUint32(4, true)\n        const hash = v.getUint32(8, true)\n        const mine = netplay.hashes.get(t)\n        if (mine !== undefined && mine !== hash) desync(t, slot)\n        netplay.peerHashes.set(t, { hash, slot })\n        if (netplay.peerHashes.size > 64) netplay.peerHashes.delete(netplay.peerHashes.keys().next().value)\n        return true\n      }\n      return false\n    }\n    // Host: a pilot gone silent (tab closed, phone locked) would stall everyone. After COOP_DROP_MS the\n    // host flies on without it: from the tick after its last input, the host sends \"gone\" inputs in\n    // its name, and every phone (the same ticks, the same inputs) takes that ship out of the game.\n    const dropCheck = () => {\n      if (!rt.net.isHost() || !coopEngine() || !netplay.went) return\n      const tick = engine._web_coop_tick()\n      if (tick < 0) return\n      const now = performance.now()\n      netplay.slots.forEach((email, slot) => {\n        if (slot === netplay.local()) return\n        let d = netplay.dropped.get(slot)\n        if (!d) {\n          const last = netplay.lastRx.get(slot) || netplay.startedAt || now\n          if (now - last < COOP_DROP_MS) return\n          d = { next: (netplay.maxTick.get(slot) ?? (COOP_DELAY - 1)) + 1 }\n          netplay.dropped.set(slot, d)\n          try { rt.net.send({ t: 'coop-dropped', email }, { reliable: true }) } catch (e) {}\n        }\n        // Keep its \"gone\" inputs ahead of the game, like a live pilot's.\n        const upTo = tick + COOP_DELAY + COOP_REDUNDANCY\n        const list = []\n        const first = d.next\n        for (let t = first; t <= upTo && list.length < 32; t++) list.push({ b: LS_GONE, x: 0, y: 0 })\n        if (!list.length) return\n        for (let i = 0; i < list.length; i++) engine._web_coop_input(slot, first + i, LS_GONE, 0, 0)\n        d.next = first + list.length\n        try { rt.net.sendBin(packInputs(slot, first, list)) } catch (e) {}\n      })\n    }\n    // ---- radio comms: push to talk ----------------------------------------------------------------\n    // Holding TALK (V on Mouse & keys) opens this pilot's radio for everyone: the engine draws the\n    // portrait with the radio's own window and yellow label (web_coop_comms / web_coop_name, draw-only,\n    // never part of the lockstep game), and the microphone goes out as 8 kHz mu-law, 20 ms a packet,\n    // on the same links as the inputs (the host relays, like inputs):\n    //   voice: u8 3, u8 slot, u8 session, u8 seq, u8 level, u8 end, u16 0, then 160 mu-law samples\n    // `end` = 1 (no samples) when the button is let go. Each phone plays every other pilot through a\n    // small jitter buffer, and feeds the engine each pilot's loudness, so the mouths move with voices.\n    const VOICE_RATE = 8000\n    const VOICE_FRAME = 160            // 20 ms\n    const VOICE_JITTER = 0.08          // s of buffer ahead of the first frame after a gap\n    const VOICE_MAX_AHEAD = 0.4        // s: further behind than this, start over\n    const VOICE_SILENT_MS = 400        // no frame for this long: that radio closes\n    const NAME_CHARS = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ!:-.0123456789'\n    const comms = { talking: false, level: 0, seq: 0, chans: new Map(), frame: new Float32Array(VOICE_FRAME), fill: 0, phase: 0, acc: 0, accN: 0 }\n    // mu-law (G.711)\n    const muEncode = (x) => {\n      let s = Math.max(-1, Math.min(1, x)) * 32635\n      const sign = s < 0 ? 0x80 : 0\n      if (sign) s = -s\n      s += 132\n      let exp = 7\n      for (let m = 0x4000; (s & m) === 0 && exp > 0; m >>= 1) exp--\n      return ~(sign | (exp << 4) | ((s >> (exp + 3)) & 0x0f)) & 0xff\n    }\n    const MU_TABLE = new Float32Array(256)\n    for (let i = 0; i < 256; i++) {\n      const u = ~i & 0xff\n      const t = (((u & 0x0f) << 3) + 132) << ((u & 0x70) >> 4)\n      MU_TABLE[i] = ((u & 0x80) ? 132 - t : t - 132) / 32768\n    }\n    // The player's name for the radio label: the small font's letters only, first word if it is long.\n    const radioName = (email) => {\n      const raw = String(((rt.mp && rt.mp.names) || {})[email] || String(email || '').split('@')[0] || '')\n      let s = raw.normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase().replace(/[_]/g, ' ')\n      s = [...s].filter((c) => NAME_CHARS.includes(c)).join('').replace(/\\s+/g, ' ').trim()\n      if (s.length > 9) s = s.split(' ')[0]\n      return s.slice(0, 9)\n    }\n    comms.names = () => {\n      if (!coopEngine() || typeof engine._web_coop_name !== 'function') return\n      netplay.slots.forEach((email, slot) => {\n        const s = radioName(email)\n        engine._web_coop_name(slot, 0, 0)\n        for (let i = 0; i < s.length; i++) engine._web_coop_name(slot, i, s.charCodeAt(i))\n      })\n    }\n    const packVoice = (level, end, data) => {\n      const buf = new ArrayBuffer(8 + (data ? data.length : 0))\n      const v = new DataView(buf)\n      v.setUint8(0, 3); v.setUint8(1, netplay.local()); v.setUint8(2, netplay.session); v.setUint8(3, comms.seq++ & 255)\n      v.setUint8(4, level); v.setUint8(5, end ? 1 : 0)\n      if (data) new Uint8Array(buf, 8).set(data)\n      return buf\n    }\n    const levelOf = (rms) => Math.min(255, Math.round(rms * 1500))\n    // A short burst of radio static (key up / key down), on the game's audio context.\n    const squelch = (gain = 0.12) => {\n      const ac = audioContext()\n      if (!ac || ac.state !== 'running') return\n      try {\n        const n = Math.round(ac.sampleRate * 0.07)\n        const b = ac.createBuffer(1, n, ac.sampleRate)\n        const d = b.getChannelData(0)\n        for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n)\n        const src = ac.createBufferSource()\n        const g = ac.createGain()\n        const f = ac.createBiquadFilter()\n        f.type = 'bandpass'; f.frequency.value = 2200; f.Q.value = 0.8\n        g.gain.value = gain\n        src.buffer = b\n        src.connect(f); f.connect(g); g.connect(ac.destination)\n        src.start()\n      } catch (e) {}\n    }\n    // Microphone: its own context at the device's rate (Firefox will not mix rates in one graph).\n    comms.openMic = async () => {\n      if (comms.mic) return comms.mic\n      if (comms.opening) return comms.opening\n      comms.opening = (async () => {\n        if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) throw new Error('no mic')\n        try { if (navigator.audioSession) navigator.audioSession.type = 'play-and-record' } catch (e) {}\n        const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 } })\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        const ac = new Ctx()\n        await ac.resume().catch(() => {})\n        const src = ac.createMediaStreamSource(stream)\n        const proc = ac.createScriptProcessor(1024, 1, 1)\n        const mute = ac.createGain()\n        mute.gain.value = 0\n        src.connect(proc); proc.connect(mute); mute.connect(ac.destination)\n        const step = ac.sampleRate / VOICE_RATE\n        proc.onaudioprocess = (e) => {\n          if (!comms.talking) return\n          const x = e.inputBuffer.getChannelData(0)\n          // Decimate to 8 kHz: each output sample is the mean of the input samples it covers.\n          for (let i = 0; i < x.length; i++) {\n            comms.acc += x[i]; comms.accN++\n            comms.phase += 1\n            if (comms.phase >= step) {\n              comms.phase -= step\n              comms.frame[comms.fill++] = comms.acc / comms.accN\n              comms.acc = 0; comms.accN = 0\n              if (comms.fill === VOICE_FRAME) { comms.fill = 0; comms.sendFrame() }\n            }\n          }\n        }\n        comms.mic = { stream, ac, src, proc, mute }\n        return comms.mic\n      })()\n      try { return await comms.opening } finally { comms.opening = null }\n    }\n    comms.sendFrame = () => {\n      let sum = 0\n      const out = new Uint8Array(VOICE_FRAME)\n      for (let i = 0; i < VOICE_FRAME; i++) { const s = comms.frame[i]; sum += s * s; out[i] = muEncode(s) }\n      comms.level = levelOf(Math.sqrt(sum / VOICE_FRAME))\n      try { rt.net.sendBin(packVoice(comms.level, false, out)) } catch (e) {}\n    }\n    comms.talk = (down) => {\n      if (!netplay.on || !netplay.went || netplay.local() < 0) return\n      if (down === comms.talking) return\n      comms.talking = down\n      const btn = $('.talk')\n      if (btn) btn.classList.toggle('on', down)\n      if (down) {\n        comms.fill = 0; comms.acc = 0; comms.accN = 0; comms.phase = 0\n        // (the engine plays the game's radio chirp as the comm opens)\n        comms.openMic().then((m) => { try { m.ac.resume() } catch (e) {} }).catch(() => {\n          comms.talking = false\n          if (btn) btn.classList.remove('on')\n          toast('Microphone not available')\n        })\n      } else {\n        comms.level = 0\n        squelch(0.08)\n        try { rt.net.sendBin(packVoice(0, true, null)) } catch (e) {}\n      }\n    }\n    // Another pilot's voice: decode, upsample to the context's rate, queue behind the last frame.\n    comms.chan = (slot) => {\n      let ch = comms.chans.get(slot)\n      if (ch) return ch\n      const ac = audioContext()\n      if (!ac) return null\n      const hp = ac.createBiquadFilter()\n      hp.type = 'highpass'; hp.frequency.value = 280\n      const gain = ac.createGain()\n      gain.gain.value = 1.4\n      const an = ac.createAnalyser()\n      an.fftSize = 512\n      hp.connect(gain); gain.connect(an); an.connect(ac.destination)\n      ch = { ac, input: hp, an, buf: new Uint8Array(an.fftSize), next: 0, lastRx: 0, on: false, lastSeq: -1 }\n      comms.chans.set(slot, ch)\n      return ch\n    }\n    comms.receive = (slot, v, buf) => {\n      const ch = comms.chan(slot)\n      if (!ch) return\n      const seq = v.getUint8(3)\n      if (v.getUint8(5)) { if (ch.on) squelch(0.06); ch.on = false; ch.lastSeq = -1; return }\n      // Late (out of order) frames are dropped.\n      if (ch.lastSeq >= 0 && ((seq - ch.lastSeq) & 255) > 128) return\n      ch.lastSeq = seq\n      ch.on = true\n      ch.lastRx = performance.now()\n      const n = buf.byteLength - 8\n      if (n <= 0 || ch.ac.state !== 'running') return\n      const mu = new Uint8Array(buf, 8, n)\n      const sr = ch.ac.sampleRate\n      const len = Math.round((n * sr) / VOICE_RATE)\n      const ab = ch.ac.createBuffer(1, len, sr)\n      const d = ab.getChannelData(0)\n      const k = VOICE_RATE / sr\n      for (let i = 0; i < len; i++) {\n        const p = i * k\n        const j = Math.floor(p)\n        const a = MU_TABLE[mu[j]]\n        const b = j + 1 < n ? MU_TABLE[mu[j + 1]] : a\n        d[i] = a + (b - a) * (p - j)\n      }\n      const now = ch.ac.currentTime\n      if (ch.next < now + 0.01 || ch.next > now + VOICE_MAX_AHEAD) ch.next = now + VOICE_JITTER\n      const src = ch.ac.createBufferSource()\n      src.buffer = ab\n      src.connect(ch.input)\n      src.start(ch.next)\n      ch.next += len / sr\n    }\n    // ~30 times a second: who is on the radio, and how loud, for the engine to draw.\n    comms.tick = () => {\n      if (!coopEngine() || typeof engine._web_coop_comms !== 'function' || !netplay.went) return\n      const me = netplay.local()\n      const now = performance.now()\n      for (let slot = 0; slot < netplay.slots.length; slot++) {\n        if (slot === me) { engine._web_coop_comms(slot, comms.talking ? 1 : 0, comms.talking ? comms.level : 0); continue }\n        const ch = comms.chans.get(slot)\n        let on = 0\n        let level = 0\n        if (ch && ch.on) {\n          if (now - ch.lastRx > VOICE_SILENT_MS) ch.on = false\n          else {\n            on = 1\n            ch.an.getByteTimeDomainData(ch.buf)\n            let sum = 0\n            for (let i = 0; i < ch.buf.length; i++) { const s = (ch.buf[i] - 128) / 128; sum += s * s }\n            level = levelOf(Math.sqrt(sum / ch.buf.length))\n          }\n        }\n        engine._web_coop_comms(slot, on, level)\n      }\n    }\n    comms.stop = () => {\n      clearInterval(comms.timer)\n      comms.talking = false\n      const m = comms.mic\n      comms.mic = null\n      if (m) {\n        try { m.proc.onaudioprocess = null; m.src.disconnect(); m.proc.disconnect() } catch (e) {}\n        try { for (const t of m.stream.getTracks()) t.stop() } catch (e) {}\n        try { m.ac.close() } catch (e) {}\n        try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      }\n      for (const ch of comms.chans.values()) { try { ch.an.disconnect(); ch.input.disconnect() } catch (e) {} }\n      comms.chans.clear()\n    }\n    // ---- status: who the game waits on, and who this phone watches while down ----\n    const coopLabel = document.createElement('div')\n    coopLabel.style.cssText = 'position:absolute;left:50%;top:calc(10px + env(safe-area-inset-top));transform:translateX(-50%);z-index:6;padding:5px 14px;border-radius:999px;background:#000a;color:#fff;font:700 13px system-ui,sans-serif;letter-spacing:.04em;pointer-events:none;display:none'\n    wrap.appendChild(coopLabel)\n    const pilotName = (slot) => PILOT_NAMES[(rt.mp.pilots || {})[netplay.slots[slot]]] || 'a wingman'\n    netplay.stall = { tick: -1, since: 0 }\n    const status = () => {\n      if (!coopEngine() || !netplay.went) return\n      const tick = engine._web_coop_tick()\n      let text = ''\n      if (tick >= 0) {\n        if (tick !== netplay.stall.tick) { netplay.stall.tick = tick; netplay.stall.since = performance.now() }\n        const waiting = engine._web_coop_waiting()\n        if (waiting && performance.now() - netplay.stall.since > 700) {\n          const who = netplay.slots.map((e, i) => i).filter((i) => waiting & (1 << i)).map(pilotName)\n          text = `Waiting for ${who.join(', ')}…`\n        } else {\n          const me = netplay.local()\n          const flags = me >= 0 ? engine._web_coop_flags(me) : 0\n          if (flags & 1) {\n            const view = engine._web_coop_view()\n            const who = view !== me ? pilotName(view) : ''\n            text = flags & 2 ? (who ? `Out of lives · watching ${who}` : 'Out of lives') : who ? `Down · watching ${who}` : 'Down · waiting for the team'\n          }\n        }\n      }\n      coopLabel.textContent = text\n      coopLabel.style.display = text ? 'block' : 'none'\n    }\n    // ---- synchronized start ----\n    // Each phone, once its engine is up AND its pilot has tapped Ready, says 'netready' until it hears\n    // 'go'. The host says 'go' when every pilot of the lobby is ready (or after NET_START_WAIT_MS), with\n    // the seed and the slot order, so every phone starts the same game. The tap is required: a departure\n    // arrives over the network, not from a tap on this phone, and iOS only grants motion (gyro), sound\n    // and fullscreen inside a tap.\n    const NET_START_WAIT_MS = 45000\n    netplay.ready = new Set()\n    // Launched by a tap a moment ago (a /map button): motion and sound were granted there.\n    netplay.tapped = !!(rt.tappedAt && Date.now() - rt.tappedAt < 10000)\n    netplay.go = (msg) => {\n      if (netplay.went) return\n      netplay.went = true\n      netplay.slots = Array.isArray(msg && msg.slots) && msg.slots.length ? msg.slots : netplay.order()\n      netplay.seed = (msg && Number(msg.seed)) | 0\n      netplay.startedAt = performance.now()\n      clearInterval(netplay.readyTimer)\n      clearTimeout(netplay.giveUp)\n      runCommand()\n    }\n    netplay.everyoneReady = () => Object.keys(rt.mp.pilots || {}).every((email) => email === rt.mp.me || netplay.ready.has(email))\n    netplay.goMsg = () => {\n      if (!netplay.hostGo) netplay.hostGo = { t: 'go', seed: (Math.random() * 0x7fffffff) | 0, slots: netplay.order() }\n      return netplay.hostGo\n    }\n    netplay.wait = () => {\n      if (netplay.waiting) return\n      if (!netplay.tapped) {\n        setStatus('Engine ready. Tap Ready to fly (turns on motion and sound).')\n        goBtn.textContent = 'Ready'\n        goBtn.hidden = false\n        return\n      }\n      netplay.waiting = true\n      setStatus('Waiting for your wingmen…')\n      if (rt.net.isHost()) {\n        if (netplay.everyoneReady()) { const m = netplay.goMsg(); rt.net.send(m, { reliable: true }); return netplay.go(m) }\n      } else {\n        rt.net.send({ t: 'netready' }, { reliable: true })\n        netplay.readyTimer = setInterval(() => rt.net.send({ t: 'netready' }, { reliable: true }), 1000)\n      }\n      netplay.giveUp = setTimeout(() => {\n        if (rt.net.isHost()) { const m = netplay.goMsg(); rt.net.send(m, { reliable: true }); netplay.go(m) }\n      }, NET_START_WAIT_MS)\n    }\n\n    if (netplay.on) {\n      netplay.offBin = rt.net.on('bin', (msg, from) => {\n        // The host relays what a guest sent to the other guests (never a dropped pilot's).\n        if (receive(msg.data, from) && rt.net.isHost()) rt.net.sendBin(msg.data, { except: from })\n      })\n      netplay.offReady = rt.net.on('netready', (msg, from) => {\n        if (!rt.net.isHost()) return\n        netplay.ready.add(from)\n        // A pilot still waiting after the start (slow boot): send it on its way, into the same game.\n        if (netplay.went) return rt.net.send(netplay.goMsg(), { reliable: true, to: from })\n        if (netplay.waiting && netplay.everyoneReady()) { const m = netplay.goMsg(); rt.net.send(m, { reliable: true }); netplay.go(m) }\n      })\n      netplay.offGo = rt.net.on('go', (msg) => { if (!rt.net.isHost()) netplay.go(msg) })\n      netplay.offDropped = rt.net.on('coop-dropped', (msg) => {\n        const slot = netplay.slots.indexOf(msg.email)\n        if (slot >= 0) netplay.dropped.set(slot, { next: 0 })\n        // This phone was dropped (it slept past the limit): its game can no longer match; back to the lobby.\n        if (msg.email === rt.mp.me && !rt.net.isHost()) { remember({ event: 'dropped' }); leave('/') }\n      })\n      netplay.timer = setInterval(() => { try { dropCheck(); status() } catch (e) {} }, 200)\n      comms.timer = setInterval(() => { try { comms.tick() } catch (e) {} }, 33)\n      const talkBtn = $('.talk')\n      if (talkBtn) { talkBtn.hidden = false; if (SCHEME === 'mouse') talkBtn.textContent = 'HOLD V · TALK' }\n    }\n    d2.onUnmount(() => {\n      clearInterval(netplay.timer)\n      comms.stop()\n      for (const off of [netplay.offBin, netplay.offReady, netplay.offGo, netplay.offDropped]) if (off) off()\n      clearInterval(netplay.readyTimer)\n      clearTimeout(netplay.giveUp)\n      try { if (engine) { engine.coopSend = null; engine.coopHash = null } } catch (e) {}\n    })\n\n    // ---- boot (once per page) --------------------------------------------------------------\n    const boot = async () => {\n      if (rt.booting || rt.engine || !rt.files) return\n      rt.booting = true\n      goBtn.hidden = true\n      setStatus('Starting engine…')\n      const files = rt.files\n      const jsUrl = URL.createObjectURL(new Blob([files.js], { type: 'text/javascript' }))\n      try {\n        // Always load this build's loader: a createStarship left on the page by an earlier page\n        // state would pair old JS glue with the new wasm, hiding the new exports.\n        try { delete window.createStarship } catch (e) { window.createStarship = undefined }\n        await new Promise((resolve, reject) => {\n          // A private blob, not a CDN library — d2.lib does not apply here.\n          const s = document.createElement('script')\n          s.src = jsUrl\n          s.onload = resolve\n          s.onerror = () => reject(new Error('Engine script failed to load'))\n          document.head.appendChild(s)\n        })\n        const factory = window.createStarship\n        if (typeof factory !== 'function') throw new Error('Engine factory missing')\n\n        const Module = await factory({\n          canvas,\n          noInitialRun: true,\n          wasmBinary: files.wasm,\n          print: (t) => { logLine(t); console.log('[starship]', t) },\n          printErr: (t) => { logLine(t); console.warn('[starship]', t) },\n          onNewShader: (lo, hi, id1) => { rt.diag && rt.diag.shd++; learnWarm(`S ${lo} ${hi} ${id1}`) },\n          onNewResource: (path) => { rt.diag && rt.diag.res.push(path); learnWarm(`R ${path}`) },\n          onAbort: (what) => (rt.crash ? rt.crash : crash)(`Engine aborted: ${what}`, new Error('abort').stack),\n          // The current player mount handles reports; none mounted: keep it for the menus.\n          onDirector: (json) => {\n            let ev\n            try { ev = JSON.parse(json) } catch (e) { return }\n            if (rt.onDirector) rt.onDirector(ev)\n            else remember(ev)\n          },\n          preRun: [(M) => {\n            // SDL addresses its canvas as '#canvas'; the canvas lives in a shadow root that\n            // document.querySelector cannot see, so map the selector to the element directly.\n            if (M.specialHTMLTargets) M.specialHTMLTargets['#canvas'] = canvas\n            // Hand SDL the context unlocked by a tap (SDL reuses Module.SDL2.audioContext).\n            if (rt.audio) { M.SDL2 = M.SDL2 || {}; M.SDL2.audioContext = rt.audio }\n            try { M.FS.writeFile('/warmup.txt', [...learned].join('\\n')) } catch (e) {}\n            M.FS.writeFile('/sf64.o2r', files.main)\n            M.FS.writeFile('/starship.o2r', files.port)\n          }],\n        })\n        rt.engine = engine = Module\n        rt.engineRef = `${refs.js}|${refs.wasm}`\n        rt.files = null // let the download buffers go; the engine holds its own copies\n        watchFrames()\n        setStatus('Preparing graphics…')\n        // Install before main so SDL's joystick enumeration at init already sees the pad.\n        rt.uninstallPad = installVirtualPad(pad)\n        canvas.focus()\n        if (typeof Module._web_director === 'function') Module._web_director(1)\n        rt.warming = true\n        try {\n          Module.callMain([])\n        } catch (e) {\n          // emscripten_set_main_loop(..., simulate_infinite_loop=1) unwinds out of main.\n          if (!(e === 'unwind' || (e && e.name === 'ExitStatus'))) throw e\n        }\n        // And announce it again now that SDL's gamepadconnected listener is registered.\n        const ev = new Event('gamepadconnected')\n        ev.gamepad = pad\n        window.dispatchEvent(ev)\n        // Behind the loading screen, compile the shaders and load the resources gameplay will ask\n        // for (the engine's own list plus what this device learned), so first use does not stall.\n        await warmUp(Module)\n        rt.warming = false\n        if (rt.ready) runCommand()\n        else setStatus('Starting…')\n      } catch (e) {\n        rt.warming = false\n        if (e && (e.name === 'ExitStatus' || e === 'unwind')) return\n        overlay.hidden = false\n        fail(String(e && e.message || e))\n      } finally {\n        URL.revokeObjectURL(jsUrl)\n      }\n    }\n\n    // ---- downloads (once per page) -----------------------------------------------------------\n    const preload = async () => {\n      if (rt.files || rt.engine) return\n      if (rt.downloading) return rt.downloading\n      rt.downloading = (async () => {\n        if (!window.WebGL2RenderingContext) throw new Error('This browser has no WebGL2.')\n        setStatus('Downloading game files…')\n        errEl.textContent = ''\n        downloadKey = await getDownloadKey()\n        const [js, wasm, main, port] = await Promise.all([\n          fetchBytes('js', refs.js), fetchBytes('wasm', refs.wasm),\n          fetchBytes('main', refs.main), fetchBytes('port', refs.port),\n        ])\n        rt.files = { js, wasm, main, port }\n        setProgress(1)\n      })()\n      try { await rt.downloading } finally { rt.downloading = null }\n    }\n\n    // Resume the engine that is already on this page (a later level, or back from the menus).\n    const adopt = () => {\n      setStatus('')\n      rt.lastDraw = performance.now()\n      try { engine.resumeMainLoop && engine.resumeMainLoop() } catch (e) {}\n      resumeAudio()\n      // SDL sizes the canvas from its CSS box on window resize; the box is new.\n      window.dispatchEvent(new Event('resize'))\n      runCommand()\n    }\n\n    const start = async () => {\n      if (crashed) {\n        const box = $('.crash')\n        box.querySelector('.crash-why').textContent = 'The game stopped earlier on this page.'\n        box.querySelector('.crash-log').textContent = logTail.slice(-15).join('\\n')\n        box.hidden = false\n        return\n      }\n      if (engine) return adopt()\n      try {\n        await preload()\n      } catch (e) {\n        if (e instanceof Refused) return nav(`/login?next=${encodeURIComponent(location.pathname + location.search)}`)\n        return fail(String(e && e.message || e))\n      }\n      if (rt.engine) { engine = rt.engine; return adopt() }\n      if (rt.audio && rt.audio.state !== 'closed') return boot() // the menu tap unlocked sound\n      setStatus('Ready. Tap Start (sound and motion need a tap).')\n      goBtn.hidden = false\n    }\n    d2.on('.start-btn', 'click', () => {\n      unlockAudio()\n      askMotionPermission()\n      enterFullscreen()\n      if (netplay.on && engine && !netplay.tapped) {\n        netplay.tapped = true\n        goBtn.hidden = true\n        return netplay.wait()\n      }\n      boot()\n    })\n\n    // Leaving /play: pause the engine, silence it and park its canvas outside the component (the\n    // component's DOM is about to go). The next /play mount adopts both.\n    d2.onUnmount(() => {\n      mountedHere = false\n      if (rt.onDirector === onDirector) rt.onDirector = null\n      if (rt.crash === crash) rt.crash = null\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      suspendAudio()\n      if (!rt.parking) {\n        rt.parking = document.createElement('div')\n        rt.parking.style.cssText = 'position:fixed;left:-10000px;top:0;width:844px;height:390px;overflow:hidden;visibility:hidden'\n        document.body.appendChild(rt.parking)\n      }\n      try { rt.parking.appendChild(canvas) } catch (e) {}\n    })\n    rt.crash = crash\n\n    // Freeze watchdog: a frozen engine raises no error, so the crash screen never came up and the\n    // last audio buffer kept looping. The renderer clears its target once per frame, so stamp that\n    // (one call per frame, not per draw); no frame for a few seconds while playing = frozen.\n    // Hitch recorder (diagnostics for first-use stalls on phones). Per frame it counts what the\n    // engine created and times the WebGL calls and rAF callbacks; a frame over 150 ms while flying\n    // is reported on screen (and kept in localStorage `${STORE}.hitches`).\n    // The GL and rAF wrappers are installed once per page (rt.diag); each mount shows the reports.\n    const diagBox = () => {\n      let el = root.querySelector('.diag')\n      if (!el) {\n        el = document.createElement('div')\n        el.className = 'diag'\n        el.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:9;max-width:92%;padding:6px 10px;border-radius:8px;background:rgba(0,0,0,0.75);color:#ffd27a;font:600 11px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap'\n        wrap.appendChild(el)\n      }\n      return el\n    }\n    const showHitch = (msg) => {\n      const box = diagBox()\n      box.textContent = msg\n      box.style.display = 'block'\n      clearTimeout(rt.diagTimer)\n      rt.diagTimer = setTimeout(() => { box.style.display = 'none' }, 8000)\n    }\n    rt.showHitch = showHitch\n    d2.onUnmount(() => { if (rt.showHitch === showHitch) rt.showHitch = null })\n    function watchFrames() {\n      if (rt.diag) return\n      const diag = rt.diag = { res: [], shd: 0, n: {}, slow: ['', 0], cpu: 0, last: 0, reports: 0 }\n      const diagReset = () => { diag.res = []; diag.shd = 0; diag.n = {}; diag.slow = ['', 0]; diag.cpu = 0 }\n      const flying = () => { try { return rt.engine && rt.engine._web_ui_mode && rt.engine._web_ui_mode() === 0 } catch (e) { return false } }\n      const diagFrame = (now) => {\n        const gap = now - diag.last\n        if (diag.last && gap > 150 && gap < 3000 && !document.hidden && rt.showHitch && flying() && diag.reports < 6) {\n          diag.reports++\n          const n = diag.n\n          const parts = [`Hitch ${Math.round(gap)}ms`, `js ${Math.round(diag.cpu)}ms`,\n            `slowest gl: ${diag.slow[0] || '-'} ${Math.round(diag.slow[1])}ms`,\n            `draws ${n.drawArrays || 0}`, `newTex ${n.createTexture || 0}`, `texUpload ${n.texImage2D || 0}`,\n            `shaders ${diag.shd}/${n.linkProgram || 0}`, `readback ${(n.readPixels || 0) + (n.blitFramebuffer || 0)}`,\n            `res ${diag.res.length}${diag.res.length ? ' (' + diag.res.slice(0, 3).map((p) => p.split('/').pop()).join(', ') + ')' : ''}`]\n          const msg = parts.join(' · ')\n          rt.showHitch(msg)\n          try {\n            const prev = JSON.parse(localStorage.getItem(`${STORE}.hitches`) || '[]')\n            prev.push({ at: new Date().toISOString(), msg })\n            localStorage.setItem(`${STORE}.hitches`, JSON.stringify(prev.slice(-20)))\n          } catch (e) {}\n        }\n        diag.last = now\n        diagReset()\n      }\n      const gl = canvas.getContext('webgl2')\n      if (gl) {\n        const clear = gl.clear.bind(gl)\n        gl.clear = (...a) => { rt.lastDraw = performance.now(); diagFrame(rt.lastDraw); return clear(...a) }\n        for (const fn of ['drawArrays', 'bufferData', 'texImage2D', 'texSubImage2D', 'createTexture', 'useProgram',\n          'compileShader', 'linkProgram', 'readPixels', 'blitFramebuffer', 'bindFramebuffer', 'uniform1iv']) {\n          if (typeof gl[fn] !== 'function') continue\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => {\n            const t = performance.now()\n            const r = orig(...a)\n            const d = performance.now() - t\n            diag.n[fn] = (diag.n[fn] || 0) + 1\n            if (d > diag.slow[1]) diag.slow = [fn, d]\n            return r\n          }\n        }\n      }\n      const raf = window.requestAnimationFrame.bind(window)\n      window.requestAnimationFrame = (cb) => raf((ts) => {\n        const t = performance.now()\n        try { return cb(ts) } finally { diag.cpu += performance.now() - t }\n      })\n    }\n    // The watchdog runs only while this mount shows the game (the engine is paused elsewhere).\n    rt.lastDraw = performance.now()\n    const watchdog = setInterval(() => {\n      if (crashed || !engine || overlay.hidden === false) { rt.lastDraw = performance.now(); return }\n      if (document.hidden) { rt.lastDraw = performance.now(); return }\n      if (performance.now() - (rt.lastDraw || 0) > 4000) {\n        crash('The game froze (no frames for 4 seconds).', '')\n        clearInterval(watchdog)\n      }\n    }, 1000)\n    d2.onUnmount(() => clearInterval(watchdog))\n\n    // ---- page takeover (phones) ----------------------------------------------------------\n    // Edge to edge: extend the page under the notch/home-indicator areas (viewport-fit=cover),\n    // paint everything dark, and pin the component over the whole screen. The HUD already keeps\n    // its buttons inside env(safe-area-inset-*). Skipped in the builder so its UI is not covered.\n    // Gestures: iOS turns a second finger into pinch-zoom, double taps into zoom and long presses\n    // into the magnifier; cancel those on everything except the form fields and the chips/buttons\n    // that rely on click.\n    const undo = []\n    const takeOverPage = () => {\n      const inBuilder = !/\\.data2\\.app$/.test(location.hostname) // only the published app takes over the page\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      if (!inBuilder) {\n        setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n        setMeta('apple-mobile-web-app-capable', 'yes')\n        setMeta('mobile-web-app-capable', 'yes')\n        setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n        setMeta('theme-color', '#05060a')\n        const styleOf = (el, css) => {\n          const prev = el.getAttribute('style')\n          el.style.cssText += css\n          undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n        }\n        styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n        styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n        const host = d2.root && d2.root.host\n        if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n      }\n      const allowed = (t) => t && t.closest && t.closest('input, .chip, .go, .crash, .overlay')\n      const on = (target, type, fn) => {\n        target.addEventListener(type, fn, { passive: false })\n        undo.push(() => target.removeEventListener(type, fn, { passive: false }))\n      }\n      const block = (e) => { if (!allowed(e.target)) e.preventDefault() }\n      // Pointer events (buttons, stick) still fire when touchstart is cancelled.\n      on(root, 'touchstart', block)\n      on(root, 'touchmove', block)\n      on(root, 'touchend', (e) => { if (!allowed(e.target) && e.touches.length) e.preventDefault() })\n      on(root, 'contextmenu', (e) => e.preventDefault())\n      on(root, 'selectstart', block)\n      on(root, 'dblclick', (e) => e.preventDefault())\n      for (const g of ['gesturestart', 'gesturechange', 'gestureend']) on(document, g, (e) => e.preventDefault())\n    }\n    takeOverPage()\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n\n    // SDL owns the canvas size: it follows the canvas's CSS box on every window resize.\n\n    start()\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"engineJs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineJs"}}}},"engineWasm":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineWasm"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"gyroRangeDeg":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gyroRangeDeg"}}}},"invertY":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertY"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"mainO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"mainO2r"}}}},"portO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"portO2r"}}}},"routerRef":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"routerRef"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac6c328d0e6a463e35716ab","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Player_DEV","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF DEV player — development copy of TFSF_Player. All player changes are made here first, tested\n// on the tfsf-dev app, then promoted to TFSF_Player (PRD) by copying this source with the DEV markers\n// (badge, runtime/storage keys, log tag) switched back.\n//\n// A pure player: no menus. It runs the Starship (SF64 PC port) WebAssembly engine in a WebGL2 canvas\n// with phone controls (motion steering, touch buttons, swipe maneuvers). The block's parameters say\n// which engine build and archives to load; the route's query says what to play:\n//   /play?cmd=new&expert=0|1   new main game from Corneria\n//   /play?cmd=level&level=N    one level (LevelId) from a fresh game state\n//   /play?cmd=training         training\n//   /play?cmd=continue|retry|alt   after a level: next planet / replay it (-1 life) / other branch\n// The engine runs in \"director\" mode (engine v18+): it never shows the game's own title, menu, map\n// or game-over screens. When it would, it holds and reports (Module.onDirector), and the player\n// navigates to the menu routes (/map, /results, /), which belong to TFSF_Menu_DEV.\n//\n// One engine per page: it lives in window[RT_KEY] and survives route changes. Leaving /play pauses\n// it and parks its canvas; coming back adopts the canvas again, so later levels start instantly.\n//\n// Input reaches the engine as a *virtual gamepad*: navigator.getGamepads() is wrapped so\n// SDL's emscripten joystick backend sees one \"standard\"-mapped pad whose axes come from the\n// gyro / touch stick and whose buttons come from the on-screen controls. Starship's default\n// SDL mapping then turns that into N64 input (A, B, Z, R, Start, C-buttons, stick).\n\nconst RT_KEY = '__TFSF_DEV__'      // the per-page runtime shared with TFSF_Menu_DEV\nconst STORE = 'tfsf-dev'           // localStorage / sessionStorage prefix\nconst TAG = '[TFSF DEV]'\n\nconst BUTTON = { A: 0, CDOWN: 1, B: 2, CLEFT: 3, Z: 4, R: 5, BACK: 8, START: 9, DUP: 12, DDOWN: 13, DLEFT: 14, DRIGHT: 15 }\n\nfunction makePad() {\n  const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }))\n  return {\n    id: 'dataFox2 virtual pad (standard)',\n    index: 0,\n    connected: true,\n    mapping: 'standard',\n    axes: [0, 0, 0, 0],\n    buttons,\n    timestamp: performance.now(),\n    vibrationActuator: null,\n  }\n}\n\nfunction installVirtualPad(pad) {\n  const nav = window.navigator\n  const original = nav.getGamepads ? nav.getGamepads.bind(nav) : () => []\n  const patched = () => {\n    pad.timestamp = performance.now()\n    const real = Array.from(original() || []).filter(Boolean)\n    return [pad, ...real.map((g, i) => g && { ...g, index: i + 1 })]\n  }\n  try {\n    Object.defineProperty(nav, 'getGamepads', { configurable: true, value: patched })\n  } catch (e) {\n    nav.getGamepads = patched\n  }\n  const ev = new Event('gamepadconnected')\n  ev.gamepad = pad\n  window.dispatchEvent(ev)\n  return () => {\n    try { Object.defineProperty(nav, 'getGamepads', { configurable: true, value: original }) } catch (e) {}\n  }\n}\n\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))\n\nexport default {\n  css: `\n    /* No long-press magnifier / callout / selection / tap flash anywhere in the game UI. */\n    .wrap, .wrap * { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n      -webkit-tap-highlight-color: transparent; touch-action: none; }\n    .wrap { position: relative; display: block; width: 100%; height: 100%; background: #05060a;\n      overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e8ecf4; }\n    /* !important: SDL writes inline css sizes on resize; the canvas must always fill the component. */\n    canvas.game { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; outline: none; }\n    .crash { position: absolute; inset: 0; z-index: 9; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 12px; padding: 24px; box-sizing: border-box; background: #05060af2; text-align: center; }\n    .crash[hidden] { display: none; }\n    .crash pre { max-width: 92%; max-height: 40%; overflow: auto; margin: 0; padding: 10px; border-radius: 8px;\n      background: #0c1222; color: #ffb4b4; font: 11px/1.4 ui-monospace, Menlo, monospace; text-align: left; white-space: pre-wrap; }\n    /* The loading screen: deep space, stars streaming at the viewer; NOW LOADING bottom right, its letters\n       bobbing; what it is doing (and the download) small, bottom left; a tap's button in the middle. */\n    .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 14px; background: #02040a; text-align: center; padding: 24px; box-sizing: border-box; z-index: 5; }\n    .overlay[hidden] { display: none; }\n    .sky { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }\n    .overlay::after { content: ''; position: absolute; inset: 0; pointer-events: none;\n      background: radial-gradient(ellipse at 50% 50%, transparent 55%, #02040acc 100%); }\n    .overlay > :not(.sky) { position: relative; z-index: 1; }\n    .title { font-size: 28px; font-weight: 800; letter-spacing: 0.08em; }\n    .overlay > .title { display: none; }\n    .nowload { position: absolute !important; right: calc(40px + env(safe-area-inset-right)); bottom: calc(28px + env(safe-area-inset-bottom));\n      display: flex; gap: .02em; font: 400 clamp(26px, 5.2vw, 52px)/1 'MGS', 'Arial Narrow', system-ui, sans-serif; letter-spacing: .04em; color: #fff;\n      text-shadow: 0 0 18px #2d90ffcc, 0 0 2px #000; white-space: nowrap; }\n    .nowload i { display: inline-block; font-style: normal; animation: nlBob 1.1s ease-in-out infinite; animation-delay: calc(var(--k) * 80ms); }\n    .nowload i.sp { width: .42em; }\n    @keyframes nlBob { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-.32em); } }\n    .overlay.failed .nowload, .overlay.tap .nowload { display: none; }\n    .sub { font-size: 14px; opacity: 0.75; max-width: 420px; line-height: 1.4; }\n    .overlay > .status { position: absolute !important; left: calc(40px + env(safe-area-inset-left)); bottom: calc(44px + env(safe-area-inset-bottom));\n      max-width: 46%; text-align: left; font: 400 11px/1.4 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: .2em;\n      text-transform: uppercase; color: #8fd6ff; opacity: 1; text-shadow: 0 0 8px #000; }\n    .overlay.tap > .status { position: relative !important; left: auto; bottom: auto; max-width: 420px; text-align: center; }\n    .bar { width: min(320px, 70vw); height: 6px; border-radius: 3px; background: #1d2740; overflow: hidden; }\n    .overlay > .bar { position: absolute !important; left: calc(40px + env(safe-area-inset-left)); bottom: calc(30px + env(safe-area-inset-bottom));\n      width: min(260px, 34vw); height: 2px; border-radius: 1px; background: #ffffff1f; }\n    .bar > i { display: block; height: 100%; width: 0%; background: #5aa2ff; transition: width 0.2s; }\n    .overlay.tap > .bar, .overlay.failed > .bar { display: none; }\n    .overlay > .bar > i { background: #00dbff; box-shadow: 0 0 8px #00dbff; }\n    .go { appearance: none; border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 700;\n      background: #5aa2ff; color: #05060a; cursor: pointer; }\n    .overlay .go { min-width: 240px; height: 64px; padding: 0 34px; border-radius: 14px; font: 400 30px/1 'MGS', 'Arial Narrow', system-ui, sans-serif;\n      letter-spacing: .04em; color: #fff; background: linear-gradient(90deg, #0e4f86e6, #0a2a55cc); box-shadow: inset 0 0 0 3px #fff, 0 0 22px #00dbff66;\n      text-shadow: 0 0 12px #2d90ffaa; transition: background .14s ease, color .14s ease, transform .1s ease; }\n    .overlay .go:hover, .overlay .go:focus-visible { background: #00dbff; color: #021018; text-shadow: none; outline: none; }\n    .overlay .go:active { transform: scale(.97); }\n    .go[disabled] { opacity: 0.4; }\n    .err { color: #ff8a8a; font-size: 13px; max-width: 460px; white-space: pre-wrap; }\n    .go[hidden] { display: none; }\n    .chip.quit.armed { background: rgba(255,90,90,0.75); border-color: #ffc4c4; }\n\n    .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }\n    .hud[hidden] { display: none; }\n    .btn { position: absolute; pointer-events: auto; display: flex; align-items: center; justify-content: center;\n      border-radius: 50%; background: rgba(255,255,255,0.14); border: 2px solid rgba(255,255,255,0.35);\n      color: #fff; font-weight: 800; font-size: 18px; box-sizing: border-box; backdrop-filter: blur(2px); }\n    .btn.on { background: rgba(90,162,255,0.55); border-color: #9cc6ff; }\n    .btn.pill { border-radius: 14px; font-size: 13px; letter-spacing: 0.05em; }\n    .a { width: 74px; height: 74px; right: calc(26px + env(safe-area-inset-right)); bottom: 34px; }\n    .b { width: 60px; height: 60px; right: calc(112px + env(safe-area-inset-right)); bottom: 22px; }\n    .r { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 124px; }\n    .z { width: 64px; height: 46px; left: calc(22px + env(safe-area-inset-left)); bottom: 186px; }\n    .boost { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 104px; font-size: 12px; }\n    .brake { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 24px; font-size: 12px; }\n    .talk { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 182px; font-size: 12px; }\n    .talk[hidden] { display: none; }\n    .talk.on { background: rgba(255,210,60,0.6); border-color: #ffe27a; color: #05060a; }\n    .kbm .btn.talk:not([hidden]) { display: flex; bottom: 20px; right: calc(20px + env(safe-area-inset-right)); width: auto; padding: 0 12px; }\n    .start { width: 78px; height: 34px; left: 50%; transform: translateX(-50%); bottom: 14px; }\n    .top { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right));\n      display: flex; gap: 8px; pointer-events: auto; }\n    .chip { pointer-events: auto; appearance: none; border: 1px solid rgba(255,255,255,0.35); background: rgba(0,0,0,0.45);\n      color: #fff; border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 600; }\n    .chip.on { background: rgba(90,162,255,0.55); }\n    .chip { transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .chip.flash { background: rgba(90,162,255,0.85); border-color: #cfe3ff; transform: scale(0.92); }\n    .toast { position: absolute; top: calc(52px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);\n      padding: 6px 14px; border-radius: 999px; background: rgba(0,0,0,0.6); color: #fff; font-size: 13px; font-weight: 700;\n      letter-spacing: 0.04em; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* The in-game menu window (the Menu chip): solo pauses the game behind it, multiplayer plays on. */\n    .pausewin { position: absolute; inset: 0; z-index: 8; display: flex; align-items: center; justify-content: center;\n      background: rgba(3,6,14,0.35); pointer-events: auto; touch-action: none; }\n    .pausewin[hidden] { display: none; }\n    .pw-panel { width: min(400px, 88vw); max-height: calc(100% - 20px); overflow-y: auto; touch-action: pan-y;\n      display: flex; flex-direction: column; gap: 6px; padding: 9px 12px; box-sizing: border-box; border-radius: 16px;\n      background: rgba(10,16,34,0.55); border: 1px solid rgba(255,255,255,0.22); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);\n      color: #fff; font: 600 13px system-ui, sans-serif; }\n    .pw-title { font-size: 14px; font-weight: 900; font-style: italic; letter-spacing: 0.06em; text-align: center; }\n    .pw-ping { font: 700 12px/1.4 ui-monospace, Menlo, monospace; color: #bfe0ff; text-align: center; }\n    .pw-ping .bad { color: #ffb070; } .pw-ping .awful { color: #ff7a7a; }\n    .pw-btn { appearance: none; border: 1px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.1); color: #fff;\n      border-radius: 12px; padding: 7px 12px; font: 800 14px system-ui, sans-serif; letter-spacing: 0.03em; }\n    .pw-btn:active { transform: scale(0.98); }\n    .pw-btn.primary { background: rgba(90,162,255,0.6); border-color: #9cc6ff; }\n    .pw-btn.danger { background: rgba(255,90,90,0.35); border-color: #ffb0b0; }\n    .pw-btn[hidden] { display: none; }\n    .pw-row { display: flex; gap: 6px; } .pw-row .pw-btn { flex: 1 1 0; min-width: 0; font-size: 12px; padding-left: 6px; padding-right: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n    .pw-mixer { display: flex; flex-direction: column; gap: 0; padding: 2px 8px; border-radius: 12px; background: rgba(255,255,255,0.06);\n      border: 1px solid rgba(255,255,255,0.14); }\n    .pw-snd { display: flex; align-items: center; justify-content: space-between; padding: 3px 0; font-size: 12px; }\n    .pw-switch { appearance: none; position: relative; width: 42px; height: 24px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.35);\n      background: rgba(255,255,255,0.15); padding: 0; flex: 0 0 auto; }\n    .pw-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform 0.15s ease; }\n    .pw-switch.on { background: rgba(61,130,232,0.9); border-color: #9cc6ff; }\n    .pw-switch.on::after { transform: translateX(18px); }\n    .pw-mixer.muted .pw-mix { opacity: 0.4; }\n    .pw-mix { display: grid; grid-template-columns: 96px 1fr 40px; align-items: center; gap: 8px; }\n    .pw-mix span { font-size: 11px; white-space: nowrap; } .pw-mix b { font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; color: #cfe0ff; }\n    .pw-mix input { -webkit-appearance: none; appearance: none; width: 100%; height: 22px; background: transparent; touch-action: none; }\n    .pw-mix input::-webkit-slider-runnable-track { height: 5px; border-radius: 3px; background: linear-gradient(90deg, #5aa2ff var(--fill, 50%), #ffffff33 var(--fill, 50%)); }\n    .pw-mix input::-moz-range-track { height: 5px; border-radius: 3px; background: #ffffff33; }\n    .pw-mix input::-moz-range-progress { height: 5px; border-radius: 3px; background: #5aa2ff; }\n    .pw-mix input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: #fff; border: 2px solid #5aa2ff; }\n    .pw-mix input::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 2px solid #5aa2ff; }\n    /* Swipe trail feedback (maneuvers / menu steps). */\n    .gesture-hint { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); font-size: 44px;\n      font-weight: 900; color: #fff; opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none;\n      text-shadow: 0 2px 12px rgba(0,0,0,0.6); }\n    .gesture-hint.show { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }\n    /* The hint glyphs, drawn (nearly invisibly) on the loading screen. iOS has no U+21B6 (the U-turn\n       arrow) in the system font, and the first time it is shown it loads a fallback font, which\n       froze the first U-turn of each session. Showing it here does that load while we wait. */\n    .glyph-warm { position: absolute; left: -9999px; top: 0; font-size: 44px; font-weight: 900;\n      pointer-events: none; user-select: none; }\n    /* Mouse & keys: no on-screen buttons, no motion chips. */\n    .kbm .btn, .kbm .chip.gyro, .kbm .chip[data-act=\"sens\"], .kbm .chip[data-act=\"center\"] { display: none; }\n    /* the touch stick: fixed bottom left, beside BOOST and BRAKE, always there while it steers */\n    .knob-base { position: absolute; width: 120px; height: 120px; left: calc(116px + env(safe-area-inset-left)); bottom: 50px; border-radius: 50%;\n      border: 2px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.06); display: none; pointer-events: none; }\n    .knob-base.held { border-color: rgba(156,198,255,0.75); background: rgba(90,162,255,0.12); }\n    .knob { position: absolute; width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;\n      background: rgba(255,255,255,0.35); display: none; }\n    .rotate { position: absolute; inset: 0; z-index: 8; display: none; align-items: center; justify-content: center;\n      background: #05060aee; font-size: 16px; text-align: center; padding: 24px; }\n    @media (orientation: portrait) and (pointer: coarse) { .rotate.armed { display: flex; } }\n    /* Environment marker: this is the DEV player. */\n    .envbadge { position: absolute; top: calc(6px + env(safe-area-inset-top)); left: calc(8px + env(safe-area-inset-left));\n      z-index: 10; padding: 2px 8px; border-radius: 6px; background: #ff9f1a; color: #05060a;\n      font: 800 11px/1.5 system-ui, -apple-system, sans-serif; letter-spacing: 0.08em; pointer-events: none; }\n  `,\n\n  // Render once; everything after mount is imperative (canvas + engine) so re-renders never touch it.\n  render() {\n    return `<div class=\"wrap\" data-d2-static>\n      <div class=\"envbadge\">DEV</div>\n      <div class=\"hud\" hidden>\n        <div class=\"gesture-hint\"></div>\n        <div class=\"btn a\" data-b=\"A\">A</div>\n        <div class=\"btn b\" data-b=\"B\">B</div>\n        <div class=\"btn pill r\" data-b=\"R\">R</div>\n        <div class=\"btn pill z\" data-b=\"Z\">Z</div>\n        <div class=\"btn boost\" data-b=\"CLEFT\">BOOST</div>\n        <div class=\"btn brake\" data-b=\"CDOWN\">BRAKE</div>\n        <div class=\"btn pill start\" data-b=\"START\">START</div>\n        <div class=\"btn pill talk\" hidden>TALK</div>\n        <div class=\"toast\"></div>\n        <div class=\"top\">\n          <button class=\"chip gyro on\" data-act=\"gyro\">Gyro</button>\n          <button class=\"chip\" data-act=\"sens\">Tilt: Med</button>\n          <button class=\"chip\" data-act=\"center\">Center</button>\n          <button class=\"chip\" data-act=\"hud\">Hide</button>\n          <button class=\"chip quit\" data-act=\"quit\">Menu</button>\n        </div>\n      </div>\n      <div class=\"pausewin\" hidden>\n        <div class=\"pw-panel\">\n          <div class=\"pw-title\">PAUSED</div>\n          <div class=\"pw-ping\" hidden></div>\n          <button class=\"pw-btn primary\" data-pw=\"resume\">Resume</button>\n          <button class=\"pw-btn\" data-pw=\"retry\">Retry</button>\n          <div class=\"pw-row\"><button class=\"pw-btn\" data-pw=\"fs\">Fullscreen</button><button class=\"pw-btn\" data-pw=\"controls\">Controls</button></div>\n          <div class=\"pw-mixer\"></div>\n          <button class=\"pw-btn danger\" data-pw=\"quit\">Quit to menu</button>\n        </div>\n      </div>\n      <div class=\"overlay\">\n        <canvas class=\"sky\" aria-hidden=\"true\"></canvas>\n        <div class=\"glyph-warm\" aria-hidden=\"true\">↻↶▲▼▶◀</div>\n        <div class=\"nowload\" aria-label=\"Now loading\">${[...'NOW LOADING'].map((c, k) => c === ' ' ? '<i class=\"sp\"></i>' : `<i style=\"--k:${k}\">${c}</i>`).join('')}</div>\n        <div class=\"title\">TFSF DEV</div>\n        <div class=\"sub status\">Preparing…</div>\n        <div class=\"bar\"><i></i></div>\n        <button class=\"go start-btn\" hidden>Start</button>\n        <button class=\"go menu-btn\" hidden>Main menu</button>\n        <div class=\"err\"></div>\n      </div>\n      <div class=\"rotate\">Rotate your phone to landscape</div>\n      <div class=\"crash\" hidden>\n        <div class=\"title\">The game stopped</div>\n        <div class=\"sub crash-why\"></div>\n        <pre class=\"crash-log\"></pre>\n        <button class=\"go restart\">Back to the menu</button>\n      </div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const wrap = $('.wrap')\n    const overlay = $('.overlay')\n    const statusEl = $('.status')\n    const barEl = $('.bar > i')\n    const goBtn = $('.start-btn')\n    const menuBtn = $('.menu-btn')\n    const errEl = $('.err')\n    const hud = $('.hud')\n\n    // ---- the loading screen: stars streaming at the viewer, forever, while it is up ----------------\n    // (a 2D canvas: the engine owns the WebGL one). Its mode: a tap's button up, or a failure, hides\n    // NOW LOADING and puts the line in the middle.\n    const sky = $('.sky'), skyCx = sky.getContext('2d')\n    const STARS = 360, stars = []\n    const newStar = (z) => ({ x: Math.random() * 2 - 1, y: Math.random() * 2 - 1, z, hue: Math.random() })\n    for (let i = 0; i < STARS; i++) stars.push(newStar(Math.random()))\n    let skyRaf = 0, skyLast = 0\n    const skyFrame = (now) => {\n      if (overlay.hidden || !overlay.isConnected) { skyRaf = 0; return }\n      skyRaf = requestAnimationFrame(skyFrame)\n      const dt = Math.min(0.05, skyLast ? (now - skyLast) / 1000 : 0.016); skyLast = now\n      const dpr = Math.min(2, window.devicePixelRatio || 1)\n      const w = Math.round(sky.clientWidth * dpr), h = Math.round(sky.clientHeight * dpr)\n      if (!w || !h) return\n      if (sky.width !== w || sky.height !== h) { sky.width = w; sky.height = h }\n      const cx = w / 2, cy = h / 2, f = Math.max(w, h) * 0.42, V = 0.34\n      const g = skyCx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.7)\n      g.addColorStop(0, '#0b1730'); g.addColorStop(0.45, '#050b1a'); g.addColorStop(1, '#02040a')\n      skyCx.fillStyle = g; skyCx.fillRect(0, 0, w, h)\n      skyCx.lineCap = 'round'\n      for (const st of stars) {\n        st.z -= dt * V\n        if (st.z <= 0.02) { Object.assign(st, newStar(1)); continue }\n        const zt = Math.min(1, st.z + 0.05) // the streak: where it was a moment ago\n        const x = cx + st.x / st.z * f, y = cy + st.y / st.z * f\n        if (x < -20 || x > w + 20 || y < -20 || y > h + 20) { Object.assign(st, newStar(1)); continue }\n        const px = cx + st.x / zt * f, py = cy + st.y / zt * f\n        const k = 1 - st.z\n        skyCx.strokeStyle = `rgba(${200 + st.hue * 55 | 0},${215 + st.hue * 30 | 0},255,${Math.min(1, k * 1.4)})`\n        skyCx.lineWidth = Math.max(0.6, k * 2.6) * dpr\n        skyCx.beginPath(); skyCx.moveTo(px, py); skyCx.lineTo(x, y); skyCx.stroke()\n      }\n    }\n    const skyOn = () => { if (!skyRaf && !overlay.hidden) { skyLast = 0; skyRaf = requestAnimationFrame(skyFrame) } }\n    const skyWatch = new MutationObserver(() => {\n      skyOn()\n      overlay.classList.toggle('tap', !goBtn.hidden || !menuBtn.hidden)\n    })\n    skyWatch.observe(overlay, { attributes: true, attributeFilter: ['hidden'] })\n    skyWatch.observe(goBtn, { attributes: true, attributeFilter: ['hidden'] })\n    skyWatch.observe(menuBtn, { attributes: true, attributeFilter: ['hidden'] })\n    skyOn()\n    d2.onUnmount(() => { skyWatch.disconnect(); cancelAnimationFrame(skyRaf); skyRaf = 0 })\n    // the menu's font (the same file TFSF_MainMenu_DEV loads)\n    try {\n      if (!Array.from(document.fonts).some((ff) => ff.family.replace(/[\"']/g, '') === 'MGS')) {\n        const ff = new FontFace('MGS', 'url(https://lucca-playground.data2.media/file/y6WN_Va9uik5nCCa_K5yu)')\n        document.fonts.add(ff); ff.load().catch(() => {})\n      }\n    } catch (e) {}\n\n    // ---- the page runtime: one engine, one canvas, one pad, kept across route changes -----\n    const rt = window[RT_KEY] || (window[RT_KEY] = {})\n    if (!rt.canvas) {\n      rt.canvas = document.createElement('canvas')\n      rt.canvas.className = 'game'\n      rt.canvas.id = 'canvas'\n      rt.canvas.tabIndex = -1\n    }\n    const canvas = rt.canvas\n    wrap.insertBefore(canvas, wrap.firstChild)\n    if (!rt.pad) rt.pad = makePad()\n    const pad = rt.pad\n    let mountedHere = true\n    // The menu's music plays across the menu screens; a game loading here fades it out.\n    rt.menuMusicOff = true\n    try { rt.menuMusicStop && rt.menuMusicStop(1.0) } catch (e) {}\n\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const refs = {\n      js: d2.param('engineJs', ''),\n      wasm: d2.param('engineWasm', ''),\n      main: d2.param('mainO2r', ''),\n      port: d2.param('portO2r', ''),\n    }\n    // The engine lives as long as the tab: one started before a new engine was published would keep\n    // running. If this page's engine is not the configured one, reload to get the new one.\n    if (rt.engine && rt.engineRef && rt.engineRef !== `${refs.js}|${refs.wasm}`) {\n      location.reload()\n      return\n    }\n\n    const setStatus = (t) => { statusEl.textContent = t }\n    const setProgress = (f) => { barEl.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%` }\n    const fail = (msg) => { errEl.textContent = msg; setStatus('Could not start'); goBtn.hidden = true; menuBtn.hidden = false; overlay.classList.add('failed') }\n\n    // ---- navigation ------------------------------------------------------------------------\n    // The Router swaps artboards; this component only ever leaves /play. d2.router() is the\n    // runtime's own navigation; if it is not reachable, push the URL and let the router hear it.\n    const routerRef = String(d2.param('routerRef', '') || '')\n    const nav = (to) => {\n      try {\n        const r = routerRef ? d2.router(routerRef) : d2.router()\n        if (r && typeof r.goTo === 'function') { r.goTo(to); return }\n      } catch (e) {}\n      try { history.pushState(null, '', to); window.dispatchEvent(new PopStateEvent('popstate', { state: null })) } catch (e) { location.href = to }\n    }\n    const query = new URLSearchParams(location.search)\n    const cmd = { name: query.get('cmd') || 'new', expert: query.get('expert') === '1', level: Number(query.get('level')),\n      stage: Number(query.get('stage')) || 0, match: Number(query.get('match')) || 0, minutes: Number(query.get('minutes')) || 3, rounds: Number(query.get('rounds')) || 3 }\n\n    // Director reports: the last one is what the menus show (also kept for a reload).\n    const remember = (ev) => {\n      rt.lastEvent = ev\n      try { sessionStorage.setItem(`${STORE}.lastEvent`, JSON.stringify(ev)) } catch (e) {}\n      queueRun(ev)\n    }\n    // The pilot's record: every report that ends something (a level, a run, an arena match) is queued\n    // here, in localStorage (it survives a reload), and TFSF_MainMenu_DEV writes the queue to the TFSF\n    // Runs table when it mounts. The engine is not asked for anything new: these are its own reports.\n    const RUN_EVENTS = { 'level-end': 1, 'game-over': 1, ending: 1, 'arena-end': 1 }\n    const RUNQ_KEY = `${STORE}.runQueue`\n    const queueRun = (ev) => {\n      if (!ev || !RUN_EVENTS[ev.event]) return\n      try {\n        const email = (localStorage.getItem(`${STORE}.me`) || '').trim().toLowerCase()\n        if (!email) return\n        const mp = rt.mp || null\n        const rec = { email, name: localStorage.getItem(`${STORE}.nick`) || '', event: ev.event, runId: rt.runId || '', at: Date.now(),\n          mode: ev.event === 'arena-end' ? 'arena' : (mp && netplay.on ? 'coop' : 'solo'), report: ev }\n        for (const k of ['level', 'status', 'hits', 'totalHits', 'medal', 'lives', 'mission', 'expert']) if (Number.isFinite(Number(ev[k]))) rec[k] = Number(ev[k])\n        if (ev.event === 'arena-end' && mp) {\n          const slot = netplay.slots ? netplay.slots.indexOf(mp.me) : -1\n          const team = ((mp.teams || {})[mp.me]) === 'red' ? 1 : 0\n          rec.slot = slot\n          if (slot >= 0 && Array.isArray(ev.pilots)) rec.kills = Number(ev.pilots[slot]) || 0\n          if (Number.isFinite(Number(ev.winner)) && Number(ev.winner) >= 0) rec.won = Number(ev.winner) === team\n        }\n        const q = JSON.parse(localStorage.getItem(RUNQ_KEY) || '[]')\n        q.push(rec)\n        localStorage.setItem(RUNQ_KEY, JSON.stringify(q.slice(-200)))\n      } catch (e) {}\n    }\n\n    // Two ways in: a builder session (the Drive's builder-only policy) or a player session from\n    // the login strategy. Both live in localStorage; each request carries whichever exist.\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(`__data2-session-${systemDomain}`)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    class Refused extends Error {}\n\n    const progress = { total: 0, loaded: {} }\n    // When a download dies at the network level ('Load failed' / 'Failed to fetch'), find out which\n    // hop broke: the authorised request to the server, or the redirect it answers with.\n    const diagnose = async (ref) => {\n      try {\n        const r = await fetch(`${filesBase}/file/${ref}`, { headers: authHeaders(), redirect: 'manual' })\n        if (r.type === 'opaqueredirect') return 'server accepted the session; the browser blocked the redirect to the file'\n        return `server answered HTTP ${r.status} without redirecting`\n      } catch (e) {\n        return `the request to the server itself failed (${e && e.message})`\n      }\n    }\n\n    // Downloads go out as plain GETs with the key in the query string (the Drive has a policy that\n    // accepts it). A request with no custom headers needs no CORS preflight, and WebKit (every\n    // iPhone browser) refuses to follow the server's redirect to the signed file URL after one.\n    // The key itself is read through the session-gated keys DataSet, which involves no redirect.\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const getDownloadKey = async () => {\n      const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (res.status === 401 || res.status === 403) throw new Refused(`keys: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`keys: HTTP ${res.status}`)\n      const body = await res.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const k = rows[0] && rows[0].key\n      if (!k) throw new Refused('keys: no key readable for this session')\n      return k\n    }\n    let downloadKey = ''\n\n    const fetchBytes = async (key, ref) => {\n      if (!ref) throw new Error(`Missing file reference for ${key}`)\n      let res\n      try {\n        res = await fetch(`${filesBase}/file/${ref}?t=${encodeURIComponent(downloadKey)}`)\n      } catch (e) {\n        const ua = (navigator.userAgent.match(/(iPhone|iPad|Android|Macintosh|Windows)[^;)]*/) || [''])[0]\n        throw new Error(`${key}: ${e && e.message} | ${await diagnose(ref)} | ${ua}`)\n      }\n      if (res.status === 401 || res.status === 403) throw new Refused(`${key}: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`)\n      const len = Number(res.headers.get('content-length')) || 0\n      progress.total += len\n      if (!res.body || !len) return new Uint8Array(await res.arrayBuffer())\n      const reader = res.body.getReader()\n      const chunks = []\n      let got = 0\n      for (;;) {\n        const { done, value } = await reader.read()\n        if (done) break\n        chunks.push(value)\n        got += value.length\n        progress.loaded[key] = got\n        const sum = Object.values(progress.loaded).reduce((a, b) => a + b, 0)\n        setProgress(progress.total ? sum / progress.total : 0)\n      }\n      const out = new Uint8Array(got)\n      let o = 0\n      for (const c of chunks) { out.set(c, o); o += c.length }\n      return out\n    }\n\n    // ---- input state ---------------------------------------------------------------------\n    // gyro: exact steering (-1..1, +x right, +y down). gyroPad: the same, pushed past\n    // libultraship's 20% stick deadzone, for engines without the direct stick export.\n    const input = { gyroOn: true, stickActive: false, stick: [0, 0], gyro: [0, 0], gyroPad: [0, 0], neutral: null, lastOri: null }\n    // The control scheme picked in the menu (/solo → Controls): 'touch' (on-screen stick) and 'mouse'\n    // (mouse & keyboard) start with motion steering off.\n    let SCHEME = readKey(`${STORE}.controls`) || 'motion' // changed in game from the menu window (setScheme)\n    input.gyroOn = SCHEME === 'motion'\n    input.keyStick = null\n    $('.chip.gyro').classList.toggle('on', input.gyroOn)\n\n    // Motion steering goes straight into the engine's stick (exact values, no gamepad deadzone or\n    // notch snapping) through web_set_stick (engine v10+). The touch stick, and older engines,\n    // go through the virtual gamepad.\n    const syncAxes = () => {\n      const direct = !!(engine && typeof engine._web_set_stick === 'function' && !crashed)\n      // Only once motion data is actually arriving (desktop keeps keyboard/gamepad sticks), and\n      // only while flying: in menus the tilt would wander the cursor.\n      const useGyro = input.gyroOn && !input.stickActive && !!input.hasMotion && uiMode() === 0\n      // On motion-controlled devices the maneuvers belong to the swipes: switch off the classic\n      // pull-back + Boost/Brake trigger so hard tilts never loop by accident (desktop keeps it).\n      if (input.hasMotion && !input.touchManeuvers && engine && typeof engine._web_set_touch_maneuvers === 'function' && !crashed) {\n        try { engine._web_set_touch_maneuvers(1); input.touchManeuvers = true } catch (e) {}\n      }\n      if (direct && input.pulse) {\n        // Gesture pulses (maneuvers, menu steps) are full-strength stick pushes, +y up.\n        try { engine._web_set_stick(1, input.pulse[0], input.pulse[1]) } catch (e) {}\n        pad.axes[0] = 0\n        pad.axes[1] = 0\n        return\n      }\n      // What steers right now (+x right, +y down): the touch stick, the keys, else motion.\n      const steer = input.stickActive ? input.stick : input.keyStick ? input.keyStick : useGyro ? input.gyro : null\n      if (direct) {\n        // Star Fox ignores raw stick values within +-16 (of 85) and saturates at 76, per axis\n        // (sys_joybus.c). Map our 0..1 onto exactly that live band, so the smallest tilt past our\n        // own tremor filter already turns the ship and full tilt is full turn.\n        const live = (v) => (v === 0 ? 0 : Math.sign(v) * (16.5 + Math.min(1, Math.abs(v)) * (76 - 16.5)) / 85)\n        try { engine._web_set_stick(steer ? 1 : 0, live(steer ? steer[0] : 0), live(steer ? -steer[1] : 0)) } catch (e) {}\n      }\n      const src = input.pulse ? [input.pulse[0], -input.pulse[1]] : direct ? [0, 0] : input.stickActive ? input.stick : input.keyStick ? input.keyStick : (useGyro ? input.gyroPad : [0, 0])\n      pad.axes[0] = src[0]\n      pad.axes[1] = src[1]\n    }\n\n    const setButton = (name, down) => {\n      const i = BUTTON[name]\n      if (i == null) return\n      pad.buttons[i] = { pressed: down, touched: down, value: down ? 1 : 0 }\n    }\n\n    // Buttons: multi-touch, each pointer tracked to the button it started on.\n    // The engine samples the pad once per frame, so a quick tap is held for MIN_HOLD_MS —\n    // otherwise down+up can both land between two polls and the press is never seen.\n    const MIN_HOLD_MS = 80\n    const held = new Map()\n    d2.on('.btn', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      el.setPointerCapture?.(e.pointerId)\n      held.set(e.pointerId, { el, at: performance.now() })\n      el.classList.add('on')\n      if (el.classList.contains('talk')) return comms.talk(true)\n      setButton(el.dataset.b, true)\n      if (navigator.vibrate) navigator.vibrate(8)\n    })\n    const release = (e) => {\n      const h = held.get(e.pointerId)\n      if (!h) return\n      held.delete(e.pointerId)\n      if (h.el.classList.contains('talk')) { h.el.classList.remove('on'); return comms.talk(false) }\n      const up = () => {\n        h.el.classList.remove('on')\n        setButton(h.el.dataset.b, false)\n      }\n      const left = MIN_HOLD_MS - (performance.now() - h.at)\n      if (left > 0) setTimeout(up, left)\n      else up()\n    }\n    d2.on(root, 'pointerup', release)\n    d2.on(root, 'pointercancel', release)\n\n    // ---- touch gestures on the game screen ------------------------------------------------\n    // Flying:  swipe up = somersault (pull back + Boost), swipe down = U-turn (pull back + Brake).\n    //          The game only triggers them from that combination inside a few frames, so the\n    //          swipe plays it for ~160 ms. Barrel roll stays on double-tapping Z / R.\n    // Menus:   tap = select (main menu cards are hit-tested by the engine; elsewhere a tap is A,\n    //          and START on the title screen); swipes step the cursor one notch.\n    // The engine says which applies (web_ui_mode); motion steering is off in menus.\n    const uiMode = () => {\n      try { return engine && typeof engine._web_ui_mode === 'function' ? engine._web_ui_mode() : 0 } catch (e) { return 0 }\n    }\n    const pressFor = (name, ms) => {\n      setButton(name, true)\n      setTimeout(() => setButton(name, false), ms)\n    }\n    const pulse = (x, y, ms) => {\n      input.pulse = [x, y]\n      syncAxes()\n      clearTimeout(input.pulseTimer)\n      input.pulseTimer = setTimeout(() => { input.pulse = null; syncAxes() }, ms)\n    }\n    let hintTimer = null\n    const hint = (glyph) => {\n      const h = $('.gesture-hint')\n      if (!h) return\n      h.textContent = glyph\n      h.classList.add('show')\n      clearTimeout(hintTimer)\n      hintTimer = setTimeout(() => h.classList.remove('show'), 350)\n    }\n    const maneuver = (kind, button, glyph) => {\n      // The engine starts the maneuver itself, under the game's own rules (full boost meter;\n      // U-turn only in all-range). No input macro: when it is not possible, nothing happens.\n      if (typeof engine._web_maneuver !== 'function') return\n      let ok = 0\n      try { ok = engine._web_maneuver(kind) } catch (e) { return }\n      if (!ok) return // not possible right now (boost recharging, on rails for a U-turn, ...)\n      hint(glyph)\n      try { navigator.vibrate && navigator.vibrate(15) } catch (e) {}\n    }\n    // Canvas point -> the game's 320x240 2D space (a 4:3 area centred in the canvas).\n    const toGame = (cx, cy) => {\n      const r = canvas.getBoundingClientRect()\n      const w = (r.height * 4) / 3\n      return [((cx - r.left - (r.width - w) / 2) / w) * 320, ((cy - r.top) / r.height) * 240]\n    }\n    const gestures = new Map()\n    const gestureTarget = (t) => !(t && t.closest && t.closest('.btn, .chip, .overlay, .crash, .pausewin, input'))\n    d2.on(root, 'pointerdown', (e) => {\n      if (!engine || crashed || !gestureTarget(e.target)) return\n      if (stickStart(e)) return // the left half steers (touch stick); swipes stay on the right\n      gestures.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() })\n    })\n    const endGesture = (e) => {\n      const g = gestures.get(e.pointerId)\n      if (!g) return\n      gestures.delete(e.pointerId)\n      if (e.type === 'pointercancel' || !engine || crashed) return\n      const dx = e.clientX - g.x\n      const dy = e.clientY - g.y\n      const dt = performance.now() - g.t\n      const dist = Math.hypot(dx, dy)\n      const mode = uiMode()\n      if (dist < 14 && dt < 400) {\n        if (mode !== 1) return // taps do nothing while flying\n        const [gx, gy] = toGame(e.clientX, e.clientY)\n        let code = 0\n        try { code = typeof engine._web_tap === 'function' ? engine._web_tap(gx, gy) : 0 } catch (err) { code = 0 }\n        if (code === 0) pressFor('A', 120)\n        else if (code === 2) pressFor('START', 120)\n        return\n      }\n      if (dist < 40 || dt > 600) return\n      const vertical = Math.abs(dy) > 1.3 * Math.abs(dx)\n      const horizontal = Math.abs(dx) > 1.3 * Math.abs(dy)\n      if (mode === 1) {\n        // One cursor step per swipe, as a short full stick push.\n        if (vertical) { pulse(0, dy < 0 ? 1 : -1, 130); hint(dy < 0 ? '▲' : '▼') }\n        else if (horizontal) { pulse(dx > 0 ? 1 : -1, 0, 130); hint(dx > 0 ? '▶' : '◀') }\n        return\n      }\n      if (vertical) {\n        if (dy < 0) maneuver(1, 'CLEFT', '↻') // up: somersault\n        else maneuver(2, 'CDOWN', '↶')        // down: U-turn\n      }\n    }\n    d2.on(root, 'pointerup', endGesture)\n    d2.on(root, 'pointercancel', endGesture)\n\n    // ---- touch stick (motion steering off: the Touch scheme, or the Gyro chip turned off) --------\n    // A fixed stick, bottom left beside BOOST and BRAKE, shown whenever it steers. A thumb landing on\n    // or near it takes it; the knob follows (from the stick's centre) until the thumb lifts, then\n    // springs back. Vertical is inverted: drag up climbs, drag down dives.\n    const STICK_R = 52 // px of drag for full stick\n    const knobBase = document.createElement('div')\n    knobBase.className = 'knob-base'\n    const knob = document.createElement('div')\n    knob.className = 'knob'\n    hud.appendChild(knobBase)\n    hud.appendChild(knob)\n    const stick = { id: null, x0: 0, y0: 0 }\n    const STICK_GRAB = 130 // px from the stick's centre a thumb may land and still take it\n    const stickOn = () => SCHEME !== 'mouse' && uiMode() === 0 && !(input.gyroOn && input.hasMotion)\n    const placeKnob = (el, x, y) => { el.style.left = `${x}px`; el.style.top = `${y}px`; el.style.display = 'block' }\n    const stickCentre = () => {\n      const r = root.getBoundingClientRect(), b = knobBase.getBoundingClientRect()\n      return [b.left - r.left + b.width / 2, b.top - r.top + b.height / 2]\n    }\n    // shown while it steers (the Touch scheme, flying), the knob at rest in the middle\n    const stickShow = () => {\n      const show = stickOn() && !hud.hidden\n      knobBase.style.display = show ? 'block' : 'none'\n      if (!show) { knob.style.display = 'none'; return }\n      if (stick.id === null) { const [cx, cy] = stickCentre(); placeKnob(knob, cx, cy) }\n    }\n    const stickShowTimer = setInterval(stickShow, 250)\n    d2.onUnmount(() => clearInterval(stickShowTimer))\n    const stickStart = (e) => {\n      if (stick.id !== null || !stickOn()) return false\n      knobBase.style.display = 'block'\n      const r = root.getBoundingClientRect()\n      const [cx, cy] = stickCentre()\n      if (Math.hypot(e.clientX - r.left - cx, e.clientY - r.top - cy) > STICK_GRAB) return false\n      stick.id = e.pointerId\n      stick.x0 = cx\n      stick.y0 = cy\n      knobBase.classList.add('held')\n      let dx = e.clientX - r.left - cx, dy = e.clientY - r.top - cy\n      const d0 = Math.hypot(dx, dy)\n      if (d0 > STICK_R) { dx *= STICK_R / d0; dy *= STICK_R / d0 }\n      placeKnob(knob, cx + dx, cy + dy)\n      input.stickActive = true\n      input.stick = [dx / STICK_R, -dy / STICK_R]\n      syncAxes()\n      return true\n    }\n    d2.on(root, 'pointermove', (e) => {\n      if (e.pointerId !== stick.id) return\n      const r = root.getBoundingClientRect()\n      let dx = e.clientX - r.left - stick.x0\n      let dy = e.clientY - r.top - stick.y0\n      const d = Math.hypot(dx, dy)\n      if (d > STICK_R) { dx *= STICK_R / d; dy *= STICK_R / d }\n      placeKnob(knob, stick.x0 + dx, stick.y0 + dy)\n      input.stick = [dx / STICK_R, -dy / STICK_R] // vertical inverted: drag up = pull back = climb\n      syncAxes()\n    })\n    const stickEnd = (e) => {\n      if (e.pointerId !== stick.id) return\n      stick.id = null\n      knobBase.classList.remove('held')\n      placeKnob(knob, stick.x0, stick.y0) // back to the middle\n      input.stickActive = false\n      input.stick = [0, 0]\n      syncAxes()\n    }\n    d2.on(root, 'pointerup', stickEnd)\n    d2.on(root, 'pointercancel', stickEnd)\n\n    // ---- mouse & keyboard (the Mouse & keys scheme) --------------------------------------------\n    //   W/A/S/D or arrows  steer (W climbs, S dives)    left click   laser (hold: lock-on)\n    //   Space              boost                        right click  bomb\n    //   Shift              brake                        Q / E        bank left / right (double tap: roll)\n    //   X                  somersault                   C            U-turn (all-range)\n    //   Enter              start\n    // The keys go straight to the virtual pad; the engine's own keyboard mapping never sees them.\n    // The listeners are always on but act only in this scheme (it can be picked in game); Escape\n    // (the menu window) works in every scheme.\n    let kbmRelease = () => {}\n    {\n      wrap.classList.toggle('kbm', SCHEME === 'mouse')\n      const keys = new Set()\n      const STEER = { KeyW: 'up', ArrowUp: 'up', KeyS: 'down', ArrowDown: 'down', KeyA: 'left', ArrowLeft: 'left', KeyD: 'right', ArrowRight: 'right' }\n      const PRESS = { Space: 'CLEFT', ShiftLeft: 'CDOWN', ShiftRight: 'CDOWN', KeyQ: 'Z', KeyE: 'R', Enter: 'START' }\n      const updateKeys = () => {\n        const has = (dir) => [...keys].some((k) => STEER[k] === dir)\n        const x = (has('right') ? 1 : 0) - (has('left') ? 1 : 0)\n        const y = (has('up') ? 1 : 0) - (has('down') ? 1 : 0) // +y down on the stick = pull back = climb\n        const m = x && y ? Math.SQRT1_2 : 1\n        input.keyStick = x || y ? [x * m, y * m] : null\n        syncAxes()\n      }\n      const onKey = (e) => {\n        const down = e.type === 'keydown'\n        const code = e.code\n        if (code === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); if (down && !e.repeat && engine && !crashed) menuWin.toggle(); return }\n        if (SCHEME !== 'mouse') return\n        if (code === 'KeyV') { e.preventDefault(); e.stopImmediatePropagation(); if (!e.repeat) comms.talk(down); return }\n        if (!(code in STEER) && !(code in PRESS) && code !== 'KeyX' && code !== 'KeyC') return\n        e.preventDefault()\n        e.stopImmediatePropagation()\n        if (code in STEER) { down ? keys.add(code) : keys.delete(code); return updateKeys() }\n        if (code in PRESS) return setButton(PRESS[code], down)\n        if (down && !e.repeat && engine && uiMode() === 0) maneuver(code === 'KeyX' ? 1 : 2, code === 'KeyX' ? 'CLEFT' : 'CDOWN', code === 'KeyX' ? '↻' : '↶')\n      }\n      const MOUSE = { 0: 'A', 2: 'B' }\n      const onMouse = (e) => {\n        if (SCHEME !== 'mouse' || !(e.button in MOUSE) || (e.target && e.target.closest && e.target.closest('.chip, .overlay, .crash, .talk, .pausewin'))) return\n        e.preventDefault()\n        setButton(MOUSE[e.button], e.type === 'mousedown')\n      }\n      const noMenu = (e) => { if (SCHEME === 'mouse') e.preventDefault() }\n      const blurAll = () => { comms.talk(false); keys.clear(); updateKeys(); for (const b of Object.values(PRESS).concat(['A', 'B'])) setButton(b, false) }\n      kbmRelease = blurAll\n      window.addEventListener('keydown', onKey, true)\n      window.addEventListener('keyup', onKey, true)\n      root.addEventListener('mousedown', onMouse)\n      window.addEventListener('mouseup', onMouse)\n      root.addEventListener('contextmenu', noMenu)\n      window.addEventListener('blur', blurAll)\n      d2.onUnmount(() => {\n        window.removeEventListener('keydown', onKey, true)\n        window.removeEventListener('keyup', onKey, true)\n        root.removeEventListener('mousedown', onMouse)\n        window.removeEventListener('mouseup', onMouse)\n        root.removeEventListener('contextmenu', noMenu)\n        window.removeEventListener('blur', blurAll)\n        input.keyStick = null\n      })\n    }\n\n    // ---- motion steering: 6-axis sensor fusion -------------------------------------------\n    // The gyroscope (rotationRate, deg/s) is the primary signal: fast and clean, so no heavy\n    // smoothing (and no lag). The accelerometer only anchors it: a complementary filter pulls the\n    // gyro-propagated gravity estimate toward the measured one, trusting the measurement less\n    // when its magnitude is far from 1 g (hand jolts, not tilt).\n    //   vertical   = pitch: tipping the top edge toward / away     (absolute, gravity-anchored)\n    //   horizontal = roll (turning the phone like a wheel)         (absolute, gravity-anchored)\n    //              + yaw  (turning it like a tray, about the real-world vertical, whatever the\n    //                      phone's tilt). Yaw has no absolute reference: it holds while you\n    //                      hold the turn, fades back over ~20 s, and Center zeroes it.\n    // Angles are relative to a neutral pose captured at start, on rotation, and by Center.\n    const SENS = [{ label: 'Low', deg: 28 }, { label: 'Med', deg: 20 }, { label: 'High', deg: 14 }]\n    let sensIdx = 1\n    // The Tilt chip owns sensitivity; the instance's gyroRangeDeg is only a hint for the start level.\n    const hinted = Number(d2.param('gyroRangeDeg', 0)) || 0\n    if (hinted) sensIdx = SENS.reduce((best, s, i) => (Math.abs(s.deg - hinted) < Math.abs(SENS[best].deg - hinted) ? i : best), 0)\n    const rollRange = () => SENS[sensIdx].deg        // degrees of wheel-turn for full stick\n    const pitchRange = () => SENS[sensIdx].deg       // degrees of tip for full stick\n    const yawRange = () => SENS[sensIdx].deg * 1.5   // degrees of tray-turn for full stick\n    // Yaw holds while the phone is held turned (like roll), and fades back only slowly so leftover\n    // gyro drift can never build up. (It was 2 s: the ship turned, then drifted back mid-hold.)\n    const YAW_RECENTER_S = 20\n    const YAW_RATE_DEAD = 0.3   // deg/s: ignore residual rate when still\n    const YAW_BIAS_S = 5        // gyro bias learning time constant\n    const YAW_BIAS_GATE = 1.5   // deg/s: only learn bias while (nearly) not turning\n    const DEAD = 0.0075                              // soft deadzone (hand tremor), stick units\n    const LUS_DEAD = 0.21                            // only for the virtual-pad fallback path\n    const G = 9.81\n    const DEG = 180 / Math.PI\n\n    const screenAngle = () => {\n      const a = screen.orientation && typeof screen.orientation.angle === 'number' ? screen.orientation.angle : (window.orientation || 0)\n      return ((a % 360) + 360) % 360\n    }\n    // Device x/y (portrait frame) -> screen x (right) / y (up) for the current rotation; z unchanged.\n    const toScreen = (x, y) => {\n      switch (screenAngle()) {\n        case 90: return [y, -x]\n        case 180: return [-x, -y]\n        case 270: return [-y, x]\n        default: return [x, y]\n      }\n    }\n    const wrapDeg = (d) => ((d + 540) % 360) - 180\n    const clamp1 = (v) => clamp(v, -1, 1)\n    // Soft deadzone + mild curve: tremor vanishes, precision near centre, full range kept.\n    const shapeAxis = (v) => {\n      const a = Math.abs(v)\n      if (a <= DEAD) return 0\n      const n = Math.min(1, (a - DEAD) / (1 - DEAD))\n      return Math.sign(v) * n * (0.85 + 0.15 * n)\n    }\n    // Tiered smoothing: only tiny changes are smoothed; real motions pass straight through.\n    const out = [0, 0]\n    const smoothTiered = (i, v) => {\n      const d = v - out[i]\n      const w = 0.35 + 0.65 * Math.min(1, Math.abs(d) / 0.06)\n      out[i] += w * d\n      return out[i]\n    }\n\n    const fusion = { f: null, t: 0, yaw: 0, yawBias: 0, neutralRef: null }\n    const norm = (v) => { const m = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / m, v[1] / m, v[2] / m] }\n    // A world-fixed vector seen from the device turns opposite to the device: dv = -w x v dt.\n    const rotStep = (v, w, dt) => norm([\n      v[0] - (w[1] * v[2] - w[2] * v[1]) * dt,\n      v[1] - (w[2] * v[0] - w[0] * v[2]) * dt,\n      v[2] - (w[0] * v[1] - w[1] * v[0]) * dt,\n    ])\n\n    // Gyro axis order. The spec says rotationRate alpha/beta/gamma are rates about the device's\n    // z/x/y axes; WebKit (every iPhone browser) reports them about x/y/z. Read the wrong way,\n    // pitching leaks into yaw and the ship moves diagonally. Rather than trust either, keep\n    // checking which mapping (and sign) predicts the accelerometer's view of gravity best and use\n    // that one; the platform only sets the starting guess.\n    const GYRO_MAPS = [\n      (r) => [r.beta, r.gamma, r.alpha],\n      (r) => [r.alpha, r.beta, r.gamma],\n      (r) => [-r.beta, -r.gamma, -r.alpha],\n      (r) => [-r.alpha, -r.beta, -r.gamma],\n    ]\n    const isWebKitMobile = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n    const cal = { score: GYRO_MAPS.map(() => 0), best: isWebKitMobile ? 1 : 0, q: null, n: 0 }\n    const calibrate = (cands, m, dt, mag) => {\n      // Only while the accelerometer reads clean gravity (no jolts).\n      if (!cal.q || Math.abs(mag - G) > 0.08 * G) { cal.q = cands.map(() => m); cal.n = 0; return }\n      cal.q = cal.q.map((q, i) => rotStep(q, cands[i], dt))\n      if (++cal.n < 8) return\n      // Over ~130 ms windows with real rotation (> ~20 deg/s), score each mapping's prediction.\n      if (Math.hypot(cands[0][0], cands[0][1], cands[0][2]) > 0.35) {\n        cal.q.forEach((q, i) => { cal.score[i] = cal.score[i] * 0.97 + (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 })\n        let best = cal.best\n        cal.score.forEach((s, i) => { if (s < cal.score[best] * 0.6) best = i })\n        cal.best = best\n      }\n      cal.q = cands.map(() => m)\n      cal.n = 0\n    }\n\n    const publish = (h, v) => {\n      // h: +right, v: +down (gamepad convention). Default pitch: tipping the top edge away climbs\n      // (flight-sim style); invertY flips it.\n      const invertY = !!d2.param('invertY', false)\n      const sx = smoothTiered(0, shapeAxis(clamp1(h)))\n      const sy = smoothTiered(1, shapeAxis(clamp1(v)) * (invertY ? 1 : -1))\n      input.gyro = [sx, sy]\n      const fb = (x) => (x === 0 ? 0 : Math.sign(x) * (LUS_DEAD + (1 - LUS_DEAD) * Math.abs(x)))\n      input.gyroPad = [fb(sx), fb(sy)]\n      syncAxes()\n    }\n\n    let gotMotion = false\n    d2.on(window, 'devicemotion', (e) => {\n      const a = e.accelerationIncludingGravity\n      if (!a || a.x == null || a.y == null || a.z == null) return\n      gotMotion = true\n      input.hasMotion = true\n      const now = performance.now()\n      const dt = fusion.t ? Math.min(0.1, Math.max(0.001, (now - fusion.t) / 1000)) : (e.interval > 1 ? e.interval / 1000 : (e.interval || 0.016))\n      fusion.t = now\n\n      // Everything in the screen frame: x right, y up, z out of the screen.\n      const [ax, ay] = toScreen(a.x, a.y)\n      const meas = [ax, ay, a.z]\n      const mag = Math.hypot(meas[0], meas[1], meas[2])\n      const r = e.rotationRate\n      const haveGyro = !!(r && r.alpha != null && r.beta != null && r.gamma != null)\n      let w = [0, 0, 0]\n      if (haveGyro) {\n        // Each candidate mapping as a device-frame rate vector, then into the screen frame (rad/s).\n        const cands = GYRO_MAPS.map((fn) => {\n          const d = fn(r)\n          const [sx, sy] = toScreen(d[0], d[1])\n          return [sx / DEG, sy / DEG, d[2] / DEG]\n        })\n        calibrate(cands, norm(meas), dt, mag)\n        w = cands[cal.best]\n      }\n\n      if (!fusion.f || !haveGyro) {\n        fusion.f = norm(meas)\n      } else {\n        const pred = rotStep(fusion.f, w, dt)\n        // Trust the accelerometer only when it reads ~1 g AND the phone is not rotating: turning the\n        // phone swings it on an arc, and that sideways push read as a tilt (a phantom roll).\n        const spin = Math.hypot(w[0], w[1], w[2]) * DEG\n        const trust = Math.exp(-Math.pow((mag - G) / (0.15 * G), 2)) * Math.exp(-Math.pow(spin / 45, 2))\n        const k = Math.min(1, 0.5 * dt) * trust                     // ~2 s time constant at rest\n        const m = norm(meas)\n        fusion.f = norm([pred[0] + k * (m[0] - pred[0]), pred[1] + k * (m[1] - pred[1]), pred[2] + k * (m[2] - pred[2])])\n      }\n      const f = fusion.f\n\n      // Absolute angles from the fused gravity (either sign convention works: we use deltas).\n      const rollDeg = Math.atan2(f[0], -f[1]) * DEG\n      const pitchDeg = Math.atan2(f[2], -f[1]) * DEG\n      input.lastOri = [rollDeg, pitchDeg]\n      if (!input.neutral) input.neutral = [rollDeg, pitchDeg]\n      if (fusion.neutralRef !== input.neutral) { fusion.neutralRef = input.neutral; fusion.yaw = 0 }\n\n      // Yaw about the real-world vertical: project the rotation rate onto 'up'. 'Up' is the fused\n      // gravity axis, signed so it points toward the top of the screen / out of a tipped-back screen,\n      // which is independent of each browser's accelerometer sign convention.\n      if (haveGyro) {\n        const s = (f[1] + f[2]) >= 0 ? 1 : -1\n        const yawRate = (w[0] * f[0] + w[1] * f[1] + w[2] * f[2]) * s * DEG // deg/s, + = turning left\n        // Sign confirmed on a real phone (twice): this way, turning the phone right steers right.\n        // (The desktop motion simulator suggests the opposite; trust the device.)\n        let yr = yawRate\n        if (Math.abs(yr - fusion.yawBias) < YAW_BIAS_GATE) fusion.yawBias += (yr - fusion.yawBias) * Math.min(1, dt / YAW_BIAS_S)\n        yr -= fusion.yawBias\n        if (Math.abs(yr) < YAW_RATE_DEAD) yr = 0\n        fusion.yaw = (fusion.yaw + yr * dt) * Math.exp(-dt / YAW_RECENTER_S)\n      }\n\n      const roll = wrapDeg(rollDeg - input.neutral[0])\n      const pitch = wrapDeg(pitchDeg - input.neutral[1])\n      publish(roll / rollRange() + fusion.yaw / yawRange(), pitch / pitchRange())\n    })\n    // Fallback for browsers that only expose orientation angles (no gyro rate, no yaw).\n    d2.on(window, 'deviceorientation', (e) => {\n      if (gotMotion || e.beta == null || e.gamma == null) return\n      input.hasMotion = true\n      const [x, y] = toScreen(e.gamma, -e.beta)\n      input.lastOri = [x, -y]\n      if (!input.neutral) input.neutral = [x, -y]\n      publish(wrapDeg(x - input.neutral[0]) / rollRange(), wrapDeg(-y - input.neutral[1]) / pitchRange())\n    })\n    d2.on(window, 'orientationchange', () => { input.neutral = null; fusion.f = null; cal.q = null })\n\n    // Press feedback for the chips, like the game buttons: a flash, a tick of vibration where the\n    // browser supports it, and a short message for actions with no other visible effect.\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      if (!t) return\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1100)\n    }\n    d2.on('.chip', 'pointerdown', (e, el) => {\n      el.classList.add('flash')\n      setTimeout(() => el.classList.remove('flash'), 160)\n      try { navigator.vibrate && navigator.vibrate(10) } catch (err) {}\n    })\n\n    d2.on('.chip', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'gyro') {\n        input.gyroOn = !input.gyroOn\n        el.classList.toggle('on', input.gyroOn)\n        syncAxes()\n      } else if (act === 'sens') {\n        sensIdx = (sensIdx + 1) % SENS.length\n        el.textContent = `Tilt: ${SENS[sensIdx].label}`\n        toast(`Tilt sensitivity: ${SENS[sensIdx].label}`)\n      } else if (act === 'center') {\n        input.neutral = input.lastOri ? [...input.lastOri] : null\n        toast(input.hasMotion ? 'Centered' : 'No motion sensor')\n      } else if (act === 'hud') {\n        const hidden = root.querySelectorAll('.btn, .stickzone')\n        const now = el.textContent === 'Hide'\n        hidden.forEach((n) => { n.style.visibility = now ? 'hidden' : '' })\n        el.textContent = now ? 'Show' : 'Hide'\n      } else if (act === 'quit') {\n        menuWin.open()\n      }\n    })\n\n    // ---- the in-game menu window ---------------------------------------------------------------\n    // Solo: the game pauses behind it (the game's own pause, its pause screen hidden: web_pause; where\n    // the game refuses a pause, as in intros, the main loop stops instead). Multiplayer: the match\n    // goes on, and the window shows the link's ping.\n    const quitLevel = () => {\n      try {\n        if (engine && typeof engine._web_quit === 'function' && !engine._web_holding()) engine._web_quit()\n        else nav('/')\n      } catch (err) { nav('/') }\n    }\n    const fsSupported = () => !!(document.fullscreenEnabled || document.webkitFullscreenEnabled)\n    const fsOn = () => !!(document.fullscreenElement || document.webkitFullscreenElement)\n    const toggleFullscreen = async () => {\n      try {\n        if (fsOn()) await (document.exitFullscreen ? document.exitFullscreen() : document.webkitExitFullscreen())\n        else await enterFullscreen()\n      } catch (e) {}\n    }\n    // Controls, as on /solo and in the lobby (`${STORE}.controls`): switched here without leaving the game.\n    const SCHEMES = [{ id: 'motion', label: 'Motion' }, { id: 'touch', label: 'Touch' }, { id: 'mouse', label: 'Mouse & keys' }]\n    const setScheme = (id) => {\n      if (SCHEME === 'mouse' && id !== 'mouse') { try { kbmRelease() } catch (e) {} }\n      SCHEME = id\n      try { localStorage.setItem(`${STORE}.controls`, id) } catch (e) {}\n      input.gyroOn = id === 'motion'\n      $('.chip.gyro').classList.toggle('on', input.gyroOn)\n      wrap.classList.toggle('kbm', id === 'mouse')\n      const talkBtn = root.querySelector('.talk')\n      if (talkBtn) talkBtn.textContent = id === 'mouse' ? 'HOLD V · TALK' : 'TALK'\n      if (id === 'motion') askMotionPermission() // iOS: inside this tap\n      syncAxes()\n    }\n    const PW_MIX = [\n      { id: 'master', label: 'Master' }, { id: 'sfx', label: 'Sound effects' }, { id: 'bgm', label: 'Music' },\n      { id: 'radio', label: 'Radio (CPU)' }, { id: 'voice', label: 'Voice chat' },\n    ]\n    const menuWin = {\n      el: $('.pausewin'),\n      isOpen: false,\n      mode: null, // 'engine' (game paused), 'loop' (main loop stopped), null (multiplayer: plays on)\n      timer: 0,\n      solo: () => !(netplay && netplay.on),\n      paint() {\n        const solo = menuWin.solo()\n        root.querySelector('.pw-title').textContent = solo && menuWin.mode ? 'PAUSED' : 'MENU'\n        root.querySelector('[data-pw=\"retry\"]').hidden = !(solo && menuWin.mode === 'engine' && cmd.name !== 'training')\n        const fs = root.querySelector('[data-pw=\"fs\"]')\n        fs.hidden = !fsSupported()\n        fs.textContent = fsOn() ? 'Fullscreen: On' : 'Fullscreen: Off'\n        root.querySelector('[data-pw=\"controls\"]').textContent = `Controls: ${(SCHEMES.find((c) => c.id === SCHEME) || SCHEMES[0]).label}`\n        const v = readVolume()\n        const mixer = root.querySelector('.pw-mixer')\n        mixer.classList.toggle('muted', v.muted)\n        mixer.innerHTML = `<div class=\"pw-snd\"><span>Sound</span><button class=\"pw-switch ${v.muted ? '' : 'on'}\" data-pw=\"sound\" role=\"switch\" aria-checked=\"${!v.muted}\" aria-label=\"Sound\"></button></div>` +\n          PW_MIX.map((m) => `<label class=\"pw-mix\"><span>${m.label}</span>\n          <input type=\"range\" min=\"0\" max=\"200\" step=\"5\" value=\"${v[m.id]}\" data-pvol=\"${m.id}\" style=\"--fill:${v[m.id] / 2}%\"><b>${v[m.id]}%</b></label>`).join('')\n        menuWin.ping()\n      },\n      ping() {\n        const el = root.querySelector('.pw-ping')\n        const net = rt.net\n        if (menuWin.solo() || !net || !net.peers) { el.hidden = true; return }\n        const cls = (ms) => (ms > 250 ? 'awful' : ms > 120 ? 'bad' : '')\n        const fmt = (p) => (p && p.state === 'open' && p.rtt ? `<span class=\"${cls(p.rtt)}\">${p.rtt} ms</span>` : '—')\n        const nick = (email) => PILOT_NAMES[(rt.mp && rt.mp.pilots || {})[email]] || String(email).split('@')[0]\n        let html\n        if (net.isHost()) {\n          const rows = (netplay.slots || []).filter((e) => e !== rt.mp.me).map((e) => `${nick(e)} ${fmt(net.peers.get(e))}`)\n          html = rows.length ? `Ping · ${rows.join(' · ')}` : 'Ping · no other pilots'\n        } else {\n          html = `Ping to host · ${fmt(net.peers.get(net.host))}`\n        }\n        el.innerHTML = html\n        el.hidden = false\n      },\n      open() {\n        if (menuWin.isOpen || !engine || crashed) return\n        menuWin.isOpen = true\n        menuWin.mode = null\n        if (menuWin.solo()) {\n          let ok = 0\n          try { ok = typeof engine._web_pause === 'function' ? engine._web_pause(1) : 0 } catch (e) {}\n          if (ok) menuWin.mode = 'engine'\n          else {\n            try { engine.pauseMainLoop(); engineOut(false) } catch (e) {}\n            menuWin.mode = 'loop'\n          }\n        }\n        menuWin.paint()\n        menuWin.el.hidden = false\n        clearInterval(menuWin.timer)\n        menuWin.timer = setInterval(menuWin.tick, 500)\n      },\n      close(resume = true) {\n        if (!menuWin.isOpen) return\n        menuWin.isOpen = false\n        clearInterval(menuWin.timer)\n        menuWin.el.hidden = true\n        if (resume && engine && !crashed) {\n          if (menuWin.mode === 'engine') { try { engine._web_pause(0) } catch (e) {} }\n          else if (menuWin.mode === 'loop') { try { engine.resumeMainLoop(); engineOut(true) } catch (e) {} }\n        }\n        menuWin.mode = null\n      },\n      toggle() { menuWin.isOpen ? menuWin.close() : menuWin.open() },\n      tick() {\n        if (!menuWin.isOpen) return\n        // The game unpaused itself (START): the window goes with it.\n        if (menuWin.mode === 'engine') { try { if (!engine._web_paused()) menuWin.close(false) } catch (e) {} }\n        menuWin.ping()\n      },\n    }\n    d2.on('[data-pw]', 'click', (e, el) => {\n      const act = el.dataset.pw\n      if (act === 'resume') menuWin.close()\n      else if (act === 'retry') {\n        let ok = 0\n        try { ok = engine._web_pause_retry() } catch (err) {}\n        if (ok) menuWin.close(false)\n        else menuWin.close()\n      } else if (act === 'fs') {\n        toggleFullscreen()\n      } else if (act === 'controls') {\n        const i = SCHEMES.findIndex((c) => c.id === SCHEME)\n        setScheme(SCHEMES[(i + 1) % SCHEMES.length].id)\n        menuWin.paint()\n      } else if (act === 'sound') {\n        const v = readVolume()\n        v.muted = !v.muted\n        try { localStorage.setItem(`${STORE}.volume`, JSON.stringify(v)) } catch (err) {}\n        try { applyVolume() } catch (err) {}\n        menuWin.paint()\n      } else if (act === 'quit') {\n        menuWin.close()\n        quitLevel()\n      }\n    })\n    d2.on('[data-pvol]', 'input', (e, el) => {\n      const v = readVolume()\n      v[el.dataset.pvol] = Math.max(0, Math.min(200, Number(el.value) || 0))\n      el.style.setProperty('--fill', `${v[el.dataset.pvol] / 2}%`)\n      el.parentElement.querySelector('b').textContent = `${v[el.dataset.pvol]}%`\n      try { localStorage.setItem(`${STORE}.volume`, JSON.stringify(v)) } catch (err) {}\n      try { applyVolume() } catch (err) {}\n    })\n    d2.on(menuWin.el, 'click', (e) => { if (e.target === menuWin.el) menuWin.close() }) // a tap beside the panel resumes\n    const onFsChange = () => { if (menuWin.isOpen) menuWin.paint() }\n    document.addEventListener('fullscreenchange', onFsChange)\n    document.addEventListener('webkitfullscreenchange', onFsChange)\n    d2.onUnmount(() => {\n      document.removeEventListener('fullscreenchange', onFsChange)\n      document.removeEventListener('webkitfullscreenchange', onFsChange)\n      // Leaving /play stops the main loop itself: only the game's own pause is undone here.\n      if (menuWin.mode !== 'engine') menuWin.mode = null\n      menuWin.close()\n    })\n\n    // ---- engine --------------------------------------------------------------------------\n    let engine = rt.engine || null\n    let crashed = !!rt.crashed\n\n    // Last engine log lines, shown on the crash screen so a failure can be reported.\n    const logTail = rt.logTail || (rt.logTail = [])\n    const logLine = (line) => { logTail.push(String(line).slice(0, 300)); if (logTail.length > 40) logTail.shift() }\n\n    // SDL's web audio lives on Module.SDL2.audioContext (the context unlocked by a tap, rt.audio).\n    // When the engine dies, its audio callback keeps replaying the last buffer (the stuck shooting\n    // sound), so close the context outright then.\n    const audioContext = () => (engine && engine.SDL2 && engine.SDL2.audioContext) || rt.audio || null\n    const stopAudio = () => { try { const ac = audioContext(); if (ac && ac.state !== 'closed') ac.close() } catch (e) {} }\n    const suspendAudio = () => { try { const ac = audioContext(); if (ac && ac.state === 'running') ac.suspend() } catch (e) {} }\n    // The engine's output node (SDL's script processor). Leaving /play unplugs it rather than suspending\n    // the context: the context also carries the radio's microphone, and iOS will not resume a suspended\n    // one outside a tap, so the next session (started by the network, not a tap) would play mute.\n    const engineOut = (on) => {\n      try {\n        const node = engine && engine.SDL2 && engine.SDL2.audio && engine.SDL2.audio.scriptProcessorNode\n        const ac = audioContext()\n        if (!node || !ac || ac.state === 'closed') return\n        try { node.disconnect() } catch (e) {}\n        if (on) node.connect(ac.destination)\n      } catch (e) {}\n    }\n    const resumeAudio = () => {\n      if (crashed || !mountedHere) return\n      try { const ac = audioContext(); if (ac && ac.state !== 'running' && ac.state !== 'closed') ac.resume() } catch (e) {}\n    }\n    // Normally the menu unlocks audio on the tap that leads here (TFSF_Menu_DEV, rt.audio). When\n    // /play is opened directly, the Start button does it. Must run synchronously inside the tap:\n    // iOS only lets audio start from a user gesture.\n    const micLive = () => !!(rt.micPipe && rt.micPipe.stream.getAudioTracks().some((t) => t.readyState === 'live'))\n    const unlockAudio = () => {\n      // Play through the iPhone silent switch, like a game or video app (Safari 16.4+). With the\n      // radio's microphone open (the title's radio check) it stays in play-and-record: switching\n      // back and forth makes iOS rebuild its audio and the game stutter.\n      try { if (navigator.audioSession) navigator.audioSession.type = micLive() ? 'play-and-record' : 'playback' } catch (e) {}\n      try {\n        if (rt.audio && rt.audio.state !== 'closed') { rt.audio.resume(); return }\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx) return\n        // The game synthesises at 32 kHz: running the context at that rate lets the browser resample\n        // natively instead of SDL doing it in WebAssembly every audio callback.\n        let ac\n        try { ac = new Ctx({ sampleRate: 32000 }) } catch (e) { ac = new Ctx() }\n        ac.resume()\n        // A one-sample silent buffer started within the gesture fully unlocks older iOS.\n        const src = ac.createBufferSource()\n        src.buffer = ac.createBuffer(1, 1, 22050)\n        src.connect(ac.destination)\n        src.start(0)\n        rt.audio = ac\n      } catch (e) {}\n    }\n\n    // ---- volume mixer (set on /settings: `${STORE}.volume`, 0-200 % each, 100 = as the game mixes it) ----\n    // Master is a gain node every sound goes through: the context's `destination` is pointed at it, so\n    // the engine's output (SDL connects to audioContext.destination), the voice chat and the radio\n    // static all pass it. Music, sound effects and the radio voices are scaled inside the engine\n    // (web_set_volume); voice chat scales the other pilots' voices.\n    const readVolume = () => {\n      let v = {}\n      try { v = JSON.parse(readKey(`${STORE}.volume`) || '{}') || {} } catch (e) {}\n      const pick = (k) => (Number.isFinite(Number(v[k])) ? Math.max(0, Math.min(200, Number(v[k]))) : 100)\n      return { master: pick('master'), sfx: pick('sfx'), bgm: pick('bgm'), radio: pick('radio'), voice: pick('voice'), muted: v.muted === true }\n    }\n    const masterBus = (ac) => {\n      if (!ac || ac.state === 'closed') return null\n      if (ac.__tfsfMaster) return ac.__tfsfMaster\n      try {\n        const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ac), 'destination') ||\n          Object.getOwnPropertyDescriptor(BaseAudioContext.prototype, 'destination')\n        const real = desc && desc.get ? desc.get.call(ac) : ac.destination\n        const gain = ac.createGain()\n        gain.connect(real)\n        Object.defineProperty(ac, 'destination', { configurable: true, get: () => gain })\n        ac.__tfsfMaster = gain\n        return gain\n      } catch (e) { return null }\n    }\n    const applyVolume = () => {\n      const v = readVolume()\n      const bus = masterBus(audioContext())\n      if (bus) bus.gain.value = v.muted ? 0 : v.master / 100 // Sound off (/settings): everything through the bus\n      comms.voiceGain = v.muted ? 0 : 1.4 * (v.voice / 100)\n      for (const ch of comms.chans.values()) { try { ch.gainNode.gain.value = comms.voiceGain } catch (e) {} }\n      if (engine && !crashed && typeof engine._web_set_volume === 'function') {\n        try { engine._web_set_volume(0, v.bgm); engine._web_set_volume(1, v.sfx); engine._web_set_volume(2, v.radio) } catch (e) {}\n      }\n    }\n    rt.applyVolume = applyVolume\n    d2.onUnmount(() => { if (rt.applyVolume === applyVolume) rt.applyVolume = null })\n\n    const crash = (why, stack) => {\n      if (crashed) return\n      crashed = rt.crashed = true\n      stopAudio()\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      const box = $('.crash')\n      box.querySelector('.crash-why').textContent = String(why || 'Unknown error').slice(0, 300)\n      // Stack first (the engine is built with function names), then the engine's last log lines.\n      const frames = String(stack || '').split('\\n').map((l) => l.trim()).filter(Boolean).slice(0, 14)\n      box.querySelector('.crash-log').textContent = [...frames, frames.length ? '----' : '', ...logTail.slice(-15)].filter(Boolean).join('\\n')\n      box.hidden = false\n      console.error(`${TAG} crashed:`, why)\n    }\n    // A crashed engine cannot be reused: reload the page at the menu.\n    d2.on('.restart', 'click', () => { location.href = '/' })\n    d2.on('.menu-btn', 'click', () => nav('/'))\n\n    // Only engine failures count: the host page can raise its own unrelated errors.\n    const isEngineError = (err, filename) =>\n      String(filename || '').startsWith('blob:') ||\n      (err && (err instanceof WebAssembly.RuntimeError || /Aborted|RuntimeError|wasm|unreachable|memory access out of bounds/i.test(String(err.message || err))))\n    d2.on(window, 'error', (e) => {\n      if (!engine || !isEngineError(e.error || e.message, e.filename)) return\n      // Name the source too: the engine and this component can both be blob: scripts.\n      const where = `at ${String(e.filename || '?').slice(-60)}:${e.lineno || 0}:${e.colno || 0}`\n      crash(e.message || e.error, [where, (e.error && e.error.stack) || ''].join('\\n'))\n    })\n    d2.on(window, 'unhandledrejection', (e) => { if (engine && isEngineError(e.reason)) crash(e.reason && (e.reason.message || e.reason), e.reason && e.reason.stack) })\n    d2.on(canvas, 'webglcontextlost', (e) => {\n      e.preventDefault()\n      crash('The browser dropped the graphics context (usually low memory on the phone).')\n    })\n    // Hidden tab: silence it; closing/leaving: shut audio down so nothing outlives the page.\n    d2.on(document, 'visibilitychange', () => {\n      if (!audioContext() || crashed) return\n      if (document.hidden) suspendAudio()\n      else resumeAudio()\n    })\n    // Leaving for good closes the audio; a page kept in memory (home-screen app switched away, back/\n    // forward cache) only suspends it, so it can play again when the player returns.\n    d2.on(window, 'pagehide', (e) => { if (e && e.persisted) suspendAudio(); else stopAudio() })\n    d2.on(window, 'pageshow', () => resumeAudio())\n    // Any tap is a chance to (re)start audio iOS suspended or interrupted (calls, Siri, other apps).\n    // Any touch on the game asks for motion again while it is not on (it must come from a tap).\n    d2.on(root, 'pointerup', () => { resumeAudio(); if (!input.hasMotion) askMotionPermission() })\n    d2.on(root, 'pointerdown', () => resumeAudio())\n\n    const askMotionPermission = async () => {\n      // iOS gates motion behind a prompt that must come from a tap; ask for both event types.\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') {\n          try { await Ev.requestPermission() } catch (e) { /* declined; the touch stick still works */ }\n        }\n      }\n    }\n\n    // The whole document goes fullscreen (not this component), so it stays fullscreen across routes.\n    const enterFullscreen = async () => {\n      const el = document.documentElement\n      try { if (el.requestFullscreen && !document.fullscreenElement) await el.requestFullscreen({ navigationUI: 'hide' }) } catch (e) {}\n      try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n      try { await navigator.wakeLock?.request?.('screen') } catch (e) {}\n    }\n\n    // ---- warm-up list learned on this device --------------------------------------------\n    const WARM_KEY = `${STORE}.warm.v1`\n    if (!rt.learned) {\n      rt.learned = new Set()\n      try { (localStorage.getItem(WARM_KEY) || '').split('\\n').filter(Boolean).forEach((l) => rt.learned.add(l)) } catch (e) {}\n    }\n    const learned = rt.learned\n    const learnWarm = (line) => {\n      if (rt.warming || learned.has(line) || learned.size > 30000) return // warm-up items are already listed\n      learned.add(line)\n      clearTimeout(rt.warmSaveTimer)\n      rt.warmSaveTimer = setTimeout(() => { try { localStorage.setItem(WARM_KEY, [...learned].join('\\n')) } catch (e) {} }, 3000)\n    }\n    const warmUp = (M) => new Promise((resolve) => {\n      if (typeof M._web_warm_step !== 'function') return resolve()\n      const t0 = performance.now()\n      const step = () => {\n        let left = 0\n        try { left = M._web_warm_step(14) } catch (e) { return resolve() }\n        if (left > 0 && performance.now() - t0 < 8000) requestAnimationFrame(step)\n        else resolve()\n      }\n      requestAnimationFrame(step)\n    })\n\n    // ---- director: what to play, and where to go when the engine stops -----------------------\n    const showPlaying = () => {\n      overlay.hidden = true\n      hud.hidden = false\n      $('.rotate').classList.add('armed')\n      canvas.focus()\n    }\n    const leave = (to) => {\n      hud.hidden = true\n      nav(to)\n    }\n    const runCommand = () => {\n      if (!engine || crashed || !mountedHere) return\n      if (typeof engine._web_holding !== 'function') return fail('This engine build has no director mode (needs engine v18+).')\n      // Coming back to a level that is still running (browser back/forward): just play on.\n      if (!engine._web_holding()) return showPlaying()\n      // Netplay: every phone starts the level together, when the host says go.\n      if (netplay.on && !netplay.went) return netplay.wait()\n      const last = rt.lastEvent && rt.lastEvent.event\n      try { netplay.setup() } catch (e) {}\n      try {\n        switch (cmd.name) {\n          case 'continue':\n          case 'retry':\n          case 'alt':\n            // Only right after a level (the engine holds the route state for it).\n            if (last !== 'level-end') return nav('/')\n            if (cmd.name === 'alt') { rt.pendingContinue = true; engine._web_route_choice(2) }\n            else engine._web_route_choice(cmd.name === 'retry' ? 1 : 0)\n            break\n          case 'level':\n            engine._web_play_level(Number.isFinite(cmd.level) ? cmd.level : 0)\n            break\n          case 'training':\n            engine._web_training()\n            break\n          case 'arena':\n            // A versus match (engine v40+): the lobby's teams, one full screen per phone.\n            if (!netplay.on || typeof engine._web_play_arena !== 'function') return nav('/')\n            if (typeof engine._web_arena_rounds === 'function') engine._web_arena_rounds(cmd.rounds) // first to N rounds\n            engine._web_play_arena(cmd.stage, cmd.match, netplay.slots.length, cmd.minutes)\n            break\n          default:\n            engine._web_new_game(cmd.expert ? 1 : 0)\n        }\n      } catch (e) {\n        return crash(`Engine command failed: ${e && e.message}`, e && e.stack)\n      }\n      // a fresh run (campaign, single level, training, arena match) gets a new id; continue/retry/alt\n      // carry on the current one\n      if (['new', 'level', 'training', 'arena'].includes(cmd.name) || !rt.runId) rt.runId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`\n      remember({ event: 'playing', cmd: cmd.name })\n      showPlaying()\n    }\n    const onDirector = (ev) => {\n      if (ev.event === 'ready') {\n        // Boot finished; the command runs once the warm-up is done too.\n        rt.ready = true\n        if (!rt.warming) runCommand()\n        return\n      }\n      if (ev.event === 'level-end' && rt.pendingContinue) {\n        // 'alt': the route switched branch; now fly it.\n        rt.pendingContinue = false\n        try { engine._web_route_choice(0) } catch (e) {}\n        remember({ event: 'playing', cmd: 'alt' })\n        return\n      }\n      if (ev.event === 'arena-end') { rt.arenaResult = ev; return remember(ev) } // the engine shows it, then leaves\n      remember(ev)\n      if (cmd.name === 'arena' && rt.mp && rt.mp.lobby) {\n        // Back to the lobby the battle came from, for the next one.\n        rt.arenaBack = { lobby: rt.mp.lobby, at: Number(rt.mp.start && rt.mp.start.at) || 0 }\n        return leave(`/multiplayer/lobbies/${rt.mp.lobby}`)\n      }\n      if (ev.event === 'level-end') leave('/map')\n      else if (ev.event === 'game-over' || ev.event === 'ending') leave('/results')\n      else leave('/')\n    }\n    rt.onDirector = onDirector\n\n    // ---- co-op netplay: lockstep (engine v27+) ------------------------------------------------------\n    // After a lobby departure (/play?…&net=1) every phone runs the whole level: the same seed, and\n    // every pilot's controller input applied on the same tick, so every phone computes the same game.\n    // The other pilots are real Players in the engine (gPlayer[1..3]), flown by their owners' input.\n    // Only inputs travel, as binary packets over the lobby's WebRTC links (rt.net, a star around the\n    // host, which relays):\n    //   input: u8 1, u8 slot, u8 session, u8 n, u32 first tick, then n x (u16 buttons, i8 x, i8 y)\n    //   hash:  u8 2, u8 slot, u8 session, u8 0, u32 tick, u32 state hash (desync check)\n    // Each tick the engine hands over this phone's pad for tick + COOP_DELAY (Module.coopSend); the\n    // packet repeats the last COOP_REDUNDANCY ticks, so a lost packet costs nothing. A tick runs only\n    // once every pilot's input for it is here (the engine waits in its frame loop).\n    const FACES = { fox: 1, falco: 2, slippy: 3, peppy: 4, wolf: 5, leon: 6, pigma: 7, andrew: 8 } // 5-8: Star Wolf (Wolfens)\n    const PILOT_NAMES = { fox: 'Fox', falco: 'Falco', slippy: 'Slippy', peppy: 'Peppy', wolf: 'Wolf', leon: 'Leon', pigma: 'Pigma', andrew: 'Andrew' }\n    const COOP_DELAY = 3        // ticks (at 30 Hz: 100 ms) between a press and the tick it lands on\n    const COOP_REDUNDANCY = 8   // each packet also repeats this many earlier ticks\n    const COOP_DROP_MS = 6000   // host (engines without host authority): a pilot silent this long is dropped\n    // Host authority (engine v36+): the host never waits for anyone. It commits every tick for every\n    // pilot (type 4 packets) and fills in for a late one: its last input for a short gap, autopilot\n    // once it is away, \"gone\" once kicked. A guest only waits for the host.\n    const AWAY_MS = 700         // host: no input from a pilot for this long = away (autopilot)\n    const KICK_MS = 20000       // host: away this long = kicked from the session\n    const LS_GONE = 0x0040      // the engine's \"pilot left\" button bit\n    const netplay = { on: query.get('net') === '1' && !!(rt.net && rt.mp), slots: [], seed: 0, session: 0, history: [], hashes: new Map(), lastRx: new Map(), maxTick: new Map(), dropped: new Map(), went: false }\n    const coopEngine = () => !!(engine && !crashed && typeof engine._web_coop_setup === 'function')\n    // Slot order: the host first (slot 0 anchors the world, as Fox does), then the others by email.\n    netplay.order = () => {\n      const pilots = rt.mp.pilots || {}\n      const host = rt.mp.host || rt.net.host\n      const rest = Object.keys(pilots).filter((e) => e !== host).sort()\n      return (host && pilots[host] !== undefined ? [host] : []).concat(rest).slice(0, cmd.name === 'arena' ? 8 : 4) // an Arena match takes 8 (4v4)\n    }\n    netplay.local = () => netplay.slots.indexOf(rt.mp.me)\n    netplay.setup = () => {\n      if (!coopEngine()) return\n      if (!netplay.on) return engine._web_coop_setup(0, 0, 0, 0, 0)\n      let faces = 0\n      netplay.slots.forEach((email, i) => { faces |= (FACES[(rt.mp.pilots || {})[email]] || i + 1) << (4 * i) })\n      // With host authority the host's own input needs no network head start: one tick, like solo.\n      netplay.auth = typeof engine._web_coop_authority === 'function'\n      engine._web_coop_setup(netplay.slots.length, Math.max(0, netplay.local()), netplay.seed | 0, netplay.auth ? 1 : COOP_DELAY, faces)\n      if (netplay.auth) engine._web_coop_authority(1)\n      if (cmd.name === 'arena' && typeof engine._web_coop_arena === 'function') {\n        let red = 0\n        netplay.slots.forEach((email, i) => { if (((rt.mp.teams || {})[email]) === 'red') red |= 1 << i })\n        engine._web_coop_arena(red)\n      }\n      netplay.session = netplay.seed & 255\n      netplay.history = []\n      netplay.commits = []\n      netplay.newest = -1\n      netplay.lastTarget = -1\n      netplay.hashes.clear()\n      engine.coopSend = coopSend\n      engine.coopHash = coopHash\n      engine.coopHashParts = coopHashParts\n      netplay.parts.clear(); netplay.peerParts.clear()\n      engine.coopCommit = coopCommit\n      comms.names()\n    }\n    // ---- packets ----\n    const packInputs = (slot, first, list) => {\n      const buf = new ArrayBuffer(8 + list.length * 4)\n      const v = new DataView(buf)\n      v.setUint8(0, 1); v.setUint8(1, slot); v.setUint8(2, netplay.session); v.setUint8(3, list.length); v.setUint32(4, first, true)\n      list.forEach((p, i) => { v.setUint16(8 + i * 4, p.b, true); v.setInt8(10 + i * 4, p.x); v.setInt8(11 + i * 4, p.y) })\n      return buf\n    }\n    // Engine -> page, once per tick: this phone's pad for tick t.\n    const coopSend = (t, b, x, y) => {\n      const h = netplay.history\n      if (netplay.auth) {\n        if (rt.net.isHost()) return // the host's own input goes out in its commits\n        // A guest aims its input at a tick the host has not reached yet when it arrives: the newest\n        // committed tick + the round trip + a margin. Consecutive ticks while that holds; a jump\n        // ahead when it would arrive late; a skipped sample when it runs too far ahead (latency).\n        const H = netplay.newest\n        if (H < 0) return\n        const peer = rt.net.peers.get(rt.net.host)\n        const lead = Math.ceil(((peer && peer.rtt) || 100) / 33.3) + 1\n        let target = netplay.lastTarget + 1\n        if (netplay.lastTarget < 0 || target < H + lead - 1) { target = H + lead; h.length = 0 }\n        else if (target > H + lead + 4) return\n        netplay.lastTarget = target\n        t = target\n      }\n      h.push({ t, b, x, y })\n      if (h.length > COOP_REDUNDANCY) h.splice(0, h.length - COOP_REDUNDANCY)\n      try { rt.net.sendBin(packInputs(netplay.local(), h[0].t, h)) } catch (e) {}\n    }\n    // (the packet also carries the hash in parts, 10 x u32, so a desync can say what drifted: the engine\n    // hands the parts over right after the hash, in the same call, so the packet goes out a moment later)\n    const coopHash = (t, hash) => {\n      netplay.hashes.set(t, hash)\n      if (netplay.hashes.size > 64) netplay.hashes.delete(netplay.hashes.keys().next().value)\n      const peer = netplay.peerHashes && netplay.peerHashes.get(t)\n      if (peer !== undefined && peer.hash !== hash) desync(t, peer.slot)\n      queueMicrotask(() => {\n        const parts = netplay.parts.get(t)\n        const buf = new ArrayBuffer(parts ? 52 : 12)\n        const v = new DataView(buf)\n        v.setUint8(0, 2); v.setUint8(1, netplay.local()); v.setUint8(2, netplay.session); v.setUint8(3, 0); v.setUint32(4, t, true); v.setUint32(8, hash, true)\n        if (parts) parts.forEach((x, i) => v.setUint32(12 + i * 4, x >>> 0, true))\n        try { rt.net.sendBin(buf) } catch (e) {}\n        desyncWhat()\n      })\n    }\n    const coopHashParts = (t, parts) => {\n      netplay.parts.set(t, parts)\n      if (netplay.parts.size > 64) netplay.parts.delete(netplay.parts.keys().next().value)\n    }\n    // What drifted, once both sides' parts for the first bad tick are here (shown on the banner).\n    const HASH_PARTS = ['rng', 'host ship', 'ship 2', 'enemies', 'boss', 'effects', 'shots', 'items', 'scenery', 'counters']\n    const desyncWhat = () => {\n      const d = rt.coopDesync\n      if (!d || d.what) return\n      const a = netplay.parts.get(d.tick), b = netplay.peerParts.get(d.tick)\n      if (!a || !b) return\n      d.what = HASH_PARTS.filter((n, i) => a[i] !== b[i]).join(', ') || 'shields or path' // (in the hash, not in its parts)\n      let level = -1\n      try { level = typeof engine._web_level === 'function' ? engine._web_level() : -1 } catch (e) {}\n      d.level = level\n      console.warn('[coop] desync parts', d.tick, d.what, JSON.stringify({ mine: a, peer: b, slot: d.slot, level }))\n    }\n    // Host, once per tick: every pilot's input for tick t as decided, to every guest (with the last\n    // COOP_REDUNDANCY ticks again, so a lost packet costs nothing).\n    //   commit: u8 4, u8 S, u8 session, u8 n, u32 first tick, then n x S slots x (u16 buttons, i8 x, i8 y)\n    // S is 4, or 8 for an Arena match of 5-8 pilots (0 from an older host means 4).\n    const coopCommit = (t, ...pks) => {\n      const c = netplay.commits\n      const S = netplay.slots.length > 4 ? 8 : 4\n      c.push(pks.slice(0, S))\n      c.first = t - c.length + 1\n      if (c.length > COOP_REDUNDANCY) { c.splice(0, c.length - COOP_REDUNDANCY); c.first = t - c.length + 1 }\n      const buf = new ArrayBuffer(8 + c.length * S * 4)\n      const v = new DataView(buf)\n      v.setUint8(0, 4); v.setUint8(1, S); v.setUint8(2, netplay.session); v.setUint8(3, c.length); v.setUint32(4, c.first, true)\n      c.forEach((row, i) => row.forEach((pk, s) => v.setUint32(8 + i * S * 4 + s * 4, (pk || 0) >>> 0, true)))\n      try { rt.net.sendBin(buf) } catch (e) {}\n    }\n    netplay.peerHashes = new Map()\n    netplay.parts = new Map()\n    netplay.peerParts = new Map()\n    const desync = (t, slot) => {\n      if (netplay.desyncAt !== undefined) return\n      netplay.desyncAt = t\n      rt.coopDesync = { tick: t, slot }\n      console.warn('[coop] desync at tick', t, 'vs slot', slot)\n    }\n    // Page <- network. Returns false for a packet to drop (stale session, dropped pilot).\n    const receive = (buf, from) => {\n      if (!(buf instanceof ArrayBuffer) || buf.byteLength < 8 || !coopEngine()) return false\n      const v = new DataView(buf)\n      const type = v.getUint8(0)\n      const slot = v.getUint8(1)\n      if (v.getUint8(2) !== netplay.session) return false\n      if (type === 4) {\n        // The host's commits: every pilot's input, this phone's own included.\n        if (rt.net.isHost()) return false\n        const n = v.getUint8(3)\n        const first = v.getUint32(4, true)\n        const S = slot || 4 // slots per tick (byte 1)\n        if (buf.byteLength < 8 + n * S * 4) return false\n        const count = Math.min(netplay.slots.length, S)\n        for (let i = 0; i < n; i++) {\n          for (let s = 0; s < count; s++) {\n            const o = 8 + i * S * 4 + s * 4\n            engine._web_coop_input(s, first + i, v.getUint16(o, true), v.getInt8(o + 2), v.getInt8(o + 3))\n          }\n        }\n        netplay.newest = Math.max(netplay.newest, first + n - 1)\n        netplay.hostRx = performance.now()\n        return false // the host sends these to everyone itself\n      }\n      if (slot >= netplay.slots.length || slot === netplay.local()) return false\n      if (rt.net.isHost() && netplay.dropped.has(slot)) return false // the host speaks for it now\n      if (type === 1) {\n        const n = v.getUint8(3)\n        const first = v.getUint32(4, true)\n        if (buf.byteLength < 8 + n * 4) return false\n        for (let i = 0; i < n; i++) engine._web_coop_input(slot, first + i, v.getUint16(8 + i * 4, true), v.getInt8(10 + i * 4), v.getInt8(11 + i * 4))\n        netplay.lastRx.set(slot, performance.now())\n        netplay.maxTick.set(slot, Math.max(netplay.maxTick.get(slot) || 0, first + n - 1))\n        return !netplay.auth // with host authority the guests get it in the host's commits\n      }\n      if (type === 3) { comms.receive(slot, v, buf); return true }\n      if (type === 2 && buf.byteLength >= 12) {\n        const t = v.getUint32(4, true)\n        const hash = v.getUint32(8, true)\n        const mine = netplay.hashes.get(t)\n        if (mine !== undefined && mine !== hash) desync(t, slot)\n        netplay.peerHashes.set(t, { hash, slot })\n        if (netplay.peerHashes.size > 64) netplay.peerHashes.delete(netplay.peerHashes.keys().next().value)\n        if (buf.byteLength >= 52) {\n          netplay.peerParts.set(t, Array.from({ length: 10 }, (x, i) => v.getUint32(12 + i * 4, true)))\n          if (netplay.peerParts.size > 64) netplay.peerParts.delete(netplay.peerParts.keys().next().value)\n          desyncWhat()\n        }\n        return true\n      }\n      return false\n    }\n    // Host: a pilot gone silent (tab closed, phone locked) would stall everyone. After COOP_DROP_MS the\n    // host flies on without it: from the tick after its last input, the host sends \"gone\" inputs in\n    // its name, and every phone (the same ticks, the same inputs) takes that ship out of the game.\n    const dropCheck = () => {\n      if (!rt.net.isHost() || !coopEngine() || !netplay.went) return\n      const tick = engine._web_coop_tick()\n      if (tick < 0) return\n      const now = performance.now()\n      if (netplay.auth) {\n        // Away: the engine flies that ship (autopilot) until its input is back. Kicked after KICK_MS.\n        netplay.slots.forEach((email, slot) => {\n          if (slot === netplay.local() || netplay.dropped.has(slot)) return\n          const silent = now - (netplay.lastRx.get(slot) || netplay.startedAt || now)\n          if (silent > KICK_MS) {\n            netplay.dropped.set(slot, { next: 0 })\n            engine._web_coop_away(slot, 2)\n            try { rt.net.send({ t: 'coop-dropped', email }, { reliable: true }) } catch (e) {}\n          } else {\n            engine._web_coop_away(slot, silent > AWAY_MS ? 1 : 0)\n          }\n        })\n        return\n      }\n      netplay.slots.forEach((email, slot) => {\n        if (slot === netplay.local()) return\n        let d = netplay.dropped.get(slot)\n        if (!d) {\n          const last = netplay.lastRx.get(slot) || netplay.startedAt || now\n          if (now - last < COOP_DROP_MS) return\n          d = { next: (netplay.maxTick.get(slot) ?? (COOP_DELAY - 1)) + 1 }\n          netplay.dropped.set(slot, d)\n          try { rt.net.send({ t: 'coop-dropped', email }, { reliable: true }) } catch (e) {}\n        }\n        // Keep its \"gone\" inputs ahead of the game, like a live pilot's.\n        const upTo = tick + COOP_DELAY + COOP_REDUNDANCY\n        const list = []\n        const first = d.next\n        for (let t = first; t <= upTo && list.length < 32; t++) list.push({ b: LS_GONE, x: 0, y: 0 })\n        if (!list.length) return\n        for (let i = 0; i < list.length; i++) engine._web_coop_input(slot, first + i, LS_GONE, 0, 0)\n        d.next = first + list.length\n        try { rt.net.sendBin(packInputs(slot, first, list)) } catch (e) {}\n      })\n    }\n    // ---- radio comms: push to talk ----------------------------------------------------------------\n    // Holding TALK (V on Mouse & keys) opens this pilot's radio for everyone: the engine draws the\n    // portrait with the radio's own window and yellow label (web_coop_comms / web_coop_name, draw-only,\n    // never part of the lockstep game), and the microphone goes out as 8 kHz mu-law, 20 ms a packet,\n    // on the same links as the inputs (the host relays, like inputs):\n    //   voice: u8 3, u8 slot, u8 session, u8 seq, u8 level, u8 end, u16 0, then 160 mu-law samples\n    // `end` = 1 (no samples) when the button is let go. Each phone plays every other pilot through a\n    // small jitter buffer, and feeds the engine each pilot's loudness, so the mouths move with voices.\n    const VOICE_RATE = 8000\n    const MIC_GAIN = 2.5        // the raw microphone (no auto gain) is quiet: a fixed boost, clipped\n    const VOICE_FRAME = 160            // 20 ms\n    const VOICE_JITTER = 0.08          // s of buffer ahead of the first frame after a gap\n    const VOICE_MAX_AHEAD = 0.4        // s: further behind than this, start over\n    const VOICE_SILENT_MS = 400        // no frame for this long: that radio closes\n    const NAME_CHARS = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ!:-.0123456789'\n    const comms = { talking: false, level: 0, seq: 0, chans: new Map(), frame: new Float32Array(VOICE_FRAME), fill: 0, phase: 0, acc: 0, accN: 0 }\n    // mu-law (G.711)\n    const muEncode = (x) => {\n      let s = Math.max(-1, Math.min(1, x)) * 32635\n      const sign = s < 0 ? 0x80 : 0\n      if (sign) s = -s\n      s += 132\n      let exp = 7\n      for (let m = 0x4000; (s & m) === 0 && exp > 0; m >>= 1) exp--\n      return ~(sign | (exp << 4) | ((s >> (exp + 3)) & 0x0f)) & 0xff\n    }\n    const MU_TABLE = new Float32Array(256)\n    for (let i = 0; i < 256; i++) {\n      const u = ~i & 0xff\n      const t = (((u & 0x0f) << 3) + 132) << ((u & 0x70) >> 4)\n      MU_TABLE[i] = ((u & 0x80) ? 132 - t : t - 132) / 32768\n    }\n    // The player's name for the radio label: the small font's letters only, first word if it is long.\n    const radioName = (email) => {\n      const raw = String(((rt.mp && rt.mp.names) || {})[email] || String(email || '').split('@')[0] || '')\n      let s = raw.normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase().replace(/[_]/g, ' ')\n      s = [...s].filter((c) => NAME_CHARS.includes(c)).join('').replace(/\\s+/g, ' ').trim()\n      if (s.length > 9) s = s.split(' ')[0]\n      return s.slice(0, 9)\n    }\n    comms.names = () => {\n      if (!coopEngine() || typeof engine._web_coop_name !== 'function') return\n      netplay.slots.forEach((email, slot) => {\n        const s = radioName(email)\n        engine._web_coop_name(slot, 0, 0)\n        for (let i = 0; i < s.length; i++) engine._web_coop_name(slot, i, s.charCodeAt(i))\n      })\n    }\n    const packVoice = (level, end, data) => {\n      const buf = new ArrayBuffer(8 + (data ? data.length : 0))\n      const v = new DataView(buf)\n      v.setUint8(0, 3); v.setUint8(1, netplay.local()); v.setUint8(2, netplay.session); v.setUint8(3, comms.seq++ & 255)\n      v.setUint8(4, level); v.setUint8(5, end ? 1 : 0)\n      if (data) new Uint8Array(buf, 8).set(data)\n      return buf\n    }\n    const levelOf = (rms) => Math.min(255, Math.round(rms * 1500))\n    // A short burst of radio static (key up / key down), on the game's audio context.\n    const squelch = (gain = 0.12) => {\n      const ac = audioContext()\n      if (!ac || ac.state !== 'running') return\n      try {\n        const n = Math.round(ac.sampleRate * 0.07)\n        const b = ac.createBuffer(1, n, ac.sampleRate)\n        const d = b.getChannelData(0)\n        for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n)\n        const src = ac.createBufferSource()\n        const g = ac.createGain()\n        const f = ac.createBiquadFilter()\n        f.type = 'bandpass'; f.frequency.value = 2200; f.Q.value = 0.8\n        g.gain.value = gain\n        src.buffer = b\n        src.connect(f); f.connect(g); g.connect(ac.destination)\n        src.start()\n      } catch (e) {}\n    }\n    // Microphone: its own context at the device's rate (Firefox will not mix rates in one graph).\n    // The microphone's samples, while talking, become 8 kHz voice frames.\n    comms.attach = (m) => {\n      const step = m.ac.sampleRate / VOICE_RATE\n      m.proc.onaudioprocess = (e) => {\n        if (!comms.talking) return\n        const x = e.inputBuffer.getChannelData(0)\n        // Decimate to 8 kHz: each output sample is the mean of the input samples it covers.\n        for (let i = 0; i < x.length; i++) {\n          comms.acc += Math.max(-1, Math.min(1, x[i] * MIC_GAIN)); comms.accN++\n          comms.phase += 1\n          if (comms.phase >= step) {\n            comms.phase -= step\n            comms.frame[comms.fill++] = comms.acc / comms.accN\n            comms.acc = 0; comms.accN = 0\n            if (comms.fill === VOICE_FRAME) { comms.fill = 0; comms.sendFrame() }\n          }\n        }\n      }\n    }\n    comms.openMic = async () => {\n      if (comms.mic) return comms.mic\n      if (comms.opening) return comms.opening\n      comms.opening = (async () => {\n        // The radio the title set up (TFSF_Menu_DEV): on the game's own context, already open.\n        const pipe = rt.micPipe\n        if (pipe && pipe.ac && pipe.ac === audioContext() && pipe.ac.state !== 'closed' && micLive()) {\n          pipe.stream.getAudioTracks().forEach((t) => { t.enabled = true })\n          comms.attach(pipe)\n          comms.mic = pipe\n          return pipe\n        }\n        if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) throw new Error('no mic')\n        try { if (navigator.audioSession) navigator.audioSession.type = 'play-and-record' } catch (e) {}\n        // A plain microphone (no call-style processing): the phone stays in media mode (see the menu).\n        const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 } })\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        const ac = new Ctx()\n        await ac.resume().catch(() => {})\n        const src = ac.createMediaStreamSource(stream)\n        const proc = ac.createScriptProcessor(1024, 1, 1)\n        const mute = ac.createGain()\n        mute.gain.value = 0\n        src.connect(proc); proc.connect(mute); mute.connect(ac.destination)\n        comms.mic = { stream, ac, src, proc, mute }\n        comms.attach(comms.mic)\n        return comms.mic\n      })()\n      try { return await comms.opening } finally { comms.opening = null }\n    }\n    comms.sendFrame = () => {\n      let sum = 0\n      const out = new Uint8Array(VOICE_FRAME)\n      for (let i = 0; i < VOICE_FRAME; i++) { const s = comms.frame[i]; sum += s * s; out[i] = muEncode(s) }\n      comms.level = levelOf(Math.sqrt(sum / VOICE_FRAME))\n      try { rt.net.sendBin(packVoice(comms.level, false, out)) } catch (e) {}\n    }\n    comms.talk = (down) => {\n      if (!netplay.on || !netplay.went || netplay.local() < 0) return\n      if (down === comms.talking) return\n      comms.talking = down\n      const btn = $('.talk')\n      if (btn) btn.classList.toggle('on', down)\n      if (down) {\n        comms.fill = 0; comms.acc = 0; comms.accN = 0; comms.phase = 0\n        // The radio's opening sound right from the press, and the comm on in the engine at once.\n        try {\n          if (typeof engine._web_comms_open === 'function') engine._web_comms_open()\n          engine._web_coop_comms(netplay.local(), 1, 0)\n        } catch (e) {}\n        // (the engine plays the game's radio chirp as the comm opens)\n        comms.openMic().then((m) => { try { m.ac.resume() } catch (e) {} }).catch(() => {\n          comms.talking = false\n          if (btn) btn.classList.remove('on')\n          toast('Microphone not available')\n        })\n      } else {\n        comms.level = 0\n        squelch(0.08)\n        try { rt.net.sendBin(packVoice(0, true, null)) } catch (e) {}\n      }\n    }\n    // Another pilot's voice: decode, upsample to the context's rate, queue behind the last frame.\n    comms.chan = (slot) => {\n      let ch = comms.chans.get(slot)\n      if (ch) return ch\n      const ac = audioContext()\n      if (!ac) return null\n      const hp = ac.createBiquadFilter()\n      hp.type = 'highpass'; hp.frequency.value = 280\n      const gain = ac.createGain()\n      gain.gain.value = comms.voiceGain == null ? 1.4 : comms.voiceGain\n      const an = ac.createAnalyser()\n      an.fftSize = 512\n      hp.connect(gain); gain.connect(an); an.connect(ac.destination)\n      ch = { ac, input: hp, gainNode: gain, an, buf: new Uint8Array(an.fftSize), next: 0, lastRx: 0, on: false, lastSeq: -1 }\n      comms.chans.set(slot, ch)\n      return ch\n    }\n    comms.receive = (slot, v, buf) => {\n      const ch = comms.chan(slot)\n      if (!ch) return\n      const seq = v.getUint8(3)\n      if (v.getUint8(5)) { if (ch.on) squelch(0.06); ch.on = false; ch.lastSeq = -1; return }\n      // Late (out of order) frames are dropped.\n      if (ch.lastSeq >= 0 && ((seq - ch.lastSeq) & 255) > 128) return\n      ch.lastSeq = seq\n      ch.on = true\n      ch.lastRx = performance.now()\n      const n = buf.byteLength - 8\n      if (n <= 0 || ch.ac.state !== 'running') return\n      const mu = new Uint8Array(buf, 8, n)\n      const sr = ch.ac.sampleRate\n      const len = Math.round((n * sr) / VOICE_RATE)\n      const ab = ch.ac.createBuffer(1, len, sr)\n      const d = ab.getChannelData(0)\n      const k = VOICE_RATE / sr\n      for (let i = 0; i < len; i++) {\n        const p = i * k\n        const j = Math.floor(p)\n        const a = MU_TABLE[mu[j]]\n        const b = j + 1 < n ? MU_TABLE[mu[j + 1]] : a\n        d[i] = a + (b - a) * (p - j)\n      }\n      const now = ch.ac.currentTime\n      if (ch.next < now + 0.01 || ch.next > now + VOICE_MAX_AHEAD) ch.next = now + VOICE_JITTER\n      const src = ch.ac.createBufferSource()\n      src.buffer = ab\n      src.connect(ch.input)\n      src.start(ch.next)\n      ch.next += len / sr\n    }\n    // ~30 times a second: who is on the radio, and how loud, for the engine to draw.\n    comms.tick = () => {\n      if (!coopEngine() || typeof engine._web_coop_comms !== 'function' || !netplay.went) return\n      const me = netplay.local()\n      const now = performance.now()\n      for (let slot = 0; slot < netplay.slots.length; slot++) {\n        if (slot === me) { engine._web_coop_comms(slot, comms.talking ? 1 : 0, comms.talking ? comms.level : 0); continue }\n        const ch = comms.chans.get(slot)\n        let on = 0\n        let level = 0\n        if (ch && ch.on) {\n          if (now - ch.lastRx > VOICE_SILENT_MS) ch.on = false\n          else {\n            on = 1\n            ch.an.getByteTimeDomainData(ch.buf)\n            let sum = 0\n            for (let i = 0; i < ch.buf.length; i++) { const s = (ch.buf[i] - 128) / 128; sum += s * s }\n            level = levelOf(Math.sqrt(sum / ch.buf.length))\n          }\n        }\n        engine._web_coop_comms(slot, on, level)\n      }\n    }\n    comms.stop = () => {\n      clearInterval(comms.timer)\n      comms.talking = false\n      const m = comms.mic\n      comms.mic = null\n      if (m) {\n        if (m === rt.micPipe) {\n          // The title's radio: stays open (muted) for the next game.\n          try { m.proc.onaudioprocess = null } catch (e) {}\n          try { for (const t of m.stream.getAudioTracks()) t.enabled = false } catch (e) {}\n        } else {\n          try { m.proc.onaudioprocess = null; m.src.disconnect(); m.proc.disconnect() } catch (e) {}\n          try { for (const t of m.stream.getTracks()) t.stop() } catch (e) {}\n          try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n          try { m.ac.close() } catch (e) {}\n        }\n      }\n      for (const ch of comms.chans.values()) { try { ch.an.disconnect(); ch.input.disconnect() } catch (e) {} }\n      comms.chans.clear()\n    }\n    // ---- status: who the game waits on, and who this phone watches while down ----\n    const coopLabel = document.createElement('div')\n    coopLabel.style.cssText = 'position:absolute;left:50%;top:calc(10px + env(safe-area-inset-top));transform:translateX(-50%);z-index:6;padding:5px 14px;border-radius:999px;background:#000a;color:#fff;font:700 13px system-ui,sans-serif;letter-spacing:.04em;pointer-events:none;display:none'\n    wrap.appendChild(coopLabel)\n    const pilotName = (slot) => PILOT_NAMES[(rt.mp.pilots || {})[netplay.slots[slot]]] || 'a wingman'\n    netplay.stall = { tick: -1, since: 0 }\n    const status = () => {\n      if (!coopEngine() || !netplay.went) return\n      const tick = engine._web_coop_tick()\n      let text = ''\n      if (tick >= 0) {\n        if (tick !== netplay.stall.tick) { netplay.stall.tick = tick; netplay.stall.since = performance.now() }\n        const waiting = engine._web_coop_waiting()\n        const me = netplay.local()\n        const away = netplay.slots.map((e, i) => i).filter((i) => i !== me && (engine._web_coop_flags(i) & 12) === 8)\n        if (rt.coopDesync) {\n          const d = rt.coopDesync\n          text = `Out of sync with the other phones${d.what ? ` (${d.what} · tick ${d.tick})` : ''} · finish the match and rejoin`\n        } else if (waiting && performance.now() - netplay.stall.since > 700) {\n          const who = netplay.slots.map((e, i) => i).filter((i) => waiting & (1 << i)).map(pilotName)\n          text = netplay.auth && !rt.net.isHost() ? 'Waiting for the host…' : `Waiting for ${who.join(', ')}…`\n        } else if (netplay.auth && me >= 0 && (engine._web_coop_flags(me) & 8)) {\n          text = 'Connection lost · autopilot'\n        } else if (away.length) {\n          text = `${away.map(pilotName).join(', ')} · lost connection, autopilot`\n        } else {\n          const me = netplay.local()\n          const flags = me >= 0 ? engine._web_coop_flags(me) : 0\n          if (flags & 1) {\n            const view = engine._web_coop_view()\n            const who = view !== me ? pilotName(view) : ''\n            text = flags & 2 ? (who ? `Out of lives · watching ${who}` : 'Out of lives') : who ? `Down · watching ${who}` : 'Down · waiting for the team'\n          }\n        }\n      }\n      coopLabel.textContent = text\n      coopLabel.style.display = text ? 'block' : 'none'\n    }\n    // ---- synchronized start ----\n    // Each phone, once its engine is up AND its pilot has tapped Ready, says 'netready' until it hears\n    // 'go'. The host says 'go' when every pilot of the lobby is ready (or after NET_START_WAIT_MS), with\n    // the seed and the slot order, so every phone starts the same game. The tap is required: a departure\n    // arrives over the network, not from a tap on this phone, and iOS only grants motion (gyro), sound\n    // and fullscreen inside a tap.\n    const NET_START_WAIT_MS = 45000\n    netplay.ready = new Set()\n    // Launched by a tap a moment ago (a /map button): motion and sound were granted there.\n    netplay.tapped = !!(rt.tappedAt && Date.now() - rt.tappedAt < 10000)\n    // Every phone must run the same engine build, or the games drift apart at once.\n    const BUILD = String(d2.param('engineJs', '') || '')\n    netplay.go = (msg) => {\n      if (netplay.went) return\n      if (msg && msg.build && BUILD && msg.build !== BUILD) {\n        setStatus('This phone has an older version of the game. Close the app and open it again, then rejoin.')\n        goBtn.hidden = true\n        return\n      }\n      netplay.went = true\n      // The host's teams, so every phone plays the same match (Arena).\n      if (msg && msg.teams && rt.mp) rt.mp.teams = msg.teams\n      netplay.slots = Array.isArray(msg && msg.slots) && msg.slots.length ? msg.slots : netplay.order()\n      netplay.seed = (msg && Number(msg.seed)) | 0\n      netplay.startedAt = performance.now()\n      clearInterval(netplay.readyTimer)\n      clearTimeout(netplay.giveUp)\n      runCommand()\n    }\n    netplay.everyoneReady = () => Object.keys(rt.mp.pilots || {}).every((email) => email === rt.mp.me || netplay.ready.has(email))\n    netplay.goMsg = () => {\n      if (!netplay.hostGo) netplay.hostGo = { t: 'go', seed: (Math.random() * 0x7fffffff) | 0, slots: netplay.order(), build: BUILD, teams: (rt.mp && rt.mp.teams) || null }\n      return netplay.hostGo\n    }\n    netplay.wait = () => {\n      if (netplay.waiting) return\n      if (!netplay.tapped) {\n        setStatus('Engine ready. Tap Ready to fly (turns on motion and sound).')\n        goBtn.textContent = 'Ready'\n        goBtn.hidden = false\n        return\n      }\n      netplay.waiting = true\n      setStatus('Waiting for your wingmen…')\n      if (rt.net.isHost()) {\n        if (netplay.everyoneReady()) { const m = netplay.goMsg(); rt.net.send(m, { reliable: true }); return netplay.go(m) }\n      } else {\n        rt.net.send({ t: 'netready' }, { reliable: true })\n        netplay.readyTimer = setInterval(() => rt.net.send({ t: 'netready' }, { reliable: true }), 1000)\n      }\n      netplay.giveUp = setTimeout(() => {\n        if (rt.net.isHost()) { const m = netplay.goMsg(); rt.net.send(m, { reliable: true }); netplay.go(m) }\n      }, NET_START_WAIT_MS)\n    }\n\n    if (netplay.on) {\n      netplay.offBin = rt.net.on('bin', (msg, from) => {\n        // The host relays what a guest sent to the other guests (never a dropped pilot's).\n        if (receive(msg.data, from) && rt.net.isHost()) rt.net.sendBin(msg.data, { except: from })\n      })\n      netplay.offReady = rt.net.on('netready', (msg, from) => {\n        if (!rt.net.isHost()) return\n        netplay.ready.add(from)\n        // A pilot still waiting after the start (slow boot): send it on its way, into the same game.\n        if (netplay.went) return rt.net.send(netplay.goMsg(), { reliable: true, to: from })\n        if (netplay.waiting && netplay.everyoneReady()) { const m = netplay.goMsg(); rt.net.send(m, { reliable: true }); netplay.go(m) }\n      })\n      netplay.offGo = rt.net.on('go', (msg) => { if (!rt.net.isHost()) netplay.go(msg) })\n      netplay.offDropped = rt.net.on('coop-dropped', (msg) => {\n        const slot = netplay.slots.indexOf(msg.email)\n        if (slot >= 0) netplay.dropped.set(slot, { next: 0 })\n        // This phone was dropped (it slept past the limit): its game can no longer match; back to the lobby.\n        if (msg.email === rt.mp.me && !rt.net.isHost()) { remember({ event: 'dropped' }); leave('/') }\n      })\n      netplay.timer = setInterval(() => { try { dropCheck(); status() } catch (e) {} }, 200)\n      comms.timer = setInterval(() => { try { comms.tick() } catch (e) {} }, 33)\n      const talkBtn = $('.talk')\n      if (talkBtn) { talkBtn.hidden = false; if (SCHEME === 'mouse') talkBtn.textContent = 'HOLD V · TALK' }\n    }\n    d2.onUnmount(() => {\n      clearInterval(netplay.timer)\n      comms.stop()\n      for (const off of [netplay.offBin, netplay.offReady, netplay.offGo, netplay.offDropped]) if (off) off()\n      clearInterval(netplay.readyTimer)\n      clearTimeout(netplay.giveUp)\n      try { if (engine) { engine.coopSend = null; engine.coopHash = null } } catch (e) {}\n    })\n\n    // ---- boot (once per page) --------------------------------------------------------------\n    const boot = async () => {\n      if (rt.booting || rt.engine || !rt.files) return\n      rt.booting = true\n      goBtn.hidden = true\n      setStatus('Starting engine…')\n      const files = rt.files\n      const jsUrl = URL.createObjectURL(new Blob([files.js], { type: 'text/javascript' }))\n      try {\n        // Always load this build's loader: a createStarship left on the page by an earlier page\n        // state would pair old JS glue with the new wasm, hiding the new exports.\n        try { delete window.createStarship } catch (e) { window.createStarship = undefined }\n        await new Promise((resolve, reject) => {\n          // A private blob, not a CDN library — d2.lib does not apply here.\n          const s = document.createElement('script')\n          s.src = jsUrl\n          s.onload = resolve\n          s.onerror = () => reject(new Error('Engine script failed to load'))\n          document.head.appendChild(s)\n        })\n        const factory = window.createStarship\n        if (typeof factory !== 'function') throw new Error('Engine factory missing')\n\n        const Module = await factory({\n          canvas,\n          noInitialRun: true,\n          wasmBinary: files.wasm,\n          print: (t) => { logLine(t); console.log('[starship]', t) },\n          printErr: (t) => { logLine(t); console.warn('[starship]', t) },\n          onNewShader: (lo, hi, id1) => { rt.diag && rt.diag.shd++; learnWarm(`S ${lo} ${hi} ${id1}`) },\n          onNewResource: (path) => { rt.diag && rt.diag.res.push(path); learnWarm(`R ${path}`) },\n          onAbort: (what) => (rt.crash ? rt.crash : crash)(`Engine aborted: ${what}`, new Error('abort').stack),\n          // The current player mount handles reports; none mounted: keep it for the menus.\n          onDirector: (json) => {\n            let ev\n            try { ev = JSON.parse(json) } catch (e) { return }\n            if (rt.onDirector) rt.onDirector(ev)\n            else remember(ev)\n          },\n          preRun: [(M) => {\n            // SDL addresses its canvas as '#canvas'; the canvas lives in a shadow root that\n            // document.querySelector cannot see, so map the selector to the element directly.\n            if (M.specialHTMLTargets) M.specialHTMLTargets['#canvas'] = canvas\n            // Hand SDL the context unlocked by a tap (SDL reuses Module.SDL2.audioContext), with the\n            // master volume bus in place before SDL connects to it.\n            if (rt.audio) { masterBus(rt.audio); M.SDL2 = M.SDL2 || {}; M.SDL2.audioContext = rt.audio }\n            try { M.FS.writeFile('/warmup.txt', [...learned].join('\\n')) } catch (e) {}\n            M.FS.writeFile('/sf64.o2r', files.main)\n            M.FS.writeFile('/starship.o2r', files.port)\n          }],\n        })\n        rt.engine = engine = Module\n        rt.engineRef = `${refs.js}|${refs.wasm}`\n        rt.files = null // let the download buffers go; the engine holds its own copies\n        watchFrames()\n        setStatus('Preparing graphics…')\n        // Install before main so SDL's joystick enumeration at init already sees the pad.\n        rt.uninstallPad = installVirtualPad(pad)\n        canvas.focus()\n        if (typeof Module._web_director === 'function') Module._web_director(1)\n        rt.warming = true\n        try {\n          Module.callMain([])\n        } catch (e) {\n          // emscripten_set_main_loop(..., simulate_infinite_loop=1) unwinds out of main.\n          if (!(e === 'unwind' || (e && e.name === 'ExitStatus'))) throw e\n        }\n        applyVolume()\n        // And announce it again now that SDL's gamepadconnected listener is registered.\n        const ev = new Event('gamepadconnected')\n        ev.gamepad = pad\n        window.dispatchEvent(ev)\n        // Behind the loading screen, compile the shaders and load the resources gameplay will ask\n        // for (the engine's own list plus what this device learned), so first use does not stall.\n        await warmUp(Module)\n        rt.warming = false\n        if (rt.ready) runCommand()\n        else setStatus('Starting…')\n      } catch (e) {\n        rt.warming = false\n        if (e && (e.name === 'ExitStatus' || e === 'unwind')) return\n        overlay.hidden = false\n        fail(String(e && e.message || e))\n      } finally {\n        URL.revokeObjectURL(jsUrl)\n      }\n    }\n\n    // ---- downloads (once per page) -----------------------------------------------------------\n    const preload = async () => {\n      if (rt.files || rt.engine) return\n      if (rt.downloading) return rt.downloading\n      rt.downloading = (async () => {\n        if (!window.WebGL2RenderingContext) throw new Error('This browser has no WebGL2.')\n        setStatus('Downloading game files…')\n        errEl.textContent = ''\n        downloadKey = await getDownloadKey()\n        const [js, wasm, main, port] = await Promise.all([\n          fetchBytes('js', refs.js), fetchBytes('wasm', refs.wasm),\n          fetchBytes('main', refs.main), fetchBytes('port', refs.port),\n        ])\n        rt.files = { js, wasm, main, port }\n        setProgress(1)\n      })()\n      try { await rt.downloading } finally { rt.downloading = null }\n    }\n\n    // Resume the engine that is already on this page (a later level, or back from the menus).\n    const adopt = () => {\n      setStatus('')\n      rt.lastDraw = performance.now()\n      try { engine.resumeMainLoop && engine.resumeMainLoop() } catch (e) {}\n      engineOut(true)\n      resumeAudio()\n      applyVolume()\n      // SDL sizes the canvas from its CSS box on window resize; the box is new.\n      window.dispatchEvent(new Event('resize'))\n      runCommand()\n    }\n\n    const start = async () => {\n      if (crashed) {\n        const box = $('.crash')\n        box.querySelector('.crash-why').textContent = 'The game stopped earlier on this page.'\n        box.querySelector('.crash-log').textContent = logTail.slice(-15).join('\\n')\n        box.hidden = false\n        return\n      }\n      if (engine) return adopt()\n      try {\n        await preload()\n      } catch (e) {\n        if (e instanceof Refused) return nav(`/login?next=${encodeURIComponent(location.pathname + location.search)}`)\n        return fail(String(e && e.message || e))\n      }\n      if (rt.engine) { engine = rt.engine; return adopt() }\n      if (rt.audio && rt.audio.state !== 'closed') return boot() // the menu tap unlocked sound\n      setStatus('Ready. Tap Start (sound and motion need a tap).')\n      goBtn.hidden = false\n    }\n    d2.on('.start-btn', 'click', () => {\n      unlockAudio()\n      askMotionPermission()\n      enterFullscreen()\n      if (netplay.on && engine && !netplay.tapped) {\n        netplay.tapped = true\n        goBtn.hidden = true\n        return netplay.wait()\n      }\n      boot()\n    })\n\n    // Leaving /play: pause the engine, silence it and park its canvas outside the component (the\n    // component's DOM is about to go). The next /play mount adopts both.\n    d2.onUnmount(() => {\n      mountedHere = false\n      if (rt.onDirector === onDirector) rt.onDirector = null\n      if (rt.crash === crash) rt.crash = null\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      engineOut(false)\n      if (!rt.parking) {\n        rt.parking = document.createElement('div')\n        rt.parking.style.cssText = 'position:fixed;left:-10000px;top:0;width:844px;height:390px;overflow:hidden;visibility:hidden'\n        document.body.appendChild(rt.parking)\n      }\n      try { rt.parking.appendChild(canvas) } catch (e) {}\n    })\n    rt.crash = crash\n\n    // Freeze watchdog: a frozen engine raises no error, so the crash screen never came up and the\n    // last audio buffer kept looping. The renderer clears its target once per frame, so stamp that\n    // (one call per frame, not per draw); no frame for a few seconds while playing = frozen.\n    // Hitch recorder (diagnostics for first-use stalls on phones). Per frame it counts what the\n    // engine created and times the WebGL calls and rAF callbacks; a frame over 150 ms while flying\n    // is reported on screen (and kept in localStorage `${STORE}.hitches`).\n    // The GL and rAF wrappers are installed once per page (rt.diag); each mount shows the reports.\n    const diagBox = () => {\n      let el = root.querySelector('.diag')\n      if (!el) {\n        el = document.createElement('div')\n        el.className = 'diag'\n        el.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:9;max-width:92%;padding:6px 10px;border-radius:8px;background:rgba(0,0,0,0.75);color:#ffd27a;font:600 11px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap'\n        wrap.appendChild(el)\n      }\n      return el\n    }\n    const showHitch = (msg) => {\n      const box = diagBox()\n      box.textContent = msg\n      box.style.display = 'block'\n      clearTimeout(rt.diagTimer)\n      rt.diagTimer = setTimeout(() => { box.style.display = 'none' }, 8000)\n    }\n    rt.showHitch = showHitch\n    d2.onUnmount(() => { if (rt.showHitch === showHitch) rt.showHitch = null })\n    function watchFrames() {\n      if (rt.diag) return\n      const diag = rt.diag = { res: [], shd: 0, n: {}, slow: ['', 0], cpu: 0, last: 0, reports: 0 }\n      const diagReset = () => { diag.res = []; diag.shd = 0; diag.n = {}; diag.slow = ['', 0]; diag.cpu = 0 }\n      const flying = () => { try { return rt.engine && rt.engine._web_ui_mode && rt.engine._web_ui_mode() === 0 } catch (e) { return false } }\n      const diagFrame = (now) => {\n        const gap = now - diag.last\n        if (diag.last && gap > 150 && gap < 3000 && !document.hidden && rt.showHitch && flying() && diag.reports < 6) {\n          diag.reports++\n          const n = diag.n\n          const parts = [`Hitch ${Math.round(gap)}ms`, `js ${Math.round(diag.cpu)}ms`,\n            `slowest gl: ${diag.slow[0] || '-'} ${Math.round(diag.slow[1])}ms`,\n            `draws ${n.drawArrays || 0}`, `newTex ${n.createTexture || 0}`, `texUpload ${n.texImage2D || 0}`,\n            `shaders ${diag.shd}/${n.linkProgram || 0}`, `readback ${(n.readPixels || 0) + (n.blitFramebuffer || 0)}`,\n            `res ${diag.res.length}${diag.res.length ? ' (' + diag.res.slice(0, 3).map((p) => p.split('/').pop()).join(', ') + ')' : ''}`]\n          const msg = parts.join(' · ')\n          rt.showHitch(msg)\n          try {\n            const prev = JSON.parse(localStorage.getItem(`${STORE}.hitches`) || '[]')\n            prev.push({ at: new Date().toISOString(), msg })\n            localStorage.setItem(`${STORE}.hitches`, JSON.stringify(prev.slice(-20)))\n          } catch (e) {}\n        }\n        diag.last = now\n        diagReset()\n      }\n      const gl = canvas.getContext('webgl2')\n      if (gl) {\n        const clear = gl.clear.bind(gl)\n        gl.clear = (...a) => { rt.lastDraw = performance.now(); diagFrame(rt.lastDraw); return clear(...a) }\n        for (const fn of ['drawArrays', 'bufferData', 'texImage2D', 'texSubImage2D', 'createTexture', 'useProgram',\n          'compileShader', 'linkProgram', 'readPixels', 'blitFramebuffer', 'bindFramebuffer', 'uniform1iv']) {\n          if (typeof gl[fn] !== 'function') continue\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => {\n            const t = performance.now()\n            const r = orig(...a)\n            const d = performance.now() - t\n            diag.n[fn] = (diag.n[fn] || 0) + 1\n            if (d > diag.slow[1]) diag.slow = [fn, d]\n            return r\n          }\n        }\n      }\n      const raf = window.requestAnimationFrame.bind(window)\n      window.requestAnimationFrame = (cb) => raf((ts) => {\n        const t = performance.now()\n        try { return cb(ts) } finally { diag.cpu += performance.now() - t }\n      })\n    }\n    // The watchdog runs only while this mount shows the game (the engine is paused elsewhere).\n    rt.lastDraw = performance.now()\n    const watchdog = setInterval(() => {\n      if (crashed || !engine || overlay.hidden === false) { rt.lastDraw = performance.now(); return }\n      if (document.hidden) { rt.lastDraw = performance.now(); return }\n      if (performance.now() - (rt.lastDraw || 0) > 4000) {\n        crash('The game froze (no frames for 4 seconds).', '')\n        clearInterval(watchdog)\n      }\n    }, 1000)\n    d2.onUnmount(() => clearInterval(watchdog))\n\n    // ---- page takeover (phones) ----------------------------------------------------------\n    // Edge to edge: extend the page under the notch/home-indicator areas (viewport-fit=cover),\n    // paint everything dark, and pin the component over the whole screen. The HUD already keeps\n    // its buttons inside env(safe-area-inset-*). Skipped in the builder so its UI is not covered.\n    // Gestures: iOS turns a second finger into pinch-zoom, double taps into zoom and long presses\n    // into the magnifier; cancel those on everything except the form fields and the chips/buttons\n    // that rely on click.\n    const undo = []\n    const takeOverPage = () => {\n      const inBuilder = !/\\.data2\\.app$/.test(location.hostname) // only the published app takes over the page\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      if (!inBuilder) {\n        setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n        setMeta('apple-mobile-web-app-capable', 'yes')\n        setMeta('mobile-web-app-capable', 'yes')\n        setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n        setMeta('theme-color', '#05060a')\n        const styleOf = (el, css) => {\n          const prev = el.getAttribute('style')\n          el.style.cssText += css\n          undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n        }\n        styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n        styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n        const host = d2.root && d2.root.host\n        if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n      }\n      const allowed = (t) => t && t.closest && t.closest('input, .chip, .go, .crash, .overlay, .pausewin')\n      const on = (target, type, fn) => {\n        target.addEventListener(type, fn, { passive: false })\n        undo.push(() => target.removeEventListener(type, fn, { passive: false }))\n      }\n      const block = (e) => { if (!allowed(e.target)) e.preventDefault() }\n      // Pointer events (buttons, stick) still fire when touchstart is cancelled.\n      on(root, 'touchstart', block)\n      on(root, 'touchmove', block)\n      on(root, 'touchend', (e) => { if (!allowed(e.target) && e.touches.length) e.preventDefault() })\n      on(root, 'contextmenu', (e) => e.preventDefault())\n      on(root, 'selectstart', block)\n      on(root, 'dblclick', (e) => e.preventDefault())\n      for (const g of ['gesturestart', 'gesturechange', 'gestureend']) on(document, g, (e) => e.preventDefault())\n    }\n    takeOverPage()\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n\n    // SDL owns the canvas size: it follows the canvas's CSS box on every window resize.\n\n    start()\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"engineJs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineJs"}}}},"engineWasm":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineWasm"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"gyroRangeDeg":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gyroRangeDeg"}}}},"invertY":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertY"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"mainO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"mainO2r"}}}},"portO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"portO2r"}}}},"routerRef":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"routerRef"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac6e14e721e51b15dc94b2e","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Menu_DEV","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF DEV menus — development copy of TFSF_Menu. All menu changes are made here first, tested on\n// the tfsf-dev app, then promoted to TFSF_Menu (PRD) by copying this source with the DEV markers\n// (badge, runtime/storage keys) switched back.\n//\n// Every screen around the game, one per route of the app's Router:\n//   /login     sign in (tfsfAuth)\n//   /title     the launch title (tap: sound, motion, microphone), then back to \"/\": the main menu\n//              itself is TFSF_MainMenu_DEV, its own artboard on \"/\"\n//   /solo      team lobby: campaign (placeholder), four team slots, ready-up, Depart\n//   /multiplayer                 create a lobby, or browse open ones\n//   /multiplayer/lobbies         open lobbies, join one\n//   /multiplayer/lobbies/:_id    a lobby: its pilots, ships (placeholder), ready-up; Depart for the host\n//   /profile, /settings, /starfox64   placeholders for now\n//   /map       after a level: result, route so far, and the choice of where to fly next\n//   /results   game over / ending summary\n// The game itself is TFSF_Player_DEV on /play. This component never touches the engine: it only\n// navigates to /play with a query saying what to play (cmd=new|level|training|continue|retry|alt),\n// and reads the engine's last report (window[RT_KEY].lastEvent, also in sessionStorage) to draw\n// /map and /results.\n//\n// Lobbies live in two tables: `lobbies` (one row per session: code, host, campaign, status, and a\n// players snapshot the host keeps in sync) and `lobbyPlayers` (one row per pilot in a lobby). Each\n// pilot only ever writes its own lobbyPlayers row, so pilots never overwrite each other; the host is\n// the only writer of the lobby row. Pilots heartbeat every few seconds. A pilot whose tab closed or\n// went to sleep stops beating: after AWAY_MS the others see it as disconnected, and after LIVE_MS\n// (60 s) it is kicked (the host deletes its row) and, if it was the host, the earliest-joined pilot\n// still there takes over. Coming back within that minute (opening the app again) lands straight\n// back in the lobby, seat, pilot and ready state kept. Presence runs on the server's clock (each row's updatedAt / createdAt): phones'\n// clocks can be off by minutes, and a phone that trusted its own would see the others as gone.\n// Every lobby write is also announced on the `socket` (a data2 PowerCordSocket, one room per lobby),\n// so the others refresh at once; the heartbeat poll stays as the fallback.\n//\n// Netcode (sm64coopdx-style): every phone flies its own ship; the host owns the world. Guests link\n// to the host over WebRTC data channels (a star: the host relays between guests). Signaling goes\n// through the `signals` table (one offer/answer row per attempt, no trickle, public STUN only, no\n// TURN). The links live in window[RT_KEY].net, so they survive the switch from the lobby to /play.\n//\n// The tap that leads to /play is the user gesture iOS needs for sound and motion, so it unlocks\n// the audio context here (shared with the player through window[RT_KEY].audio), asks for motion\n// permission and goes fullscreen before navigating.\n\nconst RT_KEY = '__TFSF_DEV__'      // the per-page runtime shared with TFSF_Player_DEV\nconst STORE = 'tfsf-dev'           // localStorage / sessionStorage prefix\n\n// PlanetId (engine order) -> name.\nconst PLANETS = ['Meteo', 'Area 6', 'Bolse', 'Sector Z', 'Sector X', 'Sector Y', 'Katina', 'Macbeth',\n  'Zoness', 'Corneria', 'Titania', 'Aquas', 'Fortuna', 'Venom', 'Solar']\nconst planetName = (id) => PLANETS[id] || '—'\n\n// The team on /solo: the player flies Fox, the CPUs fly the wingmen and are always ready.\nconst TEAM = [\n  { slot: 'Player', pilot: 'Fox', cpu: false },\n  { slot: 'CPU 1', pilot: 'Falco', cpu: true },\n  { slot: 'CPU 2', pilot: 'Slippy', cpu: true },\n  { slot: 'CPU 3', pilot: 'Peppy', cpu: true },\n]\n// Control schemes the player can pick on /solo (the player component reads `${STORE}.controls`).\nconst CONTROLS = [{ id: 'motion', label: 'Motion' }, { id: 'touch', label: 'Touch' }, { id: 'mouse', label: 'Mouse & keys' }]\nconst HOLD_BACK_MS = 800\nconst MAX_PLAYERS = 4\nconst AWAY_MS = 12000         // no heartbeat for this long: shown as disconnected\nconst LIVE_MS = 60000         // no heartbeat for this long: kicked from the lobby\nconst PRUNE_MS = LIVE_MS      // the host deletes the rows of kicked pilots\nconst BEAT_MS = 4000          // heartbeat + refresh period inside a lobby\nconst DEFAULT_CAMPAIGN = \"Andross' Attack\"\n// The campaign selector: the whole campaign, or one stage (engine LevelId, played with cmd=level).\nconst STAGES = [\n  { id: -1, name: DEFAULT_CAMPAIGN, kind: 'Full campaign' },\n  { id: 0, name: 'Corneria', kind: 'Planet' }, { id: 1, name: 'Meteo', kind: 'Space' },\n  { id: 5, name: 'Sector Y', kind: 'All-range' }, { id: 14, name: 'Fortuna', kind: 'All-range' },\n  { id: 16, name: 'Katina', kind: 'All-range' }, { id: 13, name: 'Aquas', kind: 'Blue Marine' },\n  { id: 2, name: 'Sector X', kind: 'Space' }, { id: 7, name: 'Solar', kind: 'Planet' },\n  { id: 8, name: 'Zoness', kind: 'Planet' }, { id: 12, name: 'Titania', kind: 'Landmaster' },\n  { id: 11, name: 'Macbeth', kind: 'Landmaster' }, { id: 18, name: 'Sector Z', kind: 'All-range' },\n  { id: 17, name: 'Bolse', kind: 'All-range' }, { id: 3, name: 'Area 6', kind: 'Space' },\n  { id: 6, name: 'Venom', kind: 'Planet' }, { id: 19, name: 'Venom (hard)', kind: 'Planet' },\n  { id: 9, name: 'Andross', kind: 'Boss' },\n]\nconst stageOf = (id) => STAGES.find((st) => st.id === Number(id)) || STAGES[0]\n// What /play is told for a stage: the campaign from the start, or that one level.\nconst stageCmd = (id) => (Number(id) >= 0 ? { cmd: 'level', level: Number(id) } : { cmd: 'new' })\n// The McCloud team, then Star Wolf's crew (they fly Wolfens and shoot green).\nconst PILOTS = [{ id: 'fox', name: 'Fox' }, { id: 'falco', name: 'Falco' }, { id: 'slippy', name: 'Slippy' }, { id: 'peppy', name: 'Peppy' },\n  { id: 'wolf', name: 'Wolf' }, { id: 'leon', name: 'Leon' }, { id: 'pigma', name: 'Pigma' }, { id: 'andrew', name: 'Andrew' }]\n// Lobby modes. Adventure: up to 4 pilots fly the campaign together. Arena: up to 8, two teams of 4,\n// the original game's versus battles (maps and match types below).\nconst MODES = { adventure: { name: 'Adventure', max: 4 }, arena: { name: 'Arena', max: 8 } }\nconst VS_STAGES = [{ id: 0, name: 'Corneria' }, { id: 1, name: 'Katina' }, { id: 2, name: 'Sector Z' }]\n// Arena matches are played in rounds; the lobby's `vsMatch` picks how many round points win it.\nconst VS_MATCHES = [{ id: 0, name: 'First to 3', rounds: 3 }, { id: 1, name: 'First to 5', rounds: 5 }, { id: 2, name: 'First to 10', rounds: 10 }]\nconst TEAMS = [{ id: 'blue', name: 'Team Blue' }, { id: 'red', name: 'Team Red' }]\nconst SWAP_MS = 20000         // a swap request stays open this long\nconst ARENA_MAX = 8           // pilots per Arena battle (4v4)\nconst ARENA_MINUTES = 3       // a Time Trial's length\nconst roundsOf = (vsMatch) => (VS_MATCHES[Number(vsMatch) || 0] || VS_MATCHES[0]).rounds\nconst pilotName = (id) => (PILOTS.find((p) => p.id === id) || PILOTS[0]).name\nconst SIG_MS = 1500           // signaling poll while linking\nconst LINK_TIMEOUT_MS = 15000 // a link that has not opened by then is retried\nconst STALE_MS = 6000         // no packet for this long: the link is dead\n\n// ---- the network runtime (one per page, outlives this component) -----------------------------------\n// net.send(msg, { reliable, to }) — guests always send to the host; the host sends to one guest or\n// all of them, and relays guest messages to the other guests. net.on(type, fn) subscribes.\nconst ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:global.stun.twilio.com:3478' }]\nfunction makeNet() {\n  const net = { lobby: '', me: '', host: '', peers: new Map(), handlers: new Map(), handled: new Set(), links: {} }\n  net.isHost = () => !!net.me && net.me === net.host\n  net.on = (type, fn) => {\n    if (!net.handlers.has(type)) net.handlers.set(type, new Set())\n    net.handlers.get(type).add(fn)\n    return () => net.handlers.get(type).delete(fn)\n  }\n  const dispatch = (msg, from) => { for (const fn of net.handlers.get(msg.t) || []) { try { fn(msg, from) } catch (e) {} } }\n  const raw = (peer, msg, reliable) => {\n    const ch = reliable ? peer.r : (peer.u && peer.u.readyState === 'open' ? peer.u : peer.r)\n    if (ch && ch.readyState === 'open') { try { ch.send(typeof msg === 'string' ? msg : JSON.stringify(msg)) } catch (e) {} }\n  }\n  net.send = (msg, opts = {}) => {\n    const reliable = !!opts.reliable\n    if (net.isHost()) {\n      for (const [email, peer] of net.peers) if (!opts.to || opts.to === email) raw(peer, msg, reliable)\n    } else {\n      const host = net.peers.get(net.host)\n      if (host) raw(host, msg, reliable)\n    }\n  }\n  // Binary (ArrayBuffer) on the unreliable channel: co-op inputs. A guest's goes to the host; the\n  // host's goes to every guest (opts.except: all but one, for the host relaying a guest's packet).\n  // It arrives as { t: 'bin', data } and is not relayed here: the receiver decides.\n  net.sendBin = (buf, opts = {}) => {\n    const targets = net.isHost() ? [...net.peers.values()].filter((p) => p.email !== opts.except) : [net.peers.get(net.host)].filter(Boolean)\n    for (const peer of targets) {\n      const ch = peer.u && peer.u.readyState === 'open' ? peer.u : peer.r\n      if (ch && ch.readyState === 'open') { try { ch.send(buf) } catch (e) {} }\n    }\n  }\n  net.peerState = (email) => { const p = net.peers.get(email); return p ? p.state : 'none' }\n  net.closePeer = (email) => {\n    const p = net.peers.get(email)\n    if (!p) return\n    net.peers.delete(email)\n    try { p.u && p.u.close() } catch (e) {}\n    try { p.r && p.r.close() } catch (e) {}\n    try { p.pc.close() } catch (e) {}\n  }\n  net.closeAll = () => { for (const email of [...net.peers.keys()]) net.closePeer(email) }\n  // Wire a data channel: control messages (ping/pong) are handled here; the rest is dispatched, and\n  // the host relays guest messages to the other guests.\n  net.wire = (peer, ch, reliable) => {\n    if (reliable) peer.r = ch\n    else peer.u = ch\n    ch.binaryType = 'arraybuffer'\n    ch.onopen = () => { if (peer.r && peer.r.readyState === 'open') { peer.state = 'open'; peer.lastRx = Date.now() } }\n    ch.onclose = () => { if (net.peers.get(peer.email) === peer) peer.state = 'closed' }\n    ch.onmessage = (e) => {\n      peer.lastRx = Date.now()\n      if (typeof e.data !== 'string') return dispatch({ t: 'bin', data: e.data }, peer.email)\n      let msg\n      try { msg = JSON.parse(e.data) } catch (err) { return }\n      if (msg.t === 'ping') return raw(peer, { t: 'pong', ts: msg.ts }, false)\n      if (msg.t === 'pong') { peer.rtt = Date.now() - msg.ts; return }\n      const from = msg.from || peer.email\n      if (net.isHost() && !msg.from) {\n        const fwd = JSON.stringify({ ...msg, from: peer.email })\n        for (const [email, other] of net.peers) if (email !== peer.email) raw(other, fwd, reliable)\n      }\n      dispatch(msg, from)\n    }\n  }\n  net.newPeer = (email, sid) => {\n    net.closePeer(email)\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS })\n    const peer = { email, sid, pc, u: null, r: null, state: 'linking', rtt: 0, lastRx: 0, startedAt: Date.now() }\n    pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') peer.state = 'failed' }\n    pc.ondatachannel = (e) => net.wire(peer, e.channel, e.channel.label === 'r')\n    net.peers.set(email, peer)\n    return peer\n  }\n  // Non-trickle: wait until ICE gathering is done (STUN answers in well under a second; cap 5 s).\n  net.gathered = (pc) => new Promise((resolve) => {\n    if (pc.iceGatheringState === 'complete') return resolve()\n    const t = setTimeout(resolve, 5000)\n    pc.addEventListener('icegatheringstatechange', () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); resolve() } })\n  })\n  // Keep-alive: ping every second; a link with no traffic for STALE_MS is closed (guests then redial).\n  net.pinger = setInterval(() => {\n    const now = Date.now()\n    for (const [email, p] of net.peers) {\n      if (p.state === 'open') {\n        raw(p, { t: 'ping', ts: now }, false)\n        if (now - p.lastRx > STALE_MS) p.state = 'closed'\n      }\n    }\n    // The host tells everyone how each guest's link is doing (guests only see their own).\n    if (net.isHost()) {\n      const links = {}\n      for (const [email, p] of net.peers) links[email] = { state: p.state, rtt: p.rtt }\n      net.links = links\n      net.send({ t: 'links', links })\n    }\n  }, 1000)\n  net.on('links', (msg) => { net.links = msg.links || {} })\n  return net\n}\n\n\nconst STATUS = ['Mission complete', 'Mission accomplished', 'Warp']\nconst esc = (v) => String(v == null ? '' : v).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]))\n\n// Header breadcrumb: the hold-to-go-back button and \"/ Title\".\nconst crumb = (title, backTo) => `<div class=\"crumb\">\n  <button class=\"back\" data-back=\"${backTo}\" aria-label=\"Hold to go back\">‹<svg viewBox=\"0 0 54 54\"><circle cx=\"27\" cy=\"27\" r=\"25\"></circle></svg></button>\n  <h1><span>/</span><span class=\"crumb-title\">${title}</span></h1>\n  <div class=\"back-hint\">Hold to go back</div>\n</div>`\n\nexport default {\n  css: `\n    .menu, .menu * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }\n    .menu { position: relative; width: 100%; height: 100%; overflow: hidden; color: #e8ecf4;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; user-select: none; -webkit-user-select: none;\n      background: radial-gradient(ellipse at 70% 30%, #1b2a52 0%, #0b1022 45%, #05060a 80%); }\n    /* Fullscreen toggle, top right on every menu screen. */\n    .fs-btn { position: absolute; top: calc(10px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right)); z-index: 30;\n      width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;\n      appearance: none; border: 1px solid #ffffff38; background: #0b1022b3; color: #e8ecf4; }\n    .fs-btn:active { transform: scale(0.94); }\n    .fs-btn[hidden] { display: none; }\n    .fs-btn svg { width: 18px; height: 18px; }\n    .fs-btn .fs-exit { display: none; } .fs-btn.on .fs-exit { display: block; } .fs-btn.on .fs-enter { display: none; }\n    .menu.has-fs .solo-head { padding-right: 44px; }\n    /* Main menu: the sound button, bottom left (opens /settings; crossed out while muted). */\n    .snd-btn { position: absolute; left: calc(14px + env(safe-area-inset-left)); bottom: calc(14px + env(safe-area-inset-bottom)); z-index: 5;\n      width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;\n      appearance: none; border: 1px solid #ffffff38; background: #0b1022b3; color: #e8ecf4; }\n    .snd-btn:active { transform: scale(0.94); }\n    .snd-btn svg { width: 22px; height: 22px; }\n    .snd-btn .snd-off { display: none; } .snd-btn.muted .snd-off { display: block; } .snd-btn.muted .snd-on { display: none; }\n    .snd-btn.muted { color: #ff9a9a; border-color: #ff9a9a66; }\n    /* /settings: the sound switch above the sliders. */\n    .snd-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-radius: 12px;\n      background: linear-gradient(160deg, #ffffff12, #ffffff05); border: 1px solid #ffffff1c; }\n    .snd-row span { font: 700 13px/1.2 system-ui, sans-serif; }\n    .switch { appearance: none; position: relative; width: 52px; height: 30px; border-radius: 999px; border: 1px solid #ffffff38; background: #1d2740;\n      cursor: pointer; padding: 0; flex: 0 0 auto; transition: background 0.15s ease; }\n    .switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: #e8ecf4;\n      transition: transform 0.15s ease; }\n    .switch.on { background: #3d82e8; border-color: #9cc6ff; }\n    .switch.on::after { transform: translateX(22px); }\n    .mixer.muted .mix { opacity: 0.4; }\n    /* Star field: three layers of tiny dots. */\n    .stars, .stars::before, .stars::after { position: absolute; inset: 0; content: ''; pointer-events: none;\n      background-image: radial-gradient(1px 1px at 12% 22%, #fff9 50%, transparent 51%), radial-gradient(1px 1px at 33% 71%, #fff7 50%, transparent 51%),\n        radial-gradient(1.5px 1.5px at 58% 14%, #fffc 50%, transparent 51%), radial-gradient(1px 1px at 81% 63%, #fff8 50%, transparent 51%),\n        radial-gradient(1px 1px at 91% 31%, #fff6 50%, transparent 51%), radial-gradient(1.5px 1.5px at 23% 88%, #fffa 50%, transparent 51%),\n        radial-gradient(1px 1px at 47% 44%, #fff5 50%, transparent 51%), radial-gradient(1px 1px at 69% 87%, #fff7 50%, transparent 51%); }\n    .stars::before { transform: translate(17%, 9%) scale(1.3); opacity: 0.6; }\n    .stars::after { transform: translate(-21%, -13%) scale(0.8); opacity: 0.5; }\n    .screen { position: absolute; inset: 0; display: none; padding: calc(16px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-right))\n      calc(16px + env(safe-area-inset-bottom)) calc(24px + env(safe-area-inset-left)); }\n    .screen.on { display: flex; }\n    .envbadge { position: absolute; top: calc(6px + env(safe-area-inset-top)); left: calc(8px + env(safe-area-inset-left)); z-index: 10;\n      padding: 2px 8px; border-radius: 6px; background: #ff9f1a; color: #05060a; font: 800 11px/1.5 system-ui, sans-serif;\n      letter-spacing: 0.08em; pointer-events: none; }\n\n    .brand { display: flex; flex-direction: column; justify-content: center; gap: 6px; }\n    .logo { font-size: clamp(44px, 11vh, 76px); font-weight: 900; font-style: italic; letter-spacing: 0.04em; line-height: 0.95;\n      background: linear-gradient(180deg, #ffffff 0%, #b9d3ff 55%, #5aa2ff 100%); -webkit-background-clip: text; background-clip: text;\n      color: transparent; filter: drop-shadow(0 4px 18px #5aa2ff55); }\n    .tag { font-size: 13px; letter-spacing: 0.32em; text-transform: uppercase; color: #9fb4d9; }\n    .hint { font-size: 12px; color: #8193b5; max-width: 300px; line-height: 1.45; }\n\n    .home { align-items: stretch; justify-content: space-between; gap: 24px; }\n    .home .brand { flex: 1 1 auto; }\n    .actions { flex: 0 0 min(300px, 44vw); display: flex; flex-direction: column; justify-content: center; gap: 10px; }\n    .btn { appearance: none; border: 1px solid #ffffff22; border-radius: 14px; padding: 13px 18px; text-align: left;\n      background: linear-gradient(180deg, #ffffff14, #ffffff08); color: #e8ecf4; font: 700 16px/1.2 system-ui, sans-serif;\n      letter-spacing: 0.02em; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px;\n      transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .btn small { font-weight: 600; font-size: 11px; color: #9fb4d9; letter-spacing: 0.06em; text-transform: uppercase; }\n    .btn:active { transform: scale(0.97); background: #5aa2ff44; border-color: #9cc6ff; }\n    .btn.primary { background: linear-gradient(180deg, #6fb0ff, #3d82e8); border-color: #9cc6ff; color: #05060a; }\n    .btn.primary small { color: #0b1a33; }\n    .btn.danger { border-color: #ff8a8a55; color: #ffd0d0; }\n    .btn[disabled] { opacity: 0.4; pointer-events: none; }\n    .row { display: flex; gap: 10px; }\n    .row .btn { flex: 1; justify-content: center; }\n    .link { appearance: none; border: 0; background: none; color: #8193b5; font: 600 12px system-ui, sans-serif; padding: 6px; cursor: pointer;\n      text-decoration: underline; text-underline-offset: 3px; align-self: flex-start; }\n\n    /* Route / results */\n    .route { flex-direction: row; gap: 22px; }\n    .route .left { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; gap: 12px; min-width: 0; }\n    .route .right { flex: 0 0 min(300px, 42vw); display: flex; flex-direction: column; justify-content: center; gap: 9px; }\n    .kicker { font-size: 12px; letter-spacing: 0.3em; text-transform: uppercase; color: #9fb4d9; }\n    .headline { font-size: clamp(26px, 7vh, 40px); font-weight: 900; font-style: italic; line-height: 1.05; }\n    .headline.good { color: #9cf0b4; }\n    .headline.bad { color: #ff9b9b; }\n    .stats { display: flex; flex-wrap: wrap; gap: 8px; }\n    .stat { padding: 8px 12px; border-radius: 12px; background: #ffffff0f; border: 1px solid #ffffff17; min-width: 84px; }\n    .stat b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; }\n    .stat span { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: #9fb4d9; }\n    .team { display: flex; gap: 6px; flex-wrap: wrap; }\n    .mate { padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; background: #9cf0b422; color: #bff5cf; }\n    .mate.down { background: #ff8a8a22; color: #ffb4b4; text-decoration: line-through; }\n    .path { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: #9fb4d9; }\n    .path .p { padding: 3px 8px; border-radius: 8px; background: #ffffff0d; white-space: nowrap; }\n    .path .p.done { color: #e8ecf4; }\n    .path .p.next { background: #5aa2ff; color: #05060a; font-weight: 800; }\n    .path .p em { font-style: normal; color: #ffd27a; margin-left: 4px; }\n\n    /* Login */\n    .login { align-items: center; justify-content: center; gap: 34px; }\n    .title { flex-direction: column; align-items: center; justify-content: center; gap: 28px; cursor: pointer; text-align: center; }\n    .title .brand { align-items: center; }\n    .title .tap { font-size: 15px; letter-spacing: 0.32em; text-transform: uppercase; color: #ffd34d;\n      animation: tapPulse 1.6s ease-in-out infinite; }\n    .title .perm { font-size: 12px; color: #9fb0d0; min-height: 1.2em; letter-spacing: 0.04em; }\n    @keyframes tapPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }\n    .login form { display: flex; flex-direction: column; gap: 10px; width: min(300px, 80vw); }\n    .login input { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 12px;\n      padding: 12px 14px; font-size: 16px; outline: none; -webkit-user-select: text; user-select: text; }\n    .login input:focus { border-color: #5aa2ff; }\n    .login .btn { justify-content: center; }\n    .err { color: #ff8a8a; font-size: 13px; min-height: 1em; }\n    .busy { position: absolute; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center;\n      background: #05060acc; font-size: 14px; color: #9fb4d9; }\n    .busy.on { display: flex; }\n\n    /* Main menu list */\n    .main-list .btn { padding: 11px 18px; }\n\n    /* Placeholder screens */\n    .stub { flex-direction: column; align-items: flex-start; justify-content: center; gap: 14px; }\n\n    /* /solo: breadcrumb header, campaign card, vehicles, four team slots */\n    .solo { flex-direction: column; gap: 8px; }\n    .solo-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex: 0 0 auto; }\n    .crumb { position: relative; display: flex; align-items: center; gap: 12px; padding-top: 14px; }\n    .crumb h1 { margin: 0; font-size: 28px; font-weight: 900; font-style: italic; letter-spacing: 0.06em; }\n    .crumb h1 > span:first-child { color: #5aa2ff; margin-right: 8px; }\n    .back { position: relative; width: 46px; height: 46px; flex: 0 0 auto; border-radius: 50%; border: 1px solid #ffffff2e;\n      background: #ffffff12; color: #e8ecf4; font: 800 22px/1 system-ui, sans-serif; cursor: pointer; display: grid; place-items: center;\n      touch-action: none; }\n    .back svg { position: absolute; inset: -4px; width: 54px; height: 54px; transform: rotate(-90deg); pointer-events: none; }\n    .back circle { fill: none; stroke: #5aa2ff; stroke-width: 3; stroke-linecap: round; stroke-dasharray: 157; stroke-dashoffset: 157; }\n    .back.holding { background: #5aa2ff33; border-color: #9cc6ff; }\n    .back-hint { position: absolute; left: 0; top: 66px; font-size: 11px; color: #9fb4d9; letter-spacing: 0.08em; opacity: 0;\n      transition: opacity 0.2s; pointer-events: none; white-space: nowrap; }\n    .back-hint.show { opacity: 1; }\n    .campaign { appearance: none; text-align: left; cursor: pointer; border: 1px solid #ffffff2e; border-radius: 12px; padding: 8px 14px;\n      background: linear-gradient(160deg, #ffffff16, #ffffff06); color: #e8ecf4; min-width: 190px; }\n    .campaign small { display: block; font-size: 10px; letter-spacing: 0.24em; text-transform: uppercase; color: #9fb4d9; }\n    .campaign b { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 16px; font-style: italic; }\n    .campaign b i { font-style: normal; color: #5aa2ff; font-size: 12px; }\n    .bay { position: relative; flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }\n    .col { position: relative; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; min-width: 0; }\n    .vehicle { flex: 1 1 auto; min-height: 40px; border: 1.5px dashed #ffffff2a; border-radius: 50% / 40%; display: grid; place-items: center;\n      font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: #ffffff55; }\n    .opts { display: none; flex-direction: column; gap: 6px; }\n    .col.open .opts { display: flex; }\n    .col.open .vehicle { display: none; }\n    .opt { appearance: none; border: 1px solid #ffffff2e; border-radius: 10px; padding: 8px 10px; background: #0c1222e6; color: #e8ecf4;\n      font: 700 13px/1.2 system-ui, sans-serif; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n    .opt small { font-size: 10px; color: #9fb4d9; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600; }\n    .opt.go { background: linear-gradient(180deg, #6fe3a0, #2fbf71); border-color: #b5f5cf; color: #05170d; }\n    .opt:active { transform: scale(0.97); }\n    .slot { appearance: none; cursor: pointer; border: 1px solid #ffffff2e; border-radius: 12px; padding: 8px 12px; text-align: left;\n      background: linear-gradient(160deg, #ffffff14, #ffffff05); color: #e8ecf4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n    .slot b { display: block; font-size: 15px; font-style: italic; letter-spacing: 0.04em; }\n    .slot small { display: block; font-size: 10px; color: #9fb4d9; letter-spacing: 0.12em; text-transform: uppercase; }\n    .slot .mark { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-weight: 900; font-size: 15px;\n      border: 1.5px solid #ffffff40; color: transparent; flex: 0 0 auto; }\n    .slot.ready { border-color: #6fe3a088; }\n    .slot.ready .mark { background: #2fbf71; border-color: #b5f5cf; color: #05170d; }\n    .slot.cpu { cursor: default; }\n    .depart { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); z-index: 5; display: none;\n      appearance: none; cursor: pointer; border: 1px solid #ffe2a8; border-radius: 999px; padding: 14px 46px;\n      background: linear-gradient(180deg, #ffd27a, #ff9f1a); color: #1a1000; font: 900 24px/1 system-ui, sans-serif; font-style: italic;\n      letter-spacing: 0.16em; text-transform: uppercase; box-shadow: 0 0 0 0 #ffb84d88; }\n    .depart.show { display: block; animation: departIn 0.25s ease-out forwards, departPulse 1.6s ease-in-out 0.25s infinite; }\n    @keyframes departIn { to { transform: translate(-50%, -50%) scale(1); } }\n    @keyframes departPulse { 0%, 100% { box-shadow: 0 0 0 0 #ffb84d88; } 50% { box-shadow: 0 0 0 14px #ffb84d00; } }\n    /* /multiplayer hub, lobby list, lobby room */\n    .mp { flex-direction: column; gap: 14px; }\n    .mp-actions { display: flex; flex-direction: column; gap: 12px; width: min(380px, 60vw); margin-top: 18px; }\n    .mp-actions .btn { padding: 16px 20px; font-size: 18px; }\n    .list { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; touch-action: pan-y;\n      -webkit-overflow-scrolling: touch; padding-bottom: 6px; }\n    .lrow { display: flex; align-items: center; gap: 14px; padding: 10px 14px; border-radius: 12px; border: 1px solid #ffffff22;\n      background: linear-gradient(160deg, #ffffff12, #ffffff05); }\n    .lrow .who { flex: 1 1 auto; min-width: 0; }\n    .lrow b { display: block; font-size: 15px; font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n    .lrow small { display: block; font-size: 11px; color: #9fb4d9; letter-spacing: 0.08em; text-transform: uppercase; }\n    .lrow .count { font: 800 15px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; color: #cfe0ff; }\n    .lrow .btn { padding: 9px 16px; font-size: 14px; }\n    .empty-note { color: #9fb4d9; font-size: 13px; padding: 18px 4px; }\n    .col.vacant .slot { border-style: dashed; cursor: default; opacity: 0.55; }\n    .col.vacant .vehicle { opacity: 0.4; }\n    .slot .host { display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 6px; background: #ff9f1a; color: #05060a;\n      font: 800 9px/1.5 system-ui, sans-serif; letter-spacing: 0.1em; font-style: normal; vertical-align: middle; }\n    .slot.me { box-shadow: inset 0 0 0 1px #5aa2ff88; }\n    .slot small.link { color: #ffd27a; }\n    .slot small.link.open, .slot small.link.host { color: #9cf0b4; }\n    .slot small.link.failed { color: #ff9b9b; }\n    .opt[disabled] { opacity: 0.4; }\n    .room-note { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%); z-index: 4; padding: 10px 18px;\n      border-radius: 999px; background: #000a; color: #cfe0ff; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; display: none;\n      white-space: nowrap; }\n    .room-note.show { display: block; }\n    .campaign[disabled] { cursor: default; }\n    /* /profile and /settings */\n    .form { flex-direction: column; gap: 12px; }\n    .form-body { display: flex; flex-direction: column; gap: 10px; width: min(460px, 86vw); margin-top: 10px; }\n    .field { display: flex; flex-direction: column; gap: 6px; }\n    .field label { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: #9fb4d9; }\n    .field input[type=text] { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 12px;\n      padding: 12px 14px; font-size: 16px; outline: none; -webkit-user-select: text; user-select: text; }\n    .field input[type=password] { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 12px;\n      padding: 12px 14px; font-size: 16px; outline: none; -webkit-user-select: text; user-select: text; }\n    .field input[type=text]:focus, .field input[type=password]:focus { border-color: #5aa2ff; }\n    .pw-dialog .dialog-box { width: min(420px, 92vw); max-height: 94%; overflow-y: auto; gap: 7px; padding: 12px 16px; }\n    .pw-dialog .dialog-box b.title { font-size: 18px; }\n    .pw-dialog .field { gap: 4px; }\n    .pw-dialog .field input[type=password] { padding: 9px 12px; }\n    .pw-dialog .btn { padding-top: 10px; padding-bottom: 10px; }\n    .field .note { font-size: 12px; color: #8193b5; }\n    .mixer { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; touch-action: pan-y; padding-right: 4px; }\n    .mix { display: grid; grid-template-columns: 120px 1fr 52px; align-items: center; gap: 12px; padding: 6px 12px; border-radius: 12px;\n      background: linear-gradient(160deg, #ffffff12, #ffffff05); border: 1px solid #ffffff1c; }\n    .mix span { font: 700 13px/1.2 system-ui, sans-serif; }\n    .mix b { font: 800 14px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; text-align: right; color: #cfe0ff; }\n    .mix input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 28px; background: transparent; touch-action: none; }\n    .mix input[type=range]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: linear-gradient(90deg, #5aa2ff var(--fill, 50%), #1d2740 var(--fill, 50%)); }\n    .mix input[type=range]::-moz-range-track { height: 6px; border-radius: 3px; background: #1d2740; }\n    .mix input[type=range]::-moz-range-progress { height: 6px; border-radius: 3px; background: #5aa2ff; }\n    .mix input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; margin-top: -8px; border-radius: 50%;\n      background: #e8ecf4; border: 2px solid #5aa2ff; }\n    .mix input[type=range]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: #e8ecf4; border: 2px solid #5aa2ff; }\n    /* Create-lobby dialog */\n    .dialog { position: absolute; inset: 0; z-index: 26; display: none; align-items: center; justify-content: center; background: #05060ad9; }\n    .dialog.on { display: flex; }\n    .dialog-box { width: min(560px, 92vw); display: flex; flex-direction: column; gap: 10px; padding: 16px 18px; border-radius: 16px;\n      background: linear-gradient(160deg, #1b2a52, #0b1022); border: 1px solid #ffffff2a; }\n    .dialog-box b.title { font-size: 20px; font-weight: 900; font-style: italic; letter-spacing: 0.04em; }\n    .modes { display: flex; gap: 10px; }\n    .mode { flex: 1; appearance: none; cursor: pointer; text-align: left; border: 1px solid #ffffff2e; border-radius: 12px; padding: 10px 12px;\n      background: #ffffff0d; color: #e8ecf4; }\n    .mode b { display: block; font-size: 16px; font-style: italic; }\n    .mode small { display: block; font-size: 11px; color: #9fb4d9; line-height: 1.35; margin-top: 2px; }\n    .mode.on { border-color: #9cc6ff; background: linear-gradient(180deg, #6fb0ff55, #3d82e833); }\n    /* Arena lobby: two teams of four */\n    .arena { flex: 1 1 auto; min-height: 0; display: none; grid-template-columns: 1fr 1fr; gap: 12px; }\n    .arena.on { display: grid; }\n    .tside { display: flex; flex-direction: column; gap: 5px; min-width: 0; }\n    .tside-head { font: 900 13px/1 system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; font-style: italic; padding: 2px 4px; }\n    .tside.blue .tside-head { color: #7fb6ff; }\n    .tside.red .tside-head { color: #ff8f8f; }\n    .seat { appearance: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 40px;\n      padding: 5px 10px; border-radius: 10px; border: 1px solid #ffffff22; background: linear-gradient(160deg, #ffffff12, #ffffff05); color: #e8ecf4; text-align: left; }\n    .tside.blue .seat { border-left: 3px solid #5aa2ff; }\n    .tside.red .seat { border-left: 3px solid #ff5a5a; }\n    .seat b { font-size: 14px; font-style: italic; }\n    .seat small { font-size: 10px; color: #9fb4d9; letter-spacing: 0.08em; text-transform: uppercase; margin-left: 6px; }\n    .seat.empty { border-style: dashed; opacity: 0.6; }\n    .seat.me { box-shadow: inset 0 0 0 1px #5aa2ff88; }\n    .seat.asked { box-shadow: inset 0 0 0 1px #ffd27a; }\n    .seat .mark { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-weight: 900; font-size: 13px;\n      border: 1.5px solid #ffffff40; color: transparent; flex: 0 0 auto; }\n    .seat.ready .mark { background: #2fbf71; border-color: #b5f5cf; color: #05170d; }\n    .arena-bar { display: none; gap: 8px; align-items: center; flex-wrap: wrap; }\n    .arena-bar.on { display: flex; }\n    .arena-bar .opt { flex: 0 0 auto; }\n    .swap-ask { display: none; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; background: #ffd27a22; border: 1px solid #ffd27a88; }\n    .swap-ask.on { display: flex; }\n    .swap-ask span { flex: 1; font-size: 13px; font-weight: 700; }\n    .room-match { min-width: 0; }\n    /* Stage picker (the campaign selector) */\n    .picker { position: absolute; inset: 0; z-index: 25; display: none; flex-direction: column; gap: 12px;\n      padding: calc(14px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) calc(14px + env(safe-area-inset-bottom)) calc(20px + env(safe-area-inset-left));\n      background: #05060af2; }\n    .picker.on { display: flex; }\n    .picker-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n    .picker-head b { font-size: 22px; font-weight: 900; font-style: italic; letter-spacing: 0.04em; }\n    .picker-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n      gap: 8px; align-content: start; touch-action: pan-y; }\n    .stage { appearance: none; cursor: pointer; text-align: left; border: 1px solid #ffffff22; border-radius: 12px; padding: 10px 12px;\n      background: linear-gradient(160deg, #ffffff12, #ffffff05); color: #e8ecf4; }\n    .stage b { display: block; font-size: 15px; font-style: italic; }\n    .stage small { display: block; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: #9fb4d9; }\n    .stage.on { border-color: #9cc6ff; background: linear-gradient(180deg, #6fb0ff55, #3d82e833); }\n    .stage.full { grid-column: 1 / -1; }\n\n    .toast { position: absolute; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 30;\n      padding: 7px 16px; border-radius: 999px; background: #000c; color: #fff; font-size: 13px; font-weight: 700; opacity: 0;\n      transition: opacity 0.2s; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n\n    @media (max-height: 340px) { .btn { padding: 10px 14px; font-size: 14px; } .logo { font-size: 40px; } }\n    @media (orientation: portrait) {\n      .home, .route { flex-direction: column; justify-content: center; }\n      .actions, .route .right { flex: 0 0 auto; }\n    }\n  `,\n\n  // Render once; screens switch imperatively (the URL decides which one shows).\n  render() {\n    return `<div class=\"menu\" data-d2-static>\n      <div class=\"stars\"></div>\n      <div class=\"envbadge\">DEV</div>\n      <button class=\"fs-btn\" data-act=\"fullscreen\" aria-label=\"Fullscreen\" title=\"Fullscreen\" hidden>\n        <svg class=\"fs-enter\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/></svg>\n        <svg class=\"fs-exit\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/></svg>\n      </button>\n\n      <section class=\"screen home\" data-screen=\"home\">\n        <div class=\"brand\">\n          <div class=\"tag\">Project</div>\n          <div class=\"logo\">TFSF</div>\n          <div class=\"hint\">Tilt to steer · swipe up to somersault · swipe down to U-turn (all-range)</div>\n        </div>\n        <div class=\"actions main-list\">\n          <button class=\"btn primary\" data-nav=\"/solo\">Solo</button>\n          <button class=\"btn\" data-nav=\"/multiplayer\">Multiplayer</button>\n          <button class=\"btn\" data-nav=\"/profile\">Player profile</button>\n          <button class=\"btn\" data-nav=\"/settings\">Settings</button>\n          <button class=\"btn\" data-act=\"starfox64\">Star Fox 64 <small>dataFox2</small></button>\n          <button class=\"btn danger\" data-act=\"disconnect\">Disconnect</button>\n        </div>\n        <button class=\"snd-btn\" data-nav=\"/settings\" aria-label=\"Sound settings\" title=\"Sound settings\">\n          <svg class=\"snd-on\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9h4l5-4v14l-5-4H4z\"/><path d=\"M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10\"/></svg>\n          <svg class=\"snd-off\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9h4l5-4v14l-5-4H4z\"/><path d=\"M16 9l5 6M21 9l-5 6\"/></svg>\n        </button>\n      </section>\n\n      <section class=\"screen solo\" data-screen=\"solo\">\n        <div class=\"solo-head\">\n          <div class=\"crumb\">\n            <button class=\"back\" data-back=\"/\" aria-label=\"Hold to go back\">‹<svg viewBox=\"0 0 54 54\"><circle cx=\"27\" cy=\"27\" r=\"25\"></circle></svg></button>\n            <h1><span>/</span>Solo</h1>\n            <div class=\"back-hint\">Hold to go back</div>\n          </div>\n          <button class=\"campaign\" data-act=\"campaign\"><small>Campaign</small><b><span class=\"solo-campaign-name\">Andross' Attack</span> <i>▼</i></b></button>\n        </div>\n        <div class=\"bay\"></div>\n        <button class=\"depart solo-depart\">Depart</button>\n      </section>\n\n      <section class=\"screen mp\" data-screen=\"mp\">\n        <div class=\"solo-head\">${crumb('Multiplayer', '/')}</div>\n        <div class=\"mp-actions\">\n          <button class=\"btn primary\" data-act=\"create-lobby\">Create lobby <small>Host a session</small></button>\n          <button class=\"btn\" data-nav=\"/multiplayer/lobbies\">Browse lobbies <small>Join one</small></button>\n        </div>\n      </section>\n\n      <section class=\"screen mp\" data-screen=\"browse\">\n        <div class=\"solo-head\">${crumb('Lobbies', '/multiplayer')}\n          <button class=\"btn\" data-act=\"create-lobby\">Create lobby</button>\n        </div>\n        <div class=\"list\"></div>\n      </section>\n\n      <section class=\"screen solo\" data-screen=\"room\">\n        <div class=\"solo-head\">\n          ${crumb('Lobby', '/multiplayer/lobbies')}\n          <button class=\"campaign room-campaign\" data-act=\"campaign\"><small class=\"room-campaign-kind\">Campaign</small><b><span class=\"room-campaign-name\"></span> <i>▼</i></b></button>\n          <button class=\"campaign room-match\" data-act=\"match\" hidden><small>Match</small><b><span class=\"room-match-name\"></span> <i>▶</i></b></button>\n        </div>\n        <div class=\"bay room-bay\"></div>\n        <div class=\"swap-ask\"><span></span><button class=\"opt go\" data-swap=\"yes\">Swap</button><button class=\"opt\" data-swap=\"no\">No</button></div>\n        <div class=\"arena\"></div>\n        <div class=\"arena-bar\"></div>\n        <button class=\"depart room-depart\">Depart</button>\n        <div class=\"room-note\"></div>\n      </section>\n\n      <section class=\"screen stub\" data-screen=\"stub\">\n        <div class=\"kicker\">Coming soon</div>\n        <div class=\"headline stub-title\"></div>\n        <button class=\"btn\" data-go=\"home\">Main menu</button>\n      </section>\n\n      <section class=\"screen form\" data-screen=\"profile\">\n        <div class=\"solo-head\">${crumb('Player profile', '/')}</div>\n        <div class=\"form-body\">\n          <div class=\"field\">\n            <label for=\"nick\">Nickname</label>\n            <input id=\"nick\" class=\"nick\" type=\"text\" maxlength=\"16\" autocomplete=\"nickname\" placeholder=\"Your name in lobbies and on the radio\">\n            <div class=\"note\">Shown to the other pilots in lobbies and on the radio. Empty: your email name.</div>\n          </div>\n          <div class=\"row\"><button class=\"btn primary\" data-act=\"save-nick\">Save</button></div>\n          <div class=\"field\">\n            <label>Password</label>\n            <div class=\"row\"><button class=\"btn\" data-act=\"pw-open\">Change password</button></div>\n          </div>\n        </div>\n      </section>\n\n      <section class=\"screen form\" data-screen=\"settings\">\n        <div class=\"solo-head\">${crumb('Settings', '/')}</div>\n        <div class=\"form-body mixer\"></div>\n      </section>\n\n      <section class=\"screen route\" data-screen=\"map\">\n        <div class=\"left\">\n          <div class=\"kicker map-kicker\"></div>\n          <div class=\"headline good map-title\"></div>\n          <div class=\"stats map-stats\"></div>\n          <div class=\"team map-team\"></div>\n          <div class=\"path map-path\"></div>\n        </div>\n        <div class=\"right map-actions\"></div>\n      </section>\n\n      <section class=\"screen route\" data-screen=\"results\">\n        <div class=\"left\">\n          <div class=\"kicker res-kicker\"></div>\n          <div class=\"headline res-title\"></div>\n          <div class=\"stats res-stats\"></div>\n          <div class=\"path res-path\"></div>\n        </div>\n        <div class=\"right\">\n          <button class=\"btn primary\" data-go=\"home\">Main menu</button>\n        </div>\n      </section>\n\n      <section class=\"screen title\" data-screen=\"title\">\n        <div class=\"brand\">\n          <div class=\"tag\">Project</div>\n          <div class=\"logo\">TFSF</div>\n        </div>\n        <div class=\"tap\">Tap anywhere to continue</div>\n        <div class=\"perm\"></div>\n      </section>\n\n      <section class=\"screen login\" data-screen=\"login\">\n        <div class=\"brand\">\n          <div class=\"tag\">Project</div>\n          <div class=\"logo\">TFSF</div>\n        </div>\n        <form autocomplete=\"on\">\n          <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\n          <input name=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n          <button class=\"btn primary signin\" type=\"submit\">Sign in</button>\n          <div class=\"err login-err\"></div>\n        </form>\n      </section>\n\n      <div class=\"dialog pw-dialog\">\n        <form class=\"dialog-box pw-form\" novalidate>\n          <b class=\"title\">Change password</b>\n          <div class=\"field\"><label for=\"pw-cur\">Current password</label>\n            <input id=\"pw-cur\" name=\"current\" type=\"password\" autocomplete=\"current-password\"></div>\n          <div class=\"field\"><label for=\"pw-new\">New password</label>\n            <input id=\"pw-new\" name=\"next\" type=\"password\" autocomplete=\"new-password\"></div>\n          <div class=\"field\"><label for=\"pw-confirm\">Confirm new password</label>\n            <input id=\"pw-confirm\" name=\"confirm\" type=\"password\" autocomplete=\"new-password\"></div>\n          <div class=\"err pw-err\"></div>\n          <div class=\"row\"><button class=\"btn\" type=\"button\" data-act=\"pw-cancel\">Cancel</button><button class=\"btn primary pw-go\" type=\"submit\">Save</button></div>\n        </form>\n      </div>\n\n      <div class=\"dialog create-dialog\">\n        <div class=\"dialog-box\">\n          <b class=\"title\">Create lobby</b>\n          <div class=\"field\"><label for=\"lobby-name\">Lobby name</label>\n            <input id=\"lobby-name\" class=\"lobby-name\" type=\"text\" maxlength=\"24\" autocomplete=\"off\"></div>\n          <div class=\"modes\">\n            <button class=\"mode on\" data-mode=\"adventure\"><b>Adventure</b><small>Up to 4 pilots fly the campaign or a stage together.</small></button>\n            <button class=\"mode\" data-mode=\"arena\"><b>Arena</b><small>Up to 8 pilots, two teams of 4: versus battles.</small></button>\n          </div>\n          <div class=\"row\"><button class=\"btn\" data-act=\"create-cancel\">Cancel</button><button class=\"btn primary\" data-act=\"create-go\">Create</button></div>\n        </div>\n      </div>\n\n      <div class=\"picker\">\n        <div class=\"picker-head\"><b>Choose a stage</b><button class=\"btn\" data-act=\"picker-close\">Close</button></div>\n        <div class=\"picker-grid\"></div>\n      </div>\n\n      <div class=\"busy\">Loading…</div>\n      <div class=\"toast\"></div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const $$ = (s) => Array.from(root.querySelectorAll(s))\n    const rt = window[RT_KEY] || (window[RT_KEY] = {})\n\n    // ---- fullscreen toggle (the whole document, so it carries into the game) ------------------\n    const fsBtn = root.querySelector('.fs-btn')\n    const fsOn = () => !!(document.fullscreenElement || document.webkitFullscreenElement)\n    const paintFs = () => { fsBtn.classList.toggle('on', fsOn()); fsBtn.setAttribute('aria-label', fsOn() ? 'Exit fullscreen' : 'Fullscreen') }\n    if (document.fullscreenEnabled || document.webkitFullscreenEnabled) {\n      fsBtn.hidden = false\n      root.querySelector('.menu').classList.add('has-fs')\n      paintFs()\n      document.addEventListener('fullscreenchange', paintFs)\n      document.addEventListener('webkitfullscreenchange', paintFs)\n      d2.onUnmount(() => { document.removeEventListener('fullscreenchange', paintFs); document.removeEventListener('webkitfullscreenchange', paintFs) })\n    }\n    const toggleFullscreen = async () => {\n      const el = document.documentElement\n      try {\n        if (fsOn()) await (document.exitFullscreen ? document.exitFullscreen() : document.webkitExitFullscreen())\n        else {\n          await (el.requestFullscreen ? el.requestFullscreen({ navigationUI: 'hide' }) : el.webkitRequestFullscreen())\n          try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n        }\n      } catch (e) {}\n    }\n\n    // ---- navigation (same helper as the player) --------------------------------------------\n    const routerRef = String(d2.param('routerRef', '') || '')\n    const nav = (to) => {\n      try {\n        const r = routerRef ? d2.router(routerRef) : d2.router()\n        if (r && typeof r.goTo === 'function') { r.goTo(to); return }\n      } catch (e) {}\n      try { history.pushState(null, '', to); window.dispatchEvent(new PopStateEvent('popstate', { state: null })) } catch (e) { location.href = to }\n    }\n\n    // ---- session ---------------------------------------------------------------------------\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const sessionKey = `__data2-session-${systemDomain}`\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(sessionKey)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    // Signed in = the session may read the keys DataSet (the same check the player's download does).\n    const checkSession = async () => {\n      try {\n        const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n        if (res.status === 401 || res.status === 403) return false\n        if (!res.ok) return true // a server hiccup is not a sign-out; the player will say more\n        const body = await res.json()\n        const rows = Array.isArray(body) ? body : (body && body.data) || []\n        return !!(rows[0] && rows[0].key)\n      } catch (e) { return true }\n    }\n\n    // ---- the tap into the game: sound, motion, fullscreen (must run inside the gesture) -------\n    // With the radio's microphone open the phone stays in play-and-record: switching the session back\n    // and forth (on every tap) makes iOS rebuild its audio each time, and the game stutters.\n    const micLive = () => !!(rt.micPipe && rt.micPipe.stream.getAudioTracks().some((t) => t.readyState === 'live'))\n    const unlockAudio = () => {\n      try { if (navigator.audioSession) navigator.audioSession.type = micLive() ? 'play-and-record' : 'playback' } catch (e) {}\n      try {\n        const live = (rt.engine && rt.engine.SDL2 && rt.engine.SDL2.audioContext) || rt.audio\n        if (live && live.state !== 'closed') { live.resume(); return }\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx) return\n        let ac\n        try { ac = new Ctx({ sampleRate: 32000 }) } catch (e) { ac = new Ctx() } // the game mixes at 32 kHz\n        ac.resume()\n        const src = ac.createBufferSource()\n        src.buffer = ac.createBuffer(1, 1, 22050)\n        src.connect(ac.destination)\n        src.start(0)\n        rt.audio = ac\n      } catch (e) {}\n    }\n    const askMotion = () => {\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') { try { Ev.requestPermission().catch(() => {}) } catch (e) {} }\n      }\n    }\n    const fullscreen = () => {\n      const el = document.documentElement\n      try { if (el.requestFullscreen && !document.fullscreenElement) el.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}) } catch (e) {}\n      try { screen.orientation?.lock?.('landscape')?.catch?.(() => {}) } catch (e) {}\n      try { navigator.wakeLock?.request?.('screen')?.catch?.(() => {}) } catch (e) {}\n    }\n    // Motion (gyro) and sound can only be granted inside a tap, so every tap in the menus asks: by\n    // the time a pilot departs it has long been granted (it lasts as long as the page).\n    d2.on(root, 'pointerup', () => { if (current === 'title') return; askMotion(); unlockAudio() }) // the title asks itself\n    // iOS: no text selection, magnifier or callout anywhere on the page (a long press is a hold, not a\n    // selection), except in text fields. touch-callout/user-select alone do not stop Safari's loupe on a\n    // long press: cancelling the touch does.\n    try {\n      if (!document.getElementById('tfsf-noselect')) {\n        const st = document.createElement('style')\n        st.id = 'tfsf-noselect'\n        st.textContent = 'html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }'\n        document.head.appendChild(st)\n      }\n    } catch (e) {}\n    const noLoupe = (e) => {\n      const path = e.composedPath ? e.composedPath() : []\n      if (path.some((n) => n && n.tagName && /^(INPUT|TEXTAREA|SELECT)$/.test(n.tagName))) return\n      if (path.some((n) => n && n.classList && (n.classList.contains('back') || n.classList.contains('talk')))) e.preventDefault()\n    }\n    root.addEventListener('touchstart', noLoupe, { passive: false })\n    d2.onUnmount(() => root.removeEventListener('touchstart', noLoupe))\n    d2.on(root, 'selectstart', (e) => { if (!/^(INPUT|TEXTAREA)$/.test((e.target && e.target.tagName) || '')) e.preventDefault() })\n    const play = (query, tapped = true) => {\n      unlockAudio()\n      askMotion()\n      fullscreen()\n      // A multiplayer run (rt.mp, set at the lobby's departure) stays multiplayer for every later\n      // stage launched from /map; Solo and \"End this run\" end it.\n      if (rt.mp && !/(^|&)net=1/.test(query)) query += '&net=1'\n      rt.tappedAt = tapped ? Date.now() : 0 // a tap here: the player need not ask for one\n      nav(`/play?${query}`)\n    }\n\n    // ---- small helpers -----------------------------------------------------------------------\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1600)\n    }\n    const CONTROLS_KEY = `${STORE}.controls`\n    const controlsId = () => (CONTROLS.some((c) => c.id === readKey(CONTROLS_KEY)) ? readKey(CONTROLS_KEY) : 'motion')\n    const controlsLabel = () => CONTROLS.find((c) => c.id === controlsId()).label\n\n    // ---- screens ---------------------------------------------------------------------------\n    const lastEvent = () => {\n      if (rt.lastEvent) return rt.lastEvent\n      try { return JSON.parse(sessionStorage.getItem(`${STORE}.lastEvent`) || 'null') } catch (e) { return null }\n    }\n    // The engine keeps the run's state only while the page lives: after a reload it is gone.\n    const engineAlive = () => !!(rt.engine && !rt.crashed)\n\n    const show = (name) => {\n      $$('.screen').forEach((s) => s.classList.toggle('on', s.dataset.screen === name))\n      if (onScreen) onScreen(name)\n    }\n    let onScreen = null // the menu music (below): it plays on every screen after the title\n\n    const pathHtml = (ev, nextPlanet) => {\n      const route = ev.route || []\n      const hits = ev.routeHits || []\n      const medals = ev.routeMedals || []\n      const done = Math.max(0, Number(ev.mission) || 0)\n      const parts = []\n      for (let i = 0; i < route.length; i++) {\n        const p = route[i]\n        if (p < 0 || p == null) continue\n        if (i === 6 && p === 13 && i > done) continue // the reserved Venom slot, not reached yet\n        const isNext = i === done && nextPlanet != null\n        const cls = isNext ? 'next' : i < done ? 'done' : ''\n        const extra = i < done ? `<em>${hits[i] || 0}${medals[i] ? ' ★' : ''}</em>` : ''\n        parts.push(`<span class=\"p ${cls}\">${esc(planetName(isNext ? nextPlanet : p))}${extra}</span>`)\n      }\n      return parts.join('<span>›</span>')\n    }\n\n    const stat = (value, label) => `<div class=\"stat\"><b>${esc(value)}</b><span>${esc(label)}</span></div>`\n\n    const paintMap = () => {\n      const ev = lastEvent()\n      const actions = $('.map-actions')\n      if (!ev || ev.event !== 'level-end' || !engineAlive()) {\n        $('.map-kicker').textContent = 'Route'\n        $('.map-title').textContent = 'No run in progress'\n        $('.map-title').className = 'headline map-title'\n        $('.map-stats').innerHTML = ''\n        $('.map-team').innerHTML = ''\n        $('.map-path').innerHTML = ev && ev.route ? pathHtml(ev, null) : ''\n        actions.innerHTML = `<div class=\"hint\">The game keeps a run only while this page stays open.</div>\n          <button class=\"btn primary\" data-go=\"home\">Main menu</button>`\n        return\n      }\n      $('.map-kicker').textContent = planetName(ev.planet)\n      $('.map-title').textContent = STATUS[ev.status] || 'Mission over'\n      $('.map-title').className = 'headline good map-title'\n      $('.map-stats').innerHTML = [stat(ev.hits, 'Hits'), stat(ev.totalHits, 'Total hits'), stat(ev.lives, 'Lives'),\n        ev.medal ? stat('★', 'Medal') : ''].join('')\n      const names = ['Falco', 'Slippy', 'Peppy']\n      $('.map-team').innerHTML = names.map((n, i) => `<span class=\"mate ${ev.team && ev.team[i] ? '' : 'down'}\">${n}</span>`).join('')\n      $('.map-path').innerHTML = pathHtml(ev, ev.next)\n      const buttons = [`<button class=\"btn primary\" data-play=\"cmd=continue\">Fly to ${esc(planetName(ev.next))} <small>Continue</small></button>`]\n      if (ev.alt != null && ev.alt >= 0) buttons.push(`<button class=\"btn\" data-play=\"cmd=alt\">Fly to ${esc(planetName(ev.alt))} <small>Other route</small></button>`)\n      if (ev.lives > 0) buttons.push(`<button class=\"btn\" data-play=\"cmd=retry\">Retry ${esc(planetName(ev.planet))} <small>−1 life</small></button>`)\n      buttons.push(`<button class=\"btn danger\" data-act=\"abandon\">End this run</button>`)\n      actions.innerHTML = buttons.join('')\n    }\n\n    const paintResults = () => {\n      const ev = lastEvent() || {}\n      const ending = ev.event === 'ending'\n      $('.res-kicker').textContent = ending ? 'Lylat system saved' : 'Mission failed'\n      $('.res-title').textContent = ending ? 'The End' : 'Game Over'\n      $('.res-title').className = `headline ${ending ? 'good' : 'bad'} res-title`\n      $('.res-stats').innerHTML = [stat(ev.totalHits || 0, 'Total hits'), stat((Number(ev.mission) || 0) + (ending ? 1 : 0), 'Planets'),\n        ev.expert ? stat('Expert', 'Mode') : ''].join('')\n      $('.res-path').innerHTML = ev.route ? pathHtml({ ...ev, mission: (Number(ev.mission) || 0) + 1 }, null) : ''\n    }\n\n    // ---- /solo: the team lobby -------------------------------------------------------------------\n    // Ready state lives only while /solo is open: every visit starts with the player not ready.\n    const lobby = { ready: TEAM.map((m) => m.cpu) }\n    const paintSolo = () => {\n      $('.bay').innerHTML = TEAM.map((m, i) => {\n        const ready = lobby.ready[i]\n        const open = !m.cpu && !ready\n        return `<div class=\"col ${open ? 'open' : ''}\" data-key=\"${i}\">\n          <div class=\"vehicle\">Vehicle</div>\n          <div class=\"opts\">\n            <button class=\"opt\" data-opt=\"pilot\" data-i=\"${i}\">Pilot <small>${esc(m.pilot)}</small></button>\n            <button class=\"opt\" data-opt=\"controls\" data-i=\"${i}\">Controls <small>${esc(controlsLabel())}</small></button>\n            <button class=\"opt go\" data-opt=\"ready\" data-i=\"${i}\">Ready <span>✓</span></button>\n          </div>\n          <button class=\"slot ${ready ? 'ready' : ''} ${m.cpu ? 'cpu' : ''}\" data-slot=\"${i}\">\n            <span><b>${esc(m.slot)}</b><small>${esc(m.pilot)}${m.cpu ? ' · CPU' : ''}</small></span>\n            <span class=\"mark\">✓</span>\n          </button>\n        </div>`\n      }).join('')\n      $('.solo-depart').classList.toggle('show', lobby.ready.every(Boolean))\n    }\n    d2.on('[data-opt]', 'click', (e, el) => {\n      const i = Number(el.dataset.i)\n      const opt = el.dataset.opt\n      if (opt === 'pilot') return toast('Pilot selection is coming soon')\n      if (opt === 'controls') {\n        const next = CONTROLS[(CONTROLS.findIndex((c) => c.id === controlsId()) + 1) % CONTROLS.length]\n        try { localStorage.setItem(CONTROLS_KEY, next.id) } catch (err) {}\n        toast(`Controls: ${next.label}`)\n        return paintSolo()\n      }\n      if (opt === 'ready') { lobby.ready[i] = true; paintSolo() }\n    })\n    // Tapping your own ready slot takes it back (the options come back with it).\n    d2.on('[data-slot]', 'click', (e, el) => {\n      const i = Number(el.dataset.slot)\n      if (TEAM[i].cpu || !lobby.ready[i]) return\n      lobby.ready[i] = false\n      paintSolo()\n    })\n    d2.on('.solo-depart', 'click', () => {\n      if (!lobby.ready.every(Boolean)) return\n      rt.mp = null\n      const st = stageCmd(soloStage())\n      play(st.cmd === 'level' ? `cmd=level&level=${st.level}` : 'cmd=new&expert=0')\n    })\n\n    // ---- the campaign selector: a stage picker (solo, and the host of a lobby) ------------------------\n    const STAGE_KEY = `${STORE}.soloStage`\n    const soloStage = () => { const v = Number(readKey(STAGE_KEY)); return STAGES.some((st) => st.id === v) && readKey(STAGE_KEY) !== '' ? v : -1 }\n    const paintSoloCampaign = () => { $('.solo-campaign-name').textContent = stageOf(soloStage()).name }\n    const picker = { for: '' }\n    const openPicker = (forWhat, current) => {\n      picker.for = forWhat\n      $('.picker-grid').innerHTML = STAGES.map((st) => `<button class=\"stage ${st.id === current ? 'on' : ''} ${st.id < 0 ? 'full' : ''}\" data-stage=\"${st.id}\">\n        <b>${esc(st.name)}</b><small>${esc(st.kind)}</small></button>`).join('')\n      $('.picker').classList.add('on')\n    }\n    const closePicker = () => { $('.picker').classList.remove('on'); picker.for = '' }\n    // Arena: the versus maps (the host's pick, on the lobby row), and the match type (cycled).\n    const openArenaPicker = () => {\n      picker.for = 'arena'\n      const cur = Number(room.lobby && room.lobby.vsStage) || 0\n      $('.picker-grid').innerHTML = VS_STAGES.map((st) => `<button class=\"stage ${st.id === cur ? 'on' : ''}\" data-vsstage=\"${st.id}\"><b>${esc(st.name)}</b><small>Versus map</small></button>`).join('')\n      $('.picker').classList.add('on')\n    }\n    d2.on('[data-vsstage]', 'click', async (e, el) => {\n      closePicker()\n      if (!amHost() || !room.lobby) return\n      const vsStage = Number(el.dataset.vsstage) || 0\n      room.lobby = { ...room.lobby, vsStage, campaign: VS_STAGES[vsStage].name }\n      paintRoom()\n      try { await patch(lobbiesDs(), { _id: room.id, vsStage, campaign: VS_STAGES[vsStage].name }); announce('stage') } catch (err) { toast('Could not change the map') }\n    })\n    const cycleMatch = async () => {\n      const vsMatch = ((Number(room.lobby.vsMatch) || 0) + 1) % VS_MATCHES.length\n      room.lobby = { ...room.lobby, vsMatch }\n      paintRoom()\n      try { await patch(lobbiesDs(), { _id: room.id, vsMatch }); announce('stage') } catch (err) { toast('Could not change the match') }\n    }\n    d2.on('[data-stage]', 'click', (e, el) => {\n      const id = Number(el.dataset.stage)\n      const target = picker.for\n      closePicker()\n      if (target === 'solo') {\n        try { localStorage.setItem(STAGE_KEY, String(id)) } catch (err) {}\n        paintSoloCampaign()\n      } else if (target === 'room') {\n        pickRoomStage(id)\n      }\n    })\n\n    // Back buttons (every breadcrumb): hold for HOLD_BACK_MS (the ring fills) to go to data-back;\n    // a short tap only shows the hint.\n    const hold = { el: null, t0: 0, raf: 0 }\n    const stopHold = () => {\n      cancelAnimationFrame(hold.raf)\n      if (hold.el) {\n        hold.el.classList.remove('holding')\n        hold.el.querySelector('circle').style.strokeDashoffset = '157'\n      }\n      hold.el = null\n    }\n    const tickHold = () => {\n      const el = hold.el\n      if (!el) return\n      const f = Math.min(1, (performance.now() - hold.t0) / HOLD_BACK_MS)\n      el.querySelector('circle').style.strokeDashoffset = String(157 * (1 - f))\n      if (f >= 1) {\n        const to = el.dataset.back || '/'\n        stopHold()\n        try { navigator.vibrate && navigator.vibrate(20) } catch (e) {}\n        return goBack(to)\n      }\n      hold.raf = requestAnimationFrame(tickHold)\n    }\n    d2.on('.back', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      stopHold()\n      try { el.setPointerCapture(e.pointerId) } catch (err) {}\n      hold.el = el\n      hold.t0 = performance.now()\n      el.classList.add('holding')\n      const hint = el.parentElement.querySelector('.back-hint')\n      if (hint) {\n        hint.classList.add('show')\n        clearTimeout(hint._t)\n        hint._t = setTimeout(() => hint.classList.remove('show'), 2000)\n      }\n      hold.raf = requestAnimationFrame(tickHold)\n    })\n    for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) d2.on('.back', ev, stopHold)\n    d2.on('.back', 'contextmenu', (e) => e.preventDefault())\n    d2.onUnmount(() => cancelAnimationFrame(hold.raf))\n\n    // ---- multiplayer: who am I ---------------------------------------------------------------------\n    // The login form remembers the email; older sessions fall back to the auth handle, then the token.\n    const ME_KEY = `${STORE}.me`\n    const me = () => {\n      let email = readKey(ME_KEY)\n      if (!email) { try { const u = d2.auth(d2.param('auth'))?.user; email = (u && (u.email || (u.credentials && u.credentials.email))) || '' } catch (e) {} }\n      if (!email) {\n        try {\n          const part = readKey(sessionKey).split('.')[1]\n          const claims = part ? JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) : {}\n          email = claims.email || (claims.credentials && claims.credentials.email) || ''\n        } catch (e) {}\n      }\n      if (!email) {\n        // Unknown session: a stable guest id for this browser, so a lobby still knows who is who.\n        email = readKey(`${STORE}.guest`) || `guest-${Math.random().toString(36).slice(2, 8)}`\n        try { localStorage.setItem(`${STORE}.guest`, email) } catch (e) {}\n      }\n      return email\n    }\n    const nameOf = (email) => String(email || '').split('@')[0] || 'Pilot'\n    // The nickname from /profile (this device), else the email name: what the other pilots see.\n    const NICK_KEY = `${STORE}.nick`\n    const myName = () => readKey(NICK_KEY).trim() || nameOf(me())\n    // The lobby this browser is seated in, so reopening the app can bring the pilot back to it.\n    const ROOM_KEY = `${STORE}.room`\n    const seat = (id) => { try { if (id) localStorage.setItem(ROOM_KEY, id); else localStorage.removeItem(ROOM_KEY) } catch (e) {} }\n\n    // ---- multiplayer: data -------------------------------------------------------------------------\n    const lobbiesDs = () => d2.data('lobbies')\n    const playersDs = () => d2.data('lobbyPlayers')\n    const rows = (r) => (Array.isArray(r) ? r : (r && r.data) || [])\n    const find = async (ds, q) => rows(await ds.query(q))\n    // Partial update: only the given fields change (the DataSet's update() refuses with 409 here).\n    const patch = (ds, rec) => ds.raw.saveRecord(rec)\n    // The server's clock, as seen from here: skew = server time - local time, measured on our own\n    // writes (the row comes back stamped by the server) and remembered for the next screen.\n    const SKEW_KEY = `${STORE}.clockSkew`\n    const clock = { skew: Number(readKey(SKEW_KEY)) || 0 }\n    const serverNow = () => Date.now() + clock.skew\n    // A row not stamped yet (just created, the server's copy not back) counts as written now.\n    const stampOf = (p) => Date.parse((p && (p.updatedAt || p.createdAt)) || '') || serverNow()\n    const learnSkew = (rec, sentAt) => {\n      const at = Date.parse((rec && rec.updatedAt) || '')\n      if (!at) return\n      clock.skew = Math.round(at - (sentAt + Date.now()) / 2)\n      try { localStorage.setItem(SKEW_KEY, String(clock.skew)) } catch (e) {}\n    }\n    const live = (p, now = serverNow()) => stampOf(p) > now - LIVE_MS\n    const joinedOf = (p) => Date.parse((p && p.createdAt) || '') || Number.MAX_SAFE_INTEGER\n    const byJoin = (a, b) => joinedOf(a) - joinedOf(b) || String(a._id).localeCompare(String(b._id))\n    // Heartbeat: touch our own row; the server stamps it (updatedAt), which is what the others read.\n    const touch = async (mine, fields = {}) => {\n      const sentAt = Date.now()\n      const rec = await patch(playersDs(), { _id: mine._id, seen: sentAt, ...fields })\n      learnSkew(rec, sentAt)\n      return rec\n    }\n    const newCode = () => Array.from({ length: 4 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ'[Math.floor(Math.random() * 23)]).join('')\n\n    let busy = false\n    // Create lobby: a name and a mode first (the dialog), then the rows.\n    const create = { mode: 'adventure' }\n    const openCreate = () => {\n      create.mode = 'adventure'\n      $$('[data-mode]').forEach((b) => b.classList.toggle('on', b.dataset.mode === create.mode))\n      $('.lobby-name').value = `${myName()}'s lobby`\n      $('.create-dialog').classList.add('on')\n    }\n    const closeCreate = () => $('.create-dialog').classList.remove('on')\n    d2.on('[data-mode]', 'click', (e, el) => {\n      create.mode = el.dataset.mode\n      $$('[data-mode]').forEach((b) => b.classList.toggle('on', b.dataset.mode === create.mode))\n    })\n    const createLobby = async () => {\n      if (busy) return\n      busy = true\n      try {\n        const email = me()\n        const now = Date.now()\n        const mode = MODES[create.mode] ? create.mode : 'adventure'\n        const name = String($('.lobby-name').value || '').replace(/\\s+/g, ' ').trim().slice(0, 24) || `${myName()}'s lobby`\n        closeCreate()\n        const lobby = await lobbiesDs().create({\n          code: newCode(), host: email, hostName: myName(), name, mode, maxPlayers: MODES[mode].max,\n          campaign: mode === 'arena' ? VS_STAGES[0].name : DEFAULT_CAMPAIGN, vsStage: 0, vsMatch: 0,\n          status: 'open', players: [{ email, name: myName() }],\n        })\n        if (!lobby || !lobby._id) throw new Error('no lobby id')\n        await playersDs().create({ lobby: String(lobby._id), email, name: myName(), pilot: 'fox', ready: false, seen: now, joinedAt: now,\n          ...(mode === 'arena' ? { team: 'blue', seat: 0 } : {}) })\n        nav(`/multiplayer/lobbies/${lobby._id}`)\n      } catch (e) {\n        toast('Could not create the lobby')\n        console.warn('[TFSF DEV] create lobby', e)\n      } finally {\n        busy = false\n      }\n    }\n\n    // ---- /multiplayer/lobbies: the open lobbies, with their live pilot count ------------------------\n    const browse = { timer: 0 }\n    const paintBrowse = async () => {\n      const list = $('.list')\n      try {\n        const lobbies = await find(lobbiesDs(), { status: 'open', $sort: { createdAt: -1 }, $limit: 40 })\n        const ids = lobbies.map((l) => String(l._id))\n        const now = serverNow()\n        const pilots = ids.length ? await find(playersDs(), { lobby: { $in: ids }, $limit: 200 }) : []\n        const count = {}\n        pilots.filter((p) => live(p, now)).forEach((p) => { count[p.lobby] = (count[p.lobby] || 0) + 1 })\n        const open = lobbies.filter((l) => count[String(l._id)])\n        if (current !== 'browse') return\n        list.innerHTML = open.length\n          ? open.map((l) => {\n            const n = count[String(l._id)]\n            const full = n >= (Number(l.maxPlayers) || MAX_PLAYERS)\n            return `<div class=\"lrow\" data-key=\"${esc(l._id)}\">\n              <div class=\"who\"><b>${esc(l.name || `${l.hostName || nameOf(l.host)}'s lobby`)}</b><small>${esc((MODES[l.mode] || MODES.adventure).name)} · ${esc(l.code || '')} · ${esc(l.mode === 'arena' ? `${(VS_STAGES[l.vsStage] || VS_STAGES[0]).name} · ${(VS_MATCHES[l.vsMatch] || VS_MATCHES[0]).name}` : l.campaign || DEFAULT_CAMPAIGN)}</small></div>\n              <div class=\"count\">${n}/${Number(l.maxPlayers) || MAX_PLAYERS}</div>\n              <button class=\"btn ${full ? '' : 'primary'}\" data-join=\"${esc(l._id)}\" ${full ? 'disabled' : ''}>${full ? 'Full' : 'Join'}</button>\n            </div>`\n          }).join('')\n          : '<div class=\"empty-note\">No open lobbies right now. Create one and invite your wingmen.</div>'\n      } catch (e) {\n        if (current === 'browse') list.innerHTML = '<div class=\"empty-note\">Could not load lobbies. Retrying…</div>'\n      }\n    }\n    const startBrowse = () => {\n      $('.list').innerHTML = '<div class=\"empty-note\">Scanning for lobbies…</div>'\n      paintBrowse()\n      clearInterval(browse.timer)\n      browse.timer = setInterval(paintBrowse, 3000)\n    }\n    const stopBrowse = () => clearInterval(browse.timer)\n    d2.onUnmount(stopBrowse)\n    d2.on('[data-join]', 'click', (e, el) => nav(`/multiplayer/lobbies/${el.dataset.join}`))\n\n    // ---- /multiplayer/lobbies/:_id: the room --------------------------------------------------------\n    const room = { id: '', lobby: null, pilots: [], mine: null, timer: 0, joining: false, gone: '' }\n    const amHost = () => !!(room.lobby && room.lobby.host === me())\n    const livePilots = () => { const now = serverNow(); return room.pilots.filter((p) => live(p, now)).sort(byJoin) }\n\n    // ---- lobby socket: instant refresh ------------------------------------------------------------\n    // One room per lobby. Every write is followed by a ping; on a ping the others re-read the tables\n    // (the rows are the truth, the ping only says \"look now\"). Polling keeps working without it.\n    const sockRoom = (id) => `tfsf-lobby-${id}`\n    const sock = () => { try { return d2.socket('socket') } catch (e) { return null } }\n    const announce = (kind) => {\n      const s = sock()\n      if (!s || !room.id) return\n      try { Promise.resolve(s.emit({ lobby: room.id, kind, from: me() }, sockRoom(room.id))).catch(() => {}) } catch (e) {}\n    }\n    let soonTimer = 0\n    const refreshSoon = () => { clearTimeout(soonTimer); soonTimer = setTimeout(() => { beat(); signal() }, 120) }\n    try {\n      const s = sock()\n      if (s && typeof s.onMessage === 'function') {\n        const off = s.onMessage((ev) => {\n          let msg = ev && ev.data\n          try { if (typeof msg === 'string') msg = JSON.parse(msg) } catch (e) { return }\n          if (!msg || current !== 'room' || msg.lobby !== room.id || msg.from === me()) return\n          refreshSoon()\n        })\n        if (typeof off === 'function') d2.onUnmount(off)\n      }\n    } catch (e) {}\n    const joinSock = (id) => { const s = sock(); if (s) { try { Promise.resolve(s.subscribe(sockRoom(id))).catch(() => {}) } catch (e) {} } }\n    const leaveSock = (id) => { const s = sock(); if (s && id) { try { Promise.resolve(s.unsubscribe(sockRoom(id))).catch(() => {}) } catch (e) {} } }\n\n    // ---- pilots -------------------------------------------------------------------------------------\n    // Each pilot is taken by at most one player; on a clash the later joiner moves to a free one.\n    const takenPilots = (exceptEmail) => livePilots().filter((p) => p.email !== exceptEmail).map((p) => p.pilot).filter(Boolean)\n    const freePilot = (exceptEmail) => { const taken = takenPilots(exceptEmail); return (PILOTS.find((p) => !taken.includes(p.id)) || PILOTS[0]).id }\n    const fixPilotClash = async () => {\n      const mine = room.mine\n      if (!mine) return\n      const earlier = livePilots().filter((p) => p.email !== mine.email && byJoin(p, mine) < 0)\n      if (!mine.pilot || earlier.some((p) => p.pilot === mine.pilot)) {\n        const taken = earlier.map((p) => p.pilot)\n        const pick = (PILOTS.find((p) => !taken.includes(p.id)) || PILOTS[0]).id\n        if (pick !== mine.pilot) {\n          mine.pilot = pick\n          await touch(mine, { pilot: pick })\n          announce('pilot')\n        }\n      }\n    }\n\n    // ---- links (WebRTC to the host) -------------------------------------------------------------------\n    const net = rt.net || (rt.net = makeNet())\n    const signalsDs = () => d2.data('signals')\n    const sid = () => Math.random().toString(36).slice(2, 10)\n    // Guest: dial the host (one offer row per attempt).\n    const dialHost = async () => {\n      const host = net.host\n      const s = sid()\n      const peer = net.newPeer(host, s)\n      peer.dialing = true\n      net.wire(peer, peer.pc.createDataChannel('u', { ordered: false, maxRetransmits: 0 }), false)\n      net.wire(peer, peer.pc.createDataChannel('r', { ordered: true }), true)\n      await peer.pc.setLocalDescription(await peer.pc.createOffer())\n      await net.gathered(peer.pc)\n      if (net.peers.get(host) !== peer) return\n      for (const old of await find(signalsDs(), { lobby: net.lobby, from: net.me, $limit: 20 })) { try { await signalsDs().remove(old) } catch (e) {} }\n      await signalsDs().create({ lobby: net.lobby, from: net.me, to: host, kind: 'offer', sid: s, sdp: peer.pc.localDescription.sdp, at: Date.now() })\n      announce('signal')\n    }\n    // One signaling pass: the host answers new offers; a guest picks up its answer, or redials.\n    const signal = async () => {\n      if (!net.lobby || !net.me) return\n      try {\n        if (net.isHost()) {\n          const offers = await find(signalsDs(), { lobby: net.lobby, to: net.me, kind: 'offer', $limit: 20 })\n          for (const o of offers) {\n            const key = `${o.from}:${o.sid}`\n            if (net.handled.has(key)) continue\n            net.handled.add(key)\n            const peer = net.newPeer(o.from, o.sid)\n            await peer.pc.setRemoteDescription({ type: 'offer', sdp: o.sdp })\n            await peer.pc.setLocalDescription(await peer.pc.createAnswer())\n            await net.gathered(peer.pc)\n            for (const old of await find(signalsDs(), { lobby: net.lobby, from: net.me, to: o.from, $limit: 20 })) { try { await signalsDs().remove(old) } catch (e) {} }\n            await signalsDs().create({ lobby: net.lobby, from: net.me, to: o.from, kind: 'answer', sid: o.sid, sdp: peer.pc.localDescription.sdp, at: Date.now() })\n            announce('signal')\n          }\n          // Guests that left: drop their links.\n          const live = new Set(livePilots().map((p) => p.email))\n          for (const email of [...net.peers.keys()]) if (!live.has(email)) net.closePeer(email)\n        } else if (net.host) {\n          const peer = net.peers.get(net.host)\n          const dead = !peer || peer.state === 'failed' || peer.state === 'closed' ||\n            (peer.state === 'linking' && Date.now() - peer.startedAt > LINK_TIMEOUT_MS)\n          if (dead) return dialHost()\n          if (peer.state === 'linking' && peer.dialing && !peer.pc.remoteDescription) {\n            const [ans] = await find(signalsDs(), { lobby: net.lobby, to: net.me, kind: 'answer', sid: peer.sid, $limit: 1 })\n            if (ans && net.peers.get(net.host) === peer && !peer.pc.remoteDescription) await peer.pc.setRemoteDescription({ type: 'answer', sdp: ans.sdp })\n          }\n        }\n      } catch (e) {\n        console.warn('[TFSF DEV] signaling', e)\n      }\n    }\n    // Point the runtime at this lobby / host (a host change makes guests redial).\n    const syncNet = () => {\n      const L = room.lobby\n      if (!L || !room.mine) return\n      if (net.lobby !== room.id || net.me !== me()) { net.closeAll(); net.handled.clear(); net.lobby = room.id; net.me = me() }\n      if (net.host !== L.host) { net.closeAll(); net.host = L.host }\n      clearInterval(room.sigTimer)\n      room.sigTimer = setInterval(signal, SIG_MS)\n    }\n    const linkOf = (email) => {\n      if (email === me()) {\n        if (net.isHost()) return { state: 'host' }\n        const p = net.peers.get(net.host)\n        return p ? { state: p.state, rtt: p.rtt } : { state: 'linking' }\n      }\n      if (email === net.host) return { state: 'host' }\n      if (net.isHost()) { const p = net.peers.get(email); return p ? { state: p.state, rtt: p.rtt } : { state: 'linking' } }\n      return net.links[email] || { state: 'linking' }\n    }\n    const linkLabel = (l) => (l.state === 'host' ? 'Host' : l.state === 'open' ? `Linked · ${l.rtt || '…'} ms` : l.state === 'failed' ? 'No route — retrying' : 'Linking…')\n    const allLinked = () => livePilots().every((p) => { const l = linkOf(p.email); return l.state === 'host' || l.state === 'open' })\n    // Depart from the host: everyone gets it over the link (and from the lobby row as a fallback).\n    const offDepart = net.on('depart', (msg) => { if (current === 'room' && !net.isHost()) launch(msg.start) })\n    d2.onUnmount(offDepart)\n    const launch = (start) => {\n      if (room.departing) return\n      room.departing = true\n      seat('')\n      clearInterval(room.timer)\n      clearInterval(room.sigTimer)\n      rt.mp = { lobby: room.id, start, me: me(), host: net.host, pilots: Object.fromEntries(livePilots().map((p) => [p.email, p.pilot || 'fox'])), names: Object.fromEntries(livePilots().map((p) => [p.email, p.name || nameOf(p.email)])),\n        teams: (start && start.teams) || Object.fromEntries(livePilots().map((p) => [p.email, seatOf(p).team])), seats: Object.fromEntries(livePilots().map((p) => [p.email, seatOf(p).seat])) }\n      // Guests are launched by the host's message, not by a tap of theirs: they still tap Ready.\n      const level = start && start.cmd === 'level' ? `&level=${Number(start.level) || 0}` : ''\n      const arena = start && start.cmd === 'arena' ? `&stage=${Number(start.stage) || 0}&match=${Number(start.match) || 0}&minutes=${Number(start.minutes) || ARENA_MINUTES}&rounds=${Number(start.rounds) || roundsOf(start.match)}` : ''\n      play(`cmd=${encodeURIComponent((start && start.cmd) || 'new')}${level}${arena}&net=1`, net.isHost())\n    }\n\n    // The host picks the lobby's stage; everyone sees it on the lobby row.\n    const roomStage = () => { const v = room.lobby && room.lobby.stage; return Number.isFinite(Number(v)) && v !== null && v !== undefined ? Number(v) : -1 }\n    const pickRoomStage = async (id) => {\n      if (!amHost() || !room.lobby) return\n      const st = stageOf(id)\n      room.lobby = { ...room.lobby, stage: st.id, campaign: st.name }\n      paintRoom()\n      try { await patch(lobbiesDs(), { _id: room.id, stage: st.id, campaign: st.name }); announce('stage') } catch (e) { toast('Could not change the stage') }\n    }\n    const isArena = () => !!(room.lobby && room.lobby.mode === 'arena')\n    const paintRoom = () => {\n      if (current !== 'room') return\n      const L = room.lobby\n      const arena = isArena()\n      $('.room-campaign-kind').textContent = arena ? 'Map' : 'Campaign'\n      $('.room-campaign-name').textContent = arena ? (VS_STAGES[L.vsStage] || VS_STAGES[0]).name : (L && L.campaign) || DEFAULT_CAMPAIGN\n      $('.room-match').hidden = !arena\n      if (arena) $('.room-match-name').textContent = (VS_MATCHES[L.vsMatch] || VS_MATCHES[0]).name\n      $('.room-match').disabled = !amHost()\n      $('.room-bay').style.display = arena ? 'none' : ''\n      $('.arena').classList.toggle('on', arena && !room.gone)\n      $('.arena-bar').classList.toggle('on', arena && !room.gone)\n      if (!arena) { $('.swap-ask').classList.remove('on'); $('.arena').innerHTML = ''; $('.arena-bar').innerHTML = '' }\n      $('.room-campaign').disabled = !amHost()\n      $('[data-screen=\"room\"] .crumb-title').textContent = L ? (L.name || `Lobby ${L.code || ''}`) : 'Lobby'\n      const note = $('.room-note')\n      const depart = $('.room-depart')\n      if (room.gone) {\n        $('.room-bay').innerHTML = ''\n        depart.classList.remove('show')\n        note.textContent = room.gone\n        note.classList.add('show')\n        return\n      }\n      if (arena) return paintArena()\n      const pilots = livePilots()\n      const myEmail = me()\n      const cols = []\n      for (let i = 0; i < MAX_PLAYERS; i++) {\n        const p = pilots[i]\n        if (!p) {\n          cols.push(`<div class=\"col vacant\" data-key=\"v${i}\"><div class=\"vehicle\">Vehicle</div>\n            <div class=\"slot\"><span><b>Open slot</b><small>Waiting for a pilot</small></span><span class=\"mark\"></span></div></div>`)\n          continue\n        }\n        const mine = p.email === myEmail\n        const open = mine && !p.ready\n        const host = L && p.email === L.host\n        const awayFor = mine ? 0 : serverNow() - stampOf(p)\n        const link = awayFor > AWAY_MS\n          ? { state: 'failed', label: `Disconnected · kicked in ${Math.max(0, Math.ceil((LIVE_MS - awayFor) / 1000))} s` }\n          : linkOf(p.email)\n        const taken = mine ? takenPilots(myEmail) : []\n        const opts = mine && room.picking\n          ? PILOTS.map((pl) => `<button class=\"opt ${pl.id === p.pilot ? 'go' : ''}\" data-pick=\"${pl.id}\" ${taken.includes(pl.id) ? 'disabled' : ''}>${pl.name} <small>${taken.includes(pl.id) ? 'Taken' : pl.id === p.pilot ? 'Yours' : ''}</small></button>`).join('')\n          : `<button class=\"opt\" data-ropt=\"pilot\">Pilot <small>${esc(pilotName(p.pilot))}</small></button>\n            <button class=\"opt\" data-ropt=\"controls\">Controls <small>${esc(controlsLabel())}</small></button>\n            <button class=\"opt go\" data-ropt=\"ready\">Ready <span>✓</span></button>`\n        cols.push(`<div class=\"col ${open ? 'open' : ''}\" data-key=\"${esc(p._id)}\">\n          <div class=\"vehicle\">${esc(pilotName(p.pilot))}'s Arwing</div>\n          <div class=\"opts\">${opts}</div>\n          <button class=\"slot ${p.ready ? 'ready' : ''} ${mine ? 'me' : 'cpu'}\" ${mine ? 'data-rslot=\"1\"' : ''}>\n            <span><b>${esc(pilotName(p.pilot))}${host ? '<span class=\"host\">HOST</span>' : ''}</b><small>${esc(p.name || nameOf(p.email))}${mine ? ' · You' : ''}</small><small class=\"link ${esc(link.state)}\">${esc(link.label || linkLabel(link))}</small></span>\n            <span class=\"mark\">✓</span>\n          </button>\n        </div>`)\n      }\n      $('.room-bay').innerHTML = cols.join('')\n      const allReady = pilots.length > 0 && pilots.every((p) => p.ready)\n      const linked = allLinked()\n      depart.classList.toggle('show', allReady && linked && amHost())\n      note.textContent = allReady ? (linked ? 'Waiting for the host to depart' : 'Linking pilots…') : ''\n      note.classList.toggle('show', allReady && (!amHost() || !linked))\n    }\n\n    // ---- Arena: two teams of four seats; move to an empty seat, or ask a pilot to swap -------------\n    const seatOf = (p) => ({ team: p.team === 'red' ? 'red' : 'blue', seat: Number.isFinite(Number(p.seat)) ? Number(p.seat) : 0 })\n    const freeSeat = (exceptEmail, prefer) => {\n      const pilots = livePilots().filter((p) => p.email !== exceptEmail)\n      const taken = new Set(pilots.map((p) => { const s = seatOf(p); return `${s.team}:${s.seat}` }))\n      const count = (t) => pilots.filter((p) => seatOf(p).team === t).length\n      const order = prefer ? [prefer, prefer === 'blue' ? 'red' : 'blue'] : (count('red') < count('blue') ? ['red', 'blue'] : ['blue', 'red'])\n      for (const team of order) for (let seat = 0; seat < 4; seat++) if (!taken.has(`${team}:${seat}`)) return { team, seat }\n      return null\n    }\n    // Two pilots on one seat (both moved at once): the later joiner moves on.\n    const fixSeatClash = async () => {\n      const mine = room.mine\n      if (!mine || !isArena()) return\n      const s = seatOf(mine)\n      const clash = livePilots().some((p) => p.email !== mine.email && byJoin(p, mine) < 0 && seatOf(p).team === s.team && seatOf(p).seat === s.seat)\n      if (mine.team == null || clash) {\n        const free = freeSeat(mine.email, mine.team)\n        if (free) { Object.assign(mine, free); await touch(mine, free); announce('seat') }\n      }\n    }\n    const requestsForMe = () => {\n      const now = serverNow()\n      return livePilots().filter((p) => p.swapTo === me() && now - (Number(p.swapAt) || 0) < SWAP_MS && stampOf(p) > now - SWAP_MS)\n    }\n    const paintArena = () => {\n      const pilots = livePilots()\n      const myEmail = me()\n      const L = room.lobby\n      const bySeat = {}\n      for (const p of pilots) { const s = seatOf(p); bySeat[`${s.team}:${s.seat}`] = p }\n      $('.arena').innerHTML = TEAMS.map((t) => `<div class=\"tside ${t.id}\"><div class=\"tside-head\">${t.name}</div>${[0, 1, 2, 3].map((seat) => {\n        const p = bySeat[`${t.id}:${seat}`]\n        if (!p) return `<button class=\"seat empty\" data-seat=\"${t.id}:${seat}\" data-key=\"${t.id}${seat}\"><span><b>Open</b><small>Tap to move here</small></span><span class=\"mark\"></span></button>`\n        const mine = p.email === myEmail\n        const host = L && p.email === L.host\n        const asked = room.mine && room.mine.swapTo === p.email\n        const link = mine ? { state: 'host' } : linkOf(p.email)\n        return `<button class=\"seat ${p.ready ? 'ready' : ''} ${mine ? 'me' : ''} ${asked ? 'asked' : ''}\" data-seat=\"${t.id}:${seat}\" data-key=\"${esc(p._id)}\">\n          <span><b>${esc(pilotName(p.pilot))}</b>${host ? '<small class=\"host\">HOST</small>' : ''}<small>${esc(p.name || nameOf(p.email))}${mine ? ' · You' : asked ? ' · asked to swap' : link.state === 'open' || link.state === 'host' ? '' : ' · linking'}</small></span>\n          <span class=\"mark\">✓</span></button>`\n      }).join('')}</div>`).join('')\n      const mineRow = pilots.find((p) => p.email === myEmail)\n      const taken = takenPilots(myEmail)\n      $('.arena-bar').innerHTML = !mineRow ? '' : room.picking\n        ? PILOTS.map((pl) => `<button class=\"opt ${pl.id === mineRow.pilot ? 'go' : ''}\" data-pick=\"${pl.id}\" ${taken.includes(pl.id) ? 'disabled' : ''}>${pl.name}</button>`).join('')\n        : `<button class=\"opt\" data-ropt=\"pilot\">Pilot <small>${esc(pilotName(mineRow.pilot))}</small></button>\n          <button class=\"opt\" data-ropt=\"controls\">Controls <small>${esc(controlsLabel())}</small></button>\n          <button class=\"opt ${mineRow.ready ? '' : 'go'}\" data-ropt=\"${mineRow.ready ? 'unready' : 'ready'}\">${mineRow.ready ? 'Not ready' : 'Ready ✓'}</button>`\n      const req = requestsForMe()[0]\n      const ask = $('.swap-ask')\n      ask.classList.toggle('on', !!req)\n      if (req) { ask.querySelector('span').textContent = `${pilotName(req.pilot)} (${req.name || nameOf(req.email)}) wants to swap seats with you`; ask.dataset.from = req.email }\n      const note = $('.room-note')\n      const depart = $('.room-depart')\n      const allReady = pilots.length > 1 && pilots.every((p) => p.ready)\n      const linked = allLinked()\n      const fits = pilots.length <= ARENA_MAX\n      depart.classList.toggle('show', allReady && linked && fits && amHost())\n      note.textContent = !allReady ? '' : !fits ? `Up to ${ARENA_MAX} pilots per battle for now` : linked ? 'Waiting for the host to depart' : 'Linking pilots…'\n      note.classList.toggle('show', allReady && (!fits || !amHost() || !linked))\n    }\n    d2.on('[data-seat]', 'click', async (e, el) => {\n      const mine = room.mine\n      if (!mine || !isArena()) return\n      const [team, seatStr] = el.dataset.seat.split(':')\n      const seat = Number(seatStr)\n      const p = livePilots().find((x) => { const s = seatOf(x); return s.team === team && s.seat === seat })\n      if (!p) {\n        Object.assign(mine, { team, seat })\n        paintRoom()\n        try { await touch(mine, { team, seat }); announce('seat') } catch (err) { toast('Could not move') }\n        return\n      }\n      if (p.email === mine.email) return\n      // Ask that pilot to swap: they see it, and say yes or no.\n      const s = seatOf(p)\n      const ask = { swapTo: p.email, swapAt: serverNow(), swapTeam: s.team, swapSeat: s.seat }\n      Object.assign(mine, ask)\n      paintRoom()\n      toast(`Asked ${p.name || nameOf(p.email)} to swap`)\n      try { await touch(mine, ask); announce('swap') } catch (err) { toast('Could not ask') }\n    })\n    d2.on('[data-swap]', 'click', async (e, el) => {\n      const mine = room.mine\n      const from = $('.swap-ask').dataset.from\n      const req = livePilots().find((p) => p.email === from)\n      if (!mine || !req) return\n      $('.swap-ask').classList.remove('on')\n      if (el.dataset.swap === 'yes') {\n        // Take their seat; they take ours when they see swapDone.\n        const theirs = seatOf(req)\n        const fields = { team: theirs.team, seat: theirs.seat, swapDone: from, swapNo: '' }\n        Object.assign(mine, fields)\n        try { await touch(mine, fields); announce('swap') } catch (err) {}\n      } else {\n        try { await touch(mine, { swapNo: from, swapDone: '' }); announce('swap') } catch (err) {}\n      }\n      paintRoom()\n    })\n    // The asking side: the other pilot said yes (move into the seat they left) or no.\n    const followSwap = async () => {\n      const mine = room.mine\n      if (!mine || !isArena() || !mine.swapTo) return\n      const other = livePilots().find((p) => p.email === mine.swapTo)\n      const expired = serverNow() - (Number(mine.swapAt) || 0) > SWAP_MS\n      if (other && other.swapDone === mine.email) {\n        const fields = { team: mine.swapTeam === 'red' ? 'red' : 'blue', seat: Number(mine.swapSeat) || 0, swapTo: '' }\n        Object.assign(mine, fields)\n        await touch(mine, fields)\n        announce('swap')\n      } else if (!other || other.swapNo === mine.email || expired) {\n        if (other && other.swapNo === mine.email) toast(`${other.name || nameOf(other.email)} said no`)\n        Object.assign(mine, { swapTo: '' })\n        await touch(mine, { swapTo: '' })\n      }\n    }\n\n    // Join (or re-join) this lobby: one lobbyPlayers row per pilot.\n    const joinRoom = async () => {\n      const myEmail = me()\n      const lobby = (await find(lobbiesDs(), { _id: room.id }))[0]\n      if (!lobby) { room.gone = 'This lobby does not exist anymore'; return false }\n      room.lobby = lobby\n      const pilots = await find(playersDs(), { lobby: room.id, $limit: 50 })\n      room.pilots = pilots\n      let mine = pilots.find((p) => p.email === myEmail)\n      const now = Date.now()\n      // Back from an Arena battle of this lobby (the player sends its pilots back here).\n      const back = room.back\n      if (!mine) {\n        if (lobby.status !== 'open' && !back) { room.gone = 'This lobby has already departed'; return false }\n        if (pilots.filter((p) => live(p)).length >= (Number(lobby.maxPlayers) || MAX_PLAYERS)) { room.gone = 'This lobby is full'; return false }\n        const was = back && rt.mp && rt.mp.teams && rt.mp.teams[myEmail] ? { team: rt.mp.teams[myEmail], seat: Number(rt.mp.seats[myEmail]) || 0 } : null\n        const wasFree = was && !pilots.some((p) => live(p) && p.email !== myEmail && seatOf(p).team === was.team && seatOf(p).seat === was.seat)\n        const seatFields = lobby.mode === 'arena' ? ((wasFree && was) || freeSeat(myEmail, was && was.team) || { team: 'blue', seat: 0 }) : {}\n        const wasPilot = back && rt.mp && rt.mp.pilots ? rt.mp.pilots[myEmail] : ''\n        const pilot = wasPilot && !takenPilots(myEmail).includes(wasPilot) ? wasPilot : freePilot(myEmail)\n        mine = await playersDs().create({ lobby: room.id, email: myEmail, name: myName(), pilot, ready: false, seen: now, joinedAt: now, ...seatFields })\n        if (!mine || !mine._id) throw new Error('no player id')\n        learnSkew(mine, now)\n        room.pilots.push(mine)\n        // Two pilots can take the last seat at once: the later joiner gives it back.\n        const after = (await find(playersDs(), { lobby: room.id, $limit: 50 })).filter((p) => live(p)).sort(byJoin)\n        if (after.findIndex((p) => p.email === myEmail) >= (Number(lobby.maxPlayers) || MAX_PLAYERS)) {\n          try { await playersDs().remove(mine) } catch (e) {}\n          room.gone = 'This lobby is full'\n          return false\n        }\n      } else {\n        Object.assign(mine, await touch(mine, back ? { name: myName(), ready: false } : { name: myName() }) || {})\n        if (back) mine.ready = false\n      }\n      if (back && lobby.status !== 'open') {\n        // The battle is over: the lobby takes pilots (and a new departure) again.\n        try { await patch(lobbiesDs(), { _id: room.id, status: 'open' }); room.lobby = { ...lobby, status: 'open' } } catch (e) {}\n      }\n      room.mine = mine\n      seat(room.id)\n      // The pilot was picked before this phone knew the server's clock (from the row just written),\n      // so the others' rows may have looked stale: check it again now.\n      try { await fixPilotClash() } catch (e) {}\n      try { await fixSeatClash() } catch (e) {}\n      announce('join')\n      return true\n    }\n\n    // Every BEAT_MS: heartbeat, refresh, and (as host) housekeeping.\n    let beat = async () => {\n      if (current !== 'room' || !room.mine) return\n      try {\n        const sentAt = Date.now()\n        const touched = await touch(room.mine)\n        const [lobby] = await find(lobbiesDs(), { _id: room.id })\n        if (!lobby) { room.gone = 'The lobby was closed'; seat(''); return paintRoom() }\n        room.lobby = lobby\n        // A departure (not the one this pilot just came back from) takes everyone along.\n        const old = room.back && lobby.start && Number(lobby.start.at) <= room.back.at\n        if (lobby.status === 'departed' && !amHost() && !old) return launch(lobby.start)\n        room.pilots = await find(playersDs(), { lobby: room.id, $limit: 50 })\n        const myRow = room.pilots.find((p) => String(p._id) === String(room.mine._id))\n        if (!myRow) { room.gone = 'You were away too long and left this lobby'; seat(''); return paintRoom() }\n        room.mine = myRow\n        // No record back from the save: our row, as just read, carries the server's stamp of it.\n        if (!(touched && touched.updatedAt)) learnSkew(myRow, sentAt)\n        const now = serverNow()\n        await fixPilotClash()\n        try { await fixSeatClash(); await followSwap() } catch (e) {}\n        const pilots = livePilots()\n        // The host is gone: the earliest-joined live pilot takes over.\n        if (!pilots.some((p) => p.email === lobby.host) && pilots[0] && pilots[0].email === me()) {\n          await patch(lobbiesDs(), { _id: room.id, host: me(), hostName: myName() })\n          room.lobby = { ...lobby, host: me(), hostName: myName() }\n          announce('host')\n        }\n        if (net.host !== room.lobby.host) syncNet()\n        if (amHost()) {\n          // Forget pilots long gone, and keep the lobby's players snapshot current.\n          for (const p of room.pilots) if (stampOf(p) < now - PRUNE_MS) { try { await playersDs().remove(p) } catch (e) {} }\n          const snap = pilots.map((p) => ({ email: p.email, name: p.name, pilot: p.pilot || '', ready: !!p.ready }))\n          if (JSON.stringify(snap) !== JSON.stringify(room.lobby.players || [])) {\n            await patch(lobbiesDs(), { _id: room.id, players: snap })\n            room.lobby.players = snap\n          }\n        }\n      } catch (e) {\n        console.warn('[TFSF DEV] lobby refresh', e)\n      }\n      paintRoom()\n    }\n    // beat() can be asked for by a socket ping while one is running: run one at a time.\n    const beatOnce = beat\n    let beating = null\n    beat = () => (beating || (beating = beatOnce().finally(() => { beating = null })))\n\n    const enterRoom = async (id) => {\n      leaveRoomTimers()\n      const back = rt.arenaBack && rt.arenaBack.lobby === id ? rt.arenaBack : null\n      rt.arenaBack = null\n      Object.assign(room, { id, lobby: null, pilots: [], mine: null, gone: '', joining: true, picking: false, departing: false, back })\n      $('.room-bay').innerHTML = ''\n      $('.room-depart').classList.remove('show')\n      $('.room-note').textContent = 'Joining…'\n      $('.room-note').classList.add('show')\n      try {\n        const ok = await joinRoom()\n        room.joining = false\n        paintRoom()\n        if (ok) {\n          joinSock(id)\n          beat()\n          room.timer = setInterval(() => beat(), BEAT_MS)\n          syncNet()\n          signal()\n          // Link status changes faster than the lobby beat: repaint every second.\n          room.paintTimer = setInterval(paintRoom, 1000)\n        }\n      } catch (e) {\n        room.joining = false\n        room.gone = 'Could not join this lobby'\n        paintRoom()\n      }\n    }\n    const leaveRoomTimers = () => { clearInterval(room.timer); clearInterval(room.sigTimer); clearInterval(room.paintTimer) }\n    // Leaving: drop my row; a leaving host hands the lobby on, the last pilot out closes it.\n    const leaveRoom = async () => {\n      leaveRoomTimers()\n      const mine = room.mine\n      const lobby = room.lobby\n      room.mine = null\n      seat('')\n      if (!mine) return\n      net.closeAll()\n      net.lobby = ''\n      leaveSock(room.id)\n      try {\n        for (const s of await find(signalsDs(), { lobby: room.id, from: me(), $limit: 20 })) { try { await signalsDs().remove(s) } catch (e) {} }\n        await playersDs().remove(mine)\n        announce('leave')\n        if (lobby && lobby.host === me()) {\n          const rest = (await find(playersDs(), { lobby: room.id, $limit: 50 })).filter((p) => live(p)).sort(byJoin)\n          if (rest[0]) await patch(lobbiesDs(), { _id: room.id, host: rest[0].email, hostName: rest[0].name || nameOf(rest[0].email) })\n          else {\n            for (const p of await find(playersDs(), { lobby: room.id, $limit: 50 })) { try { await playersDs().remove(p) } catch (e) {} }\n            for (const s of await find(signalsDs(), { lobby: room.id, $limit: 50 })) { try { await signalsDs().remove(s) } catch (e) {} }\n            await lobbiesDs().remove(lobby)\n          }\n        }\n      } catch (e) {\n        console.warn('[TFSF DEV] leave lobby', e)\n      }\n    }\n    // Closing or reloading the tab is not leaving: the seat is kept for LIVE_MS (see the top).\n    let unloading = false\n    d2.on(window, 'pagehide', () => { unloading = true })\n    d2.onUnmount(() => { if (room.mine && !room.departing && !unloading) leaveRoom(); else leaveRoomTimers() })\n\n    d2.on('[data-ropt]', 'click', async (e, el) => {\n      const opt = el.dataset.ropt\n      if (opt === 'pilot') { room.picking = true; return paintRoom() }\n      if (opt === 'controls') {\n        const next = CONTROLS[(CONTROLS.findIndex((c) => c.id === controlsId()) + 1) % CONTROLS.length]\n        try { localStorage.setItem(CONTROLS_KEY, next.id) } catch (err) {}\n        toast(`Controls: ${next.label}`)\n        return paintRoom()\n      }\n      if (opt === 'ready' && room.mine) setReady(true)\n      if (opt === 'unready' && room.mine) setReady(false)\n    })\n    d2.on('[data-pick]', 'click', async (e, el) => {\n      const pick = el.dataset.pick\n      room.picking = false\n      const mine = room.mine\n      if (!mine || takenPilots(mine.email).includes(pick)) return paintRoom()\n      mine.pilot = pick\n      const row = room.pilots.find((p) => String(p._id) === String(mine._id))\n      if (row) row.pilot = pick\n      paintRoom()\n      try { await touch(mine, { pilot: pick }); announce('pilot') } catch (err) { toast('Could not change pilot') }\n    })\n    d2.on('[data-rslot]', 'click', () => { if (room.mine && room.mine.ready) setReady(false) })\n    const setReady = async (ready) => {\n      const mine = room.mine\n      mine.ready = ready\n      const row = room.pilots.find((p) => String(p._id) === String(mine._id))\n      if (row) row.ready = ready\n      paintRoom()\n      try { await touch(mine, { ready }); announce('ready') } catch (e) { toast('Could not update your status') }\n    }\n    d2.on('.room-depart', 'click', async () => {\n      askMotion() // inside the tap, before any await\n      if (!amHost() || room.departing) return\n      const pilots = livePilots()\n      if (!pilots.every((p) => p.ready) || !allLinked()) return\n      if (isArena() && (pilots.length < 2 || pilots.length > ARENA_MAX)) return\n      const L = room.lobby || {}\n      const start = isArena()\n        ? { cmd: 'arena', stage: Number(L.vsStage) || 0, match: Number(L.vsMatch) || 0, rounds: roundsOf(L.vsMatch), minutes: Number(rt.arenaMinutes) || ARENA_MINUTES, at: Date.now(),\n          teams: Object.fromEntries(pilots.map((p) => [p.email, seatOf(p).team])) }\n        : { ...stageCmd(roomStage()), at: Date.now() }\n      net.send({ t: 'depart', start }, { reliable: true })\n      try { await patch(lobbiesDs(), { _id: room.id, status: 'departed', start }) } catch (e) {}\n      announce('depart')\n      launch(start)\n    })\n\n    // Back targets: leaving a lobby goes through leaveRoom first.\n    const goBack = async (to) => {\n      if (current === 'room') await leaveRoom()\n      nav(to)\n    }\n\n    const STUBS = {}\n\n    // ---- /profile: the nickname ----------------------------------------------------------------\n    const paintProfile = () => { $('.nick').value = readKey(NICK_KEY) }\n    const saveNick = () => {\n      const nick = String($('.nick').value || '').replace(/\\s+/g, ' ').trim().slice(0, 16)\n      try { if (nick) localStorage.setItem(NICK_KEY, nick); else localStorage.removeItem(NICK_KEY) } catch (e) {}\n      $('.nick').value = nick\n      toast(nick ? `Nickname: ${nick}` : 'Nickname cleared')\n    }\n    d2.on('.nick', 'keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); saveNick(); e.target.blur() } })\n\n    // ---- /profile: change password (popup) ------------------------------------------------------\n    // The AuthenticationStrategy's own changePassword: it reads the current password from\n    // cursor.current.previous, the new one from .credentials and its confirmation from .registration,\n    // and the server (/id/cp) checks the current one against the signed-in user before saving.\n    // It resolves to an Error instead of throwing.\n    const pwForm = $('.pw-form')\n    const openPw = () => {\n      pwForm.reset()\n      $('.pw-err').textContent = ''\n      $('.pw-dialog').classList.add('on')\n      setTimeout(() => { try { $('#pw-cur').focus() } catch (e) {} }, 50)\n    }\n    const closePw = () => { pwForm.reset(); $('.pw-dialog').classList.remove('on') }\n    d2.on(pwForm, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(pwForm)\n      const current = String(fd.get('current') || '')\n      const next = String(fd.get('next') || '')\n      const confirm = String(fd.get('confirm') || '')\n      const err = $('.pw-err')\n      const btn = pwForm.querySelector('.pw-go')\n      err.textContent = !current ? 'Enter your current password.'\n        : !next ? 'Enter a new password.'\n        : next !== confirm ? 'The new passwords do not match.'\n        : next === current ? 'The new password is the same as the current one.' : ''\n      if (err.textContent) return\n      btn.disabled = true\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        const raw = auth && auth.raw\n        if (!raw || typeof raw.changePassword !== 'function') throw new Error('Login is not configured')\n        const key = (raw.passwordField && raw.passwordField.key) || 'password'\n        const cur = raw.cursor.current\n        cur.previous = { [key]: current }\n        cur.credentials = { ...(cur.credentials || {}), [key]: next }\n        cur.registration = { ...(cur.registration || {}), [key]: confirm }\n        const res = await raw.changePassword()\n        if (res instanceof Error || (res && res.name === 'Data2Error')) throw res\n        closePw()\n        toast('Password changed')\n      } catch (ex) {\n        try { const cur = d2.auth(d2.param('auth')).raw.cursor.current; cur.previous = {}; cur.credentials = {}; cur.registration = {} } catch (e2) {}\n        const m = String((ex && ex.message) || '')\n        err.textContent = /could not change/i.test(m) ? 'Could not change the password. Check your current password.'\n          : /no authenticated/i.test(m) ? 'You are signed out. Sign in again first.'\n          : 'Could not change the password. Try again.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    // ---- /settings: the volume mixer (0-200 %, 100 = as the game mixes it) ------------------------\n    // Kept in `${STORE}.volume`; the player applies it (rt.applyVolume when the engine is up).\n    const VOL_KEY = `${STORE}.volume`\n    const MIXER = [\n      { id: 'master', label: 'Master' }, { id: 'sfx', label: 'Sound effects' }, { id: 'bgm', label: 'Music' },\n      { id: 'radio', label: 'Radio (CPU)' }, { id: 'voice', label: 'Voice chat' },\n    ]\n    const readVolume = () => {\n      let v = {}\n      try { v = JSON.parse(readKey(VOL_KEY) || '{}') || {} } catch (e) {}\n      const out = {}\n      for (const m of MIXER) out[m.id] = Number.isFinite(Number(v[m.id])) ? Math.max(0, Math.min(200, Math.round(Number(v[m.id])))) : 100\n      out.muted = v.muted === true // the Sound switch: everything silent, the levels kept for later\n      return out\n    }\n    const paintSettings = () => {\n      const v = readVolume()\n      $('.mixer').classList.toggle('muted', v.muted)\n      $('.mixer').innerHTML = `<div class=\"snd-row\" data-key=\"sound\"><span>Sound</span>\n        <button class=\"switch ${v.muted ? '' : 'on'}\" data-act=\"sound\" role=\"switch\" aria-checked=\"${!v.muted}\" aria-label=\"Sound\"></button></div>` +\n        MIXER.map((m) => `<label class=\"mix\" data-key=\"${m.id}\"><span>${esc(m.label)}</span>\n        <input type=\"range\" min=\"0\" max=\"200\" step=\"5\" value=\"${v[m.id]}\" data-vol=\"${m.id}\" style=\"--fill:${v[m.id] / 2}%\">\n        <b>${v[m.id]}%</b></label>`).join('')\n    }\n    // The game's master gain (TFSF_Player_DEV puts it on the shared audio context) also carries the menu\n    // music once a game has been played: keep it in step from here, where the player is not mounted.\n    const applyMaster = () => {\n      const v = readVolume()\n      try { const bus = rt.audio && rt.audio.__tfsfMaster; if (bus) bus.gain.value = v.muted ? 0 : v.master / 100 } catch (e) {}\n      try { if (music.gain) music.gain.gain.setTargetAtTime(musicLevel(), music.gain.context.currentTime, 0.05) } catch (e) {}\n      root.querySelectorAll('.snd-btn').forEach((b) => b.classList.toggle('muted', v.muted))\n    }\n    const setMuted = (muted) => {\n      const v = readVolume()\n      v.muted = !!muted\n      try { localStorage.setItem(VOL_KEY, JSON.stringify(v)) } catch (err) {}\n      try { rt.applyVolume && rt.applyVolume() } catch (err) {}\n      paintSettings()\n      applyMaster()\n      toast(v.muted ? 'Sound off' : 'Sound on')\n    }\n    d2.on('[data-vol]', 'input', (e, el) => {\n      const v = readVolume()\n      v[el.dataset.vol] = Math.max(0, Math.min(200, Number(el.value) || 0))\n      el.style.setProperty('--fill', `${v[el.dataset.vol] / 2}%`)\n      el.parentElement.querySelector('b').textContent = `${v[el.dataset.vol]}%`\n      try { localStorage.setItem(VOL_KEY, JSON.stringify(v)) } catch (err) {}\n      try { rt.applyVolume && rt.applyVolume() } catch (err) {}\n    })\n    // ---- menu music: the Corneria theme, looping on every menu screen once the title is tapped ------\n    // A private file (TFSF ROM drive), read with the session's download key like the engine files. It\n    // runs on the game's audio context (rt.audio), fades in, follows Master x Music from /settings, and\n    // keeps playing from screen to screen (this component is rebuilt per screen); the game's loading\n    // screen fades it out (TFSF_Player_DEV calls rt.menuMusicStop). The decoded song stays in rt.\n    const MENU_MUSIC = { ref: 'FBoHETPRIMLxFzfavAwi2', loopStart: 1.0, loopEnd: 84.2079 }\n    const music = rt.menuMusic || (rt.menuMusic = { buf: null, loading: null, src: null, gain: null })\n    const musicLevel = () => {\n      const v = readVolume()\n      const ac = rt.audio\n      // Once the player has put its master gain on the context, `destination` is that gain already.\n      if (v.muted) return 0\n      return (ac && ac.__tfsfMaster ? 1 : v.master / 100) * (v.bgm / 100) * 0.35\n    }\n    const musicLoad = () => music.loading || (music.loading = (async () => {\n      const kr = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (!kr.ok) throw new Error(`keys: HTTP ${kr.status}`)\n      const body = await kr.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const key = rows[0] && rows[0].key\n      if (!key) throw new Error('keys: none')\n      const res = await fetch(`${filesBase}/file/${MENU_MUSIC.ref}?t=${encodeURIComponent(key)}`)\n      if (!res.ok) throw new Error(`music: HTTP ${res.status}`)\n      const bytes = await res.arrayBuffer()\n      music.buf = await rt.audio.decodeAudioData(bytes)\n      return music.buf\n    })().catch((e) => { music.loading = null; throw e }))\n    const musicPlay = async () => {\n      const ac = rt.audio\n      if (!ac || ac.state === 'closed' || music.src) return\n      try { await musicLoad() } catch (e) { return }\n      if (music.src || !mounted || rt.menuMusicOff || ac !== rt.audio || ac.state === 'closed') return\n      const src = ac.createBufferSource()\n      src.buffer = music.buf\n      src.loop = true\n      src.loopStart = MENU_MUSIC.loopStart\n      src.loopEnd = Math.min(MENU_MUSIC.loopEnd, music.buf.duration)\n      const gain = ac.createGain()\n      gain.gain.setValueAtTime(0, ac.currentTime)\n      gain.gain.linearRampToValueAtTime(musicLevel(), ac.currentTime + 1.5)\n      src.connect(gain)\n      gain.connect(ac.destination)\n      src.start()\n      music.src = src\n      music.gain = gain\n    }\n    const musicStop = (fade) => {\n      const { src, gain } = music\n      music.src = music.gain = null\n      if (!src) return\n      try {\n        const ac = src.context\n        gain.gain.cancelScheduledValues(ac.currentTime)\n        gain.gain.setValueAtTime(gain.gain.value, ac.currentTime)\n        gain.gain.linearRampToValueAtTime(0, ac.currentTime + fade)\n        src.stop(ac.currentTime + fade + 0.05)\n      } catch (e) {}\n    }\n    let mounted = true\n    rt.menuMusicStop = musicStop\n    rt.menuMusicOff = false // back on a menu screen: the music may play again\n    onScreen = (name) => { if (name !== 'title' && name !== 'login' && rt.titleDone) musicPlay() }\n    d2.on('[data-vol]', 'input', () => applyMaster())\n    applyMaster()\n    d2.onUnmount(() => { mounted = false; onScreen = null })\n\n    // Star Fox 64: the original game, in the dataFox2 app (same system, its own subdomain).\n    const openDataFox2 = () => { location.href = `https://${location.hostname.replace(/^[^.]+/, 'datafox2')}/` }\n\n    // Which screen the URL asks for. Same artboard on several routes: follow the URL.\n    let current = ''\n    let routeKey = ''\n    let checking = false\n    const route = async () => {\n      const segs = location.pathname.replace(/\\/+$/, '').split('/').filter(Boolean)\n      let want = 'home'\n      let lobbyId = ''\n      if (segs[0] === 'multiplayer') {\n        // (Arena lobbies live on /multiplayer/arena/:_id: adventure ones are TFSF_MainMenu_DEV's screen now)\n        if ((segs[1] === 'lobbies' || segs[1] === 'arena') && segs[2]) { want = 'room'; lobbyId = segs[2] }\n        else if (segs[1] === 'lobbies') want = 'browse'\n        else want = 'mp'\n      } else if (['login', 'map', 'results', 'solo', 'profile', 'settings', 'starfox64', ...Object.keys(STUBS)].includes(segs[0])) want = segs[0]\n      const key = want === 'room' ? `room:${lobbyId}` : want\n      // Every launch starts on the title: its tap grants sound, motion and the microphone at once.\n      if (!rt.titleDone) {\n        current = 'title'\n        routeKey = `title:${key}`\n        return show('title')\n      }\n      if (key === routeKey) return\n      closePicker()\n      // Leaving a screen with live work: stop polling, and leave the lobby (unless going to play).\n      if (current === 'browse') stopBrowse()\n      if (current === 'room' && room.mine && want !== 'room' && !room.departing) leaveRoom()\n      routeKey = key\n      current = want\n      if (want === 'login') return show('login')\n      if (want === 'map') { paintMap(); return show('map') }\n      if (want === 'results') { paintResults(); return show('results') }\n      if (want === 'mp') show('mp')\n      else if (want === 'browse') { show('browse'); startBrowse() }\n      else if (want === 'room') { show('room'); enterRoom(lobbyId) }\n      else if (want === 'solo') { lobby.ready = TEAM.map((m) => m.cpu); paintSolo(); paintSoloCampaign(); show('solo') }\n      else if (want === 'profile') { paintProfile(); show('profile') }\n      else if (want === 'settings') { paintSettings(); show('settings') }\n      else if (want === 'starfox64') return openDataFox2()\n      else if (STUBS[want]) { $('.stub-title').textContent = STUBS[want]; show('stub') }\n      // The main menu is TFSF_MainMenu_DEV now, on \"/\": this component only keeps the screens around\n      // it, and the title on /title. Anything else (the title's way back, an unknown path) goes home.\n      else return nav('/')\n      if (checking) return\n      checking = true\n      const ok = await checkSession()\n      checking = false\n      // Back to the same page after signing in (a shared lobby link keeps working).\n      if (!ok) nav(`/login?next=${encodeURIComponent(location.pathname || '/')}`)\n    }\n    // Opening the app while still seated in a lobby (closed tab, phone asleep, reload): back to it.\n    const backToSeat = async () => {\n      const id = readKey(ROOM_KEY)\n      if (!id || current === 'room' || current === 'login' || current === 'title') return\n      try {\n        const [row] = await find(playersDs(), { lobby: id, email: me(), $limit: 1 })\n        const [lobby] = row ? await find(lobbiesDs(), { _id: id }) : []\n        if (row && lobby && lobby.status === 'open' && live(row)) {\n          if (current !== 'room' && current !== 'login') nav(`/multiplayer/lobbies/${id}`)\n        } else seat('')\n      } catch (e) {}\n    }\n    // ---- title: tap anywhere. Asks for everything the game uses, inside that one tap: sound, motion\n    // (iOS asks), the microphone (push to talk), fullscreen. Browsers keep what was granted for the\n    // site (Android; iOS for the home-screen app), so later launches pass straight through; the tap\n    // stays because sound needs one on every launch.\n    const PERMS_KEY = `${STORE}.perms`\n    const askMotionNow = () => {\n      const asks = []\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') { try { asks.push(Ev.requestPermission().catch(() => 'denied')) } catch (e) {} }\n      }\n      return asks.length ? Promise.all(asks).then((r) => (r.every((x) => x === 'granted') ? 'granted' : 'denied')) : Promise.resolve('granted')\n    }\n    // The radio (push to talk) is set up here, once, on the game's own audio context (rt.audio): the\n    // microphone stream and its processor stay open, muted (rt.micPipe), and the player only switches\n    // the voice on. Opening it mid-game would make the phone change audio route then (iOS rebuilds its\n    // audio, the game stutters); here it happens on the title, under the radio check.\n    const askMic = () => {\n      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return Promise.resolve('unavailable')\n      if (micLive()) return Promise.resolve('granted')\n      try { if (navigator.audioSession) navigator.audioSession.type = 'play-and-record' } catch (e) {}\n      return navigator.mediaDevices\n        // A plain microphone: no call-style voice processing (echo cancelling, noise suppression,\n        // auto gain), which is what moves a phone into its \"call\" audio mode (earpiece-like routing,\n        // call volume, a quieter game). The game keeps playing as media, on the loudspeaker.\n        .getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 } })\n        .then((stream) => {\n          stream.getAudioTracks().forEach((t) => { t.enabled = false })\n          try {\n            unlockAudio()\n            const ac = rt.audio\n            const src = ac.createMediaStreamSource(stream)\n            const proc = ac.createScriptProcessor(1024, 1, 1)\n            const mute = ac.createGain()\n            mute.gain.value = 0\n            src.connect(proc); proc.connect(mute); mute.connect(ac.destination)\n            rt.micPipe = { stream, ac, src, proc, mute }\n          } catch (e) {\n            rt.micPipe = { stream, ac: null }\n          }\n          return 'granted'\n        })\n        .catch(() => {\n          try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n          return 'denied'\n        })\n    }\n    // Radio check: the transmission chirp and a burst of static through the radio's audio route, so the\n    // switch to it is done (and heard) before the game starts.\n    const radioCheck = () => new Promise((resolve) => {\n      try {\n        const ac = rt.audio\n        if (!ac || ac.state === 'closed') return resolve()\n        const t0 = ac.currentTime + 0.05\n        const out = ac.createGain()\n        out.gain.value = 0.25\n        out.connect(ac.destination)\n        for (const [at, f] of [[0, 1180], [0.09, 1560], [0.18, 1180]]) {\n          const o = ac.createOscillator()\n          const g = ac.createGain()\n          o.type = 'square'\n          o.frequency.value = f\n          g.gain.setValueAtTime(0.0001, t0 + at)\n          g.gain.exponentialRampToValueAtTime(0.5, t0 + at + 0.01)\n          g.gain.exponentialRampToValueAtTime(0.0001, t0 + at + 0.08)\n          o.connect(g); g.connect(out)\n          o.start(t0 + at); o.stop(t0 + at + 0.09)\n        }\n        const n = Math.floor(ac.sampleRate * 0.35)\n        const buf = ac.createBuffer(1, n, ac.sampleRate)\n        const d = buf.getChannelData(0)\n        for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * 0.35 * (1 - i / n)\n        const noise = ac.createBufferSource()\n        const bp = ac.createBiquadFilter()\n        bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.8\n        noise.buffer = buf\n        noise.connect(bp); bp.connect(out)\n        noise.start(t0 + 0.3)\n      } catch (e) {}\n      setTimeout(resolve, 900)\n    })\n    let titleBusy = false\n    d2.on('.title', 'click', async () => {\n      if (titleBusy || rt.titleDone) return\n      titleBusy = true\n      unlockAudio()\n      fullscreen()\n      // Both asked inside this tap (iOS shows them one after the other): motion only asks inside a tap.\n      const motion = askMotionNow()\n      const mic = askMic()\n      $('.title .tap').textContent = 'Getting ready…'\n      const perms = { motion: await motion, mic: await mic }\n      unlockAudio() // the microphone prompt can suspend sound on iOS: wake it again\n      $('.title .tap').textContent = 'Radio check…'\n      await radioCheck()\n      perms.at = Date.now()\n      try { localStorage.setItem(PERMS_KEY, JSON.stringify(perms)) } catch (e) {}\n      rt.perms = perms\n      rt.titleDone = true\n      titleBusy = false\n      routeKey = ''\n      route().then(backToSeat)\n    })\n\n    route().then(backToSeat)\n    d2.on(window, 'popstate', () => setTimeout(route, 0))\n    // The router may also navigate without a popstate (its own history.push): watch the path.\n    let lastPath = location.pathname\n    const pathWatch = setInterval(() => { if (location.pathname !== lastPath) { lastPath = location.pathname; route() } }, 250)\n    d2.onUnmount(() => clearInterval(pathWatch))\n\n    // ---- actions ---------------------------------------------------------------------------\n    d2.on('[data-play]', 'click', (e, el) => play(el.dataset.play))\n    d2.on('[data-nav]', 'click', (e, el) => nav(el.dataset.nav))\n    d2.on('[data-go]', 'click', () => goBack('/'))\n    d2.on('[data-act]', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'picker-close') {\n        closePicker()\n      } else if (act === 'campaign') {\n        if (current === 'solo') openPicker('solo', soloStage())\n        else if (current === 'room') {\n          if (!amHost()) toast(isArena() ? 'The host picks the map' : 'The host picks the stage')\n          else if (isArena()) openArenaPicker()\n          else openPicker('room', roomStage())\n        }\n      } else if (act === 'match') {\n        if (current === 'room' && isArena()) { if (amHost()) cycleMatch(); else toast('The host picks the match') }\n      } else if (act === 'create-cancel') {\n        closeCreate()\n      } else if (act === 'create-go') {\n        createLobby()\n      } else if (act === 'create-lobby') {\n        openCreate()\n      } else if (act === 'disconnect') {\n        signOut()\n      } else if (act === 'fullscreen') {\n        toggleFullscreen()\n      } else if (act === 'sound') {\n        setMuted(!readVolume().muted)\n      } else if (act === 'save-nick') {\n        saveNick()\n      } else if (act === 'pw-open') {\n        openPw()\n      } else if (act === 'pw-cancel') {\n        closePw()\n      } else if (act === 'starfox64') {\n        openDataFox2()\n      } else if (act === 'abandon') {\n        rt.lastEvent = null\n        rt.mp = null\n        try { sessionStorage.removeItem(`${STORE}.lastEvent`) } catch (err) {}\n        nav('/')\n      }\n    })\n    const signOut = async () => {\n      try { const auth = d2.auth(d2.param('auth')); if (auth && typeof auth.logout === 'function') await auth.logout() } catch (e) {}\n      try { localStorage.removeItem(sessionKey) } catch (e) {}\n      try { localStorage.removeItem(ME_KEY) } catch (e) {}\n      nav('/login')\n    }\n\n    // ---- login -----------------------------------------------------------------------------\n    const form = $('.login form')\n    d2.on(form, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(form)\n      const btn = form.querySelector('.signin')\n      const err = $('.login-err')\n      btn.disabled = true\n      err.textContent = ''\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        if (!auth) throw new Error('Login is not configured')\n        // AuthenticationStrategy.login takes { credentials, metadata? } and merges credentials in.\n        await auth.login({\n          credentials: { email: String(fd.get('email') || '').trim(), password: String(fd.get('password') || '') },\n        })\n        try { localStorage.setItem(ME_KEY, String(fd.get('email') || '').trim().toLowerCase()) } catch (err) {}\n        const next = new URLSearchParams(location.search).get('next') || '/'\n        current = ''\n        routeKey = ''\n        nav(next.startsWith('/') ? next : '/')\n        setTimeout(backToSeat, 300)\n      } catch (ex) {\n        err.textContent = 'Sign-in failed. Check your email and password.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    // ---- page takeover (phones): edge to edge, no browser zoom/scroll gestures ---------------\n    const undo = []\n    const inBuilder = !/\\.data2\\.app$/.test(location.hostname)\n    if (!inBuilder) {\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n      setMeta('apple-mobile-web-app-capable', 'yes')\n      setMeta('mobile-web-app-capable', 'yes')\n      setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n      setMeta('theme-color', '#05060a')\n      const styleOf = (el, css) => {\n        const prev = el.getAttribute('style')\n        el.style.cssText += css\n        undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n      }\n      styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n      styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n      const host = d2.root && d2.root.host\n      if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n    }\n    const onDoc = (type, fn) => { document.addEventListener(type, fn, { passive: false }); undo.push(() => document.removeEventListener(type, fn, { passive: false })) }\n    for (const g of ['gesturestart', 'gesturechange', 'gestureend']) onDoc(g, (e) => e.preventDefault())\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"lobbies":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lobbies"}}}},"lobbyPlayers":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lobbyPlayers"}}}},"routerRef":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"routerRef"}}}},"signals":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"signals"}}}},"socket":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"socket"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac83f9574abf47a445ff022","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Menu","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF menus (PRD) — promoted from TFSF_Menu_DEV, where all menu changes are made first and tested on\n// the tfsf-dev app; this is a copy of that source with the DEV markers\n// (badge, runtime/storage keys) switched back.\n//\n// Every screen around the game, one per route of the app's Router:\n//   /login     sign in (tfsfAuth)\n//   /          title + main menu: Solo, Multiplayer, Player profile, Settings, Star Fox 64, Disconnect\n//   /solo      team lobby: campaign (placeholder), four team slots, ready-up, Depart\n//   /multiplayer                 create a lobby, or browse open ones\n//   /multiplayer/lobbies         open lobbies, join one\n//   /multiplayer/lobbies/:_id    a lobby: its pilots, ships (placeholder), ready-up; Depart for the host\n//   /profile, /settings, /starfox64   placeholders for now\n//   /map       after a level: result, route so far, and the choice of where to fly next\n//   /results   game over / ending summary\n// The game itself is TFSF_Player on /play. This component never touches the engine: it only\n// navigates to /play with a query saying what to play (cmd=new|level|training|continue|retry|alt),\n// and reads the engine's last report (window[RT_KEY].lastEvent, also in sessionStorage) to draw\n// /map and /results.\n//\n// Lobbies live in two tables: `lobbies` (one row per session: code, host, campaign, status, and a\n// players snapshot the host keeps in sync) and `lobbyPlayers` (one row per pilot in a lobby). Each\n// pilot only ever writes its own lobbyPlayers row, so pilots never overwrite each other; the host is\n// the only writer of the lobby row. Pilots heartbeat every few seconds. A pilot whose tab closed or\n// went to sleep stops beating: after AWAY_MS the others see it as disconnected, and after LIVE_MS\n// (60 s) it is kicked (the host deletes its row) and, if it was the host, the earliest-joined pilot\n// still there takes over. Coming back within that minute (opening the app again) lands straight\n// back in the lobby, seat, pilot and ready state kept. Presence runs on the server's clock (each row's updatedAt / createdAt): phones'\n// clocks can be off by minutes, and a phone that trusted its own would see the others as gone.\n// Every lobby write is also announced on the `socket` (a data2 PowerCordSocket, one room per lobby),\n// so the others refresh at once; the heartbeat poll stays as the fallback.\n//\n// Netcode (sm64coopdx-style): every phone flies its own ship; the host owns the world. Guests link\n// to the host over WebRTC data channels (a star: the host relays between guests). Signaling goes\n// through the `signals` table (one offer/answer row per attempt, no trickle, public STUN only, no\n// TURN). The links live in window[RT_KEY].net, so they survive the switch from the lobby to /play.\n//\n// The tap that leads to /play is the user gesture iOS needs for sound and motion, so it unlocks\n// the audio context here (shared with the player through window[RT_KEY].audio), asks for motion\n// permission and goes fullscreen before navigating.\n\nconst RT_KEY = '__TFSF__'          // the per-page runtime shared with TFSF_Player\nconst STORE = 'tfsf'               // localStorage / sessionStorage prefix\n\n// PlanetId (engine order) -> name.\nconst PLANETS = ['Meteo', 'Area 6', 'Bolse', 'Sector Z', 'Sector X', 'Sector Y', 'Katina', 'Macbeth',\n  'Zoness', 'Corneria', 'Titania', 'Aquas', 'Fortuna', 'Venom', 'Solar']\nconst planetName = (id) => PLANETS[id] || '—'\n\n// The team on /solo: the player flies Fox, the CPUs fly the wingmen and are always ready.\nconst TEAM = [\n  { slot: 'Player', pilot: 'Fox', cpu: false },\n  { slot: 'CPU 1', pilot: 'Falco', cpu: true },\n  { slot: 'CPU 2', pilot: 'Slippy', cpu: true },\n  { slot: 'CPU 3', pilot: 'Peppy', cpu: true },\n]\n// Control schemes the player can pick on /solo (the player component reads `${STORE}.controls`).\nconst CONTROLS = [{ id: 'motion', label: 'Motion' }, { id: 'touch', label: 'Touch' }, { id: 'mouse', label: 'Mouse & keys' }]\nconst HOLD_BACK_MS = 3000\nconst MAX_PLAYERS = 4\nconst AWAY_MS = 12000         // no heartbeat for this long: shown as disconnected\nconst LIVE_MS = 60000         // no heartbeat for this long: kicked from the lobby\nconst PRUNE_MS = LIVE_MS      // the host deletes the rows of kicked pilots\nconst BEAT_MS = 4000          // heartbeat + refresh period inside a lobby\nconst DEFAULT_CAMPAIGN = \"Andross' Attack\"\n// The campaign selector: the whole campaign, or one stage (engine LevelId, played with cmd=level).\nconst STAGES = [\n  { id: -1, name: DEFAULT_CAMPAIGN, kind: 'Full campaign' },\n  { id: 0, name: 'Corneria', kind: 'Planet' }, { id: 1, name: 'Meteo', kind: 'Space' },\n  { id: 5, name: 'Sector Y', kind: 'All-range' }, { id: 14, name: 'Fortuna', kind: 'All-range' },\n  { id: 16, name: 'Katina', kind: 'All-range' }, { id: 13, name: 'Aquas', kind: 'Blue Marine' },\n  { id: 2, name: 'Sector X', kind: 'Space' }, { id: 7, name: 'Solar', kind: 'Planet' },\n  { id: 8, name: 'Zoness', kind: 'Planet' }, { id: 12, name: 'Titania', kind: 'Landmaster' },\n  { id: 11, name: 'Macbeth', kind: 'Landmaster' }, { id: 18, name: 'Sector Z', kind: 'All-range' },\n  { id: 17, name: 'Bolse', kind: 'All-range' }, { id: 3, name: 'Area 6', kind: 'Space' },\n  { id: 6, name: 'Venom', kind: 'Planet' }, { id: 19, name: 'Venom (hard)', kind: 'Planet' },\n  { id: 9, name: 'Andross', kind: 'Boss' },\n]\nconst stageOf = (id) => STAGES.find((st) => st.id === Number(id)) || STAGES[0]\n// What /play is told for a stage: the campaign from the start, or that one level.\nconst stageCmd = (id) => (Number(id) >= 0 ? { cmd: 'level', level: Number(id) } : { cmd: 'new' })\nconst PILOTS = [{ id: 'fox', name: 'Fox' }, { id: 'falco', name: 'Falco' }, { id: 'slippy', name: 'Slippy' }, { id: 'peppy', name: 'Peppy' }]\nconst pilotName = (id) => (PILOTS.find((p) => p.id === id) || PILOTS[0]).name\nconst SIG_MS = 1500           // signaling poll while linking\nconst LINK_TIMEOUT_MS = 15000 // a link that has not opened by then is retried\nconst STALE_MS = 6000         // no packet for this long: the link is dead\n\n// ---- the network runtime (one per page, outlives this component) -----------------------------------\n// net.send(msg, { reliable, to }) — guests always send to the host; the host sends to one guest or\n// all of them, and relays guest messages to the other guests. net.on(type, fn) subscribes.\nconst ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:global.stun.twilio.com:3478' }]\nfunction makeNet() {\n  const net = { lobby: '', me: '', host: '', peers: new Map(), handlers: new Map(), handled: new Set(), links: {} }\n  net.isHost = () => !!net.me && net.me === net.host\n  net.on = (type, fn) => {\n    if (!net.handlers.has(type)) net.handlers.set(type, new Set())\n    net.handlers.get(type).add(fn)\n    return () => net.handlers.get(type).delete(fn)\n  }\n  const dispatch = (msg, from) => { for (const fn of net.handlers.get(msg.t) || []) { try { fn(msg, from) } catch (e) {} } }\n  const raw = (peer, msg, reliable) => {\n    const ch = reliable ? peer.r : (peer.u && peer.u.readyState === 'open' ? peer.u : peer.r)\n    if (ch && ch.readyState === 'open') { try { ch.send(typeof msg === 'string' ? msg : JSON.stringify(msg)) } catch (e) {} }\n  }\n  net.send = (msg, opts = {}) => {\n    const reliable = !!opts.reliable\n    if (net.isHost()) {\n      for (const [email, peer] of net.peers) if (!opts.to || opts.to === email) raw(peer, msg, reliable)\n    } else {\n      const host = net.peers.get(net.host)\n      if (host) raw(host, msg, reliable)\n    }\n  }\n  // Binary (ArrayBuffer) on the unreliable channel: co-op inputs. A guest's goes to the host; the\n  // host's goes to every guest (opts.except: all but one, for the host relaying a guest's packet).\n  // It arrives as { t: 'bin', data } and is not relayed here: the receiver decides.\n  net.sendBin = (buf, opts = {}) => {\n    const targets = net.isHost() ? [...net.peers.values()].filter((p) => p.email !== opts.except) : [net.peers.get(net.host)].filter(Boolean)\n    for (const peer of targets) {\n      const ch = peer.u && peer.u.readyState === 'open' ? peer.u : peer.r\n      if (ch && ch.readyState === 'open') { try { ch.send(buf) } catch (e) {} }\n    }\n  }\n  net.peerState = (email) => { const p = net.peers.get(email); return p ? p.state : 'none' }\n  net.closePeer = (email) => {\n    const p = net.peers.get(email)\n    if (!p) return\n    net.peers.delete(email)\n    try { p.u && p.u.close() } catch (e) {}\n    try { p.r && p.r.close() } catch (e) {}\n    try { p.pc.close() } catch (e) {}\n  }\n  net.closeAll = () => { for (const email of [...net.peers.keys()]) net.closePeer(email) }\n  // Wire a data channel: control messages (ping/pong) are handled here; the rest is dispatched, and\n  // the host relays guest messages to the other guests.\n  net.wire = (peer, ch, reliable) => {\n    if (reliable) peer.r = ch\n    else peer.u = ch\n    ch.binaryType = 'arraybuffer'\n    ch.onopen = () => { if (peer.r && peer.r.readyState === 'open') { peer.state = 'open'; peer.lastRx = Date.now() } }\n    ch.onclose = () => { if (net.peers.get(peer.email) === peer) peer.state = 'closed' }\n    ch.onmessage = (e) => {\n      peer.lastRx = Date.now()\n      if (typeof e.data !== 'string') return dispatch({ t: 'bin', data: e.data }, peer.email)\n      let msg\n      try { msg = JSON.parse(e.data) } catch (err) { return }\n      if (msg.t === 'ping') return raw(peer, { t: 'pong', ts: msg.ts }, false)\n      if (msg.t === 'pong') { peer.rtt = Date.now() - msg.ts; return }\n      const from = msg.from || peer.email\n      if (net.isHost() && !msg.from) {\n        const fwd = JSON.stringify({ ...msg, from: peer.email })\n        for (const [email, other] of net.peers) if (email !== peer.email) raw(other, fwd, reliable)\n      }\n      dispatch(msg, from)\n    }\n  }\n  net.newPeer = (email, sid) => {\n    net.closePeer(email)\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS })\n    const peer = { email, sid, pc, u: null, r: null, state: 'linking', rtt: 0, lastRx: 0, startedAt: Date.now() }\n    pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') peer.state = 'failed' }\n    pc.ondatachannel = (e) => net.wire(peer, e.channel, e.channel.label === 'r')\n    net.peers.set(email, peer)\n    return peer\n  }\n  // Non-trickle: wait until ICE gathering is done (STUN answers in well under a second; cap 5 s).\n  net.gathered = (pc) => new Promise((resolve) => {\n    if (pc.iceGatheringState === 'complete') return resolve()\n    const t = setTimeout(resolve, 5000)\n    pc.addEventListener('icegatheringstatechange', () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); resolve() } })\n  })\n  // Keep-alive: ping every second; a link with no traffic for STALE_MS is closed (guests then redial).\n  net.pinger = setInterval(() => {\n    const now = Date.now()\n    for (const [email, p] of net.peers) {\n      if (p.state === 'open') {\n        raw(p, { t: 'ping', ts: now }, false)\n        if (now - p.lastRx > STALE_MS) p.state = 'closed'\n      }\n    }\n    // The host tells everyone how each guest's link is doing (guests only see their own).\n    if (net.isHost()) {\n      const links = {}\n      for (const [email, p] of net.peers) links[email] = { state: p.state, rtt: p.rtt }\n      net.links = links\n      net.send({ t: 'links', links })\n    }\n  }, 1000)\n  net.on('links', (msg) => { net.links = msg.links || {} })\n  return net\n}\n\n\nconst STATUS = ['Mission complete', 'Mission accomplished', 'Warp']\nconst esc = (v) => String(v == null ? '' : v).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]))\n\n// Header breadcrumb: the hold-to-go-back button and \"/ Title\".\nconst crumb = (title, backTo) => `<div class=\"crumb\">\n  <button class=\"back\" data-back=\"${backTo}\" aria-label=\"Hold to go back\">‹<svg viewBox=\"0 0 54 54\"><circle cx=\"27\" cy=\"27\" r=\"25\"></circle></svg></button>\n  <h1><span>/</span><span class=\"crumb-title\">${title}</span></h1>\n  <div class=\"back-hint\">Hold 3 s to go back</div>\n</div>`\n\nexport default {\n  css: `\n    .menu, .menu * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }\n    .menu { position: relative; width: 100%; height: 100%; overflow: hidden; color: #e8ecf4;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; user-select: none; -webkit-user-select: none;\n      background: radial-gradient(ellipse at 70% 30%, #1b2a52 0%, #0b1022 45%, #05060a 80%); }\n    /* Star field: three layers of tiny dots. */\n    .stars, .stars::before, .stars::after { position: absolute; inset: 0; content: ''; pointer-events: none;\n      background-image: radial-gradient(1px 1px at 12% 22%, #fff9 50%, transparent 51%), radial-gradient(1px 1px at 33% 71%, #fff7 50%, transparent 51%),\n        radial-gradient(1.5px 1.5px at 58% 14%, #fffc 50%, transparent 51%), radial-gradient(1px 1px at 81% 63%, #fff8 50%, transparent 51%),\n        radial-gradient(1px 1px at 91% 31%, #fff6 50%, transparent 51%), radial-gradient(1.5px 1.5px at 23% 88%, #fffa 50%, transparent 51%),\n        radial-gradient(1px 1px at 47% 44%, #fff5 50%, transparent 51%), radial-gradient(1px 1px at 69% 87%, #fff7 50%, transparent 51%); }\n    .stars::before { transform: translate(17%, 9%) scale(1.3); opacity: 0.6; }\n    .stars::after { transform: translate(-21%, -13%) scale(0.8); opacity: 0.5; }\n    .screen { position: absolute; inset: 0; display: none; padding: calc(16px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-right))\n      calc(16px + env(safe-area-inset-bottom)) calc(24px + env(safe-area-inset-left)); }\n    .screen.on { display: flex; }\n\n    .brand { display: flex; flex-direction: column; justify-content: center; gap: 6px; }\n    .logo { font-size: clamp(44px, 11vh, 76px); font-weight: 900; font-style: italic; letter-spacing: 0.04em; line-height: 0.95;\n      background: linear-gradient(180deg, #ffffff 0%, #b9d3ff 55%, #5aa2ff 100%); -webkit-background-clip: text; background-clip: text;\n      color: transparent; filter: drop-shadow(0 4px 18px #5aa2ff55); }\n    .tag { font-size: 13px; letter-spacing: 0.32em; text-transform: uppercase; color: #9fb4d9; }\n    .hint { font-size: 12px; color: #8193b5; max-width: 300px; line-height: 1.45; }\n\n    .home { align-items: stretch; justify-content: space-between; gap: 24px; }\n    .home .brand { flex: 1 1 auto; }\n    .actions { flex: 0 0 min(300px, 44vw); display: flex; flex-direction: column; justify-content: center; gap: 10px; }\n    .btn { appearance: none; border: 1px solid #ffffff22; border-radius: 14px; padding: 13px 18px; text-align: left;\n      background: linear-gradient(180deg, #ffffff14, #ffffff08); color: #e8ecf4; font: 700 16px/1.2 system-ui, sans-serif;\n      letter-spacing: 0.02em; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px;\n      transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .btn small { font-weight: 600; font-size: 11px; color: #9fb4d9; letter-spacing: 0.06em; text-transform: uppercase; }\n    .btn:active { transform: scale(0.97); background: #5aa2ff44; border-color: #9cc6ff; }\n    .btn.primary { background: linear-gradient(180deg, #6fb0ff, #3d82e8); border-color: #9cc6ff; color: #05060a; }\n    .btn.primary small { color: #0b1a33; }\n    .btn.danger { border-color: #ff8a8a55; color: #ffd0d0; }\n    .btn[disabled] { opacity: 0.4; pointer-events: none; }\n    .row { display: flex; gap: 10px; }\n    .row .btn { flex: 1; justify-content: center; }\n    .link { appearance: none; border: 0; background: none; color: #8193b5; font: 600 12px system-ui, sans-serif; padding: 6px; cursor: pointer;\n      text-decoration: underline; text-underline-offset: 3px; align-self: flex-start; }\n\n    /* Route / results */\n    .route { flex-direction: row; gap: 22px; }\n    .route .left { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; gap: 12px; min-width: 0; }\n    .route .right { flex: 0 0 min(300px, 42vw); display: flex; flex-direction: column; justify-content: center; gap: 9px; }\n    .kicker { font-size: 12px; letter-spacing: 0.3em; text-transform: uppercase; color: #9fb4d9; }\n    .headline { font-size: clamp(26px, 7vh, 40px); font-weight: 900; font-style: italic; line-height: 1.05; }\n    .headline.good { color: #9cf0b4; }\n    .headline.bad { color: #ff9b9b; }\n    .stats { display: flex; flex-wrap: wrap; gap: 8px; }\n    .stat { padding: 8px 12px; border-radius: 12px; background: #ffffff0f; border: 1px solid #ffffff17; min-width: 84px; }\n    .stat b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; }\n    .stat span { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: #9fb4d9; }\n    .team { display: flex; gap: 6px; flex-wrap: wrap; }\n    .mate { padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; background: #9cf0b422; color: #bff5cf; }\n    .mate.down { background: #ff8a8a22; color: #ffb4b4; text-decoration: line-through; }\n    .path { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: #9fb4d9; }\n    .path .p { padding: 3px 8px; border-radius: 8px; background: #ffffff0d; white-space: nowrap; }\n    .path .p.done { color: #e8ecf4; }\n    .path .p.next { background: #5aa2ff; color: #05060a; font-weight: 800; }\n    .path .p em { font-style: normal; color: #ffd27a; margin-left: 4px; }\n\n    /* Login */\n    .login { align-items: center; justify-content: center; gap: 34px; }\n    .login form { display: flex; flex-direction: column; gap: 10px; width: min(300px, 80vw); }\n    .login input { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 12px;\n      padding: 12px 14px; font-size: 16px; outline: none; -webkit-user-select: text; user-select: text; }\n    .login input:focus { border-color: #5aa2ff; }\n    .login .btn { justify-content: center; }\n    .err { color: #ff8a8a; font-size: 13px; min-height: 1em; }\n    .busy { position: absolute; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center;\n      background: #05060acc; font-size: 14px; color: #9fb4d9; }\n    .busy.on { display: flex; }\n\n    /* Main menu list */\n    .main-list .btn { padding: 11px 18px; }\n\n    /* Placeholder screens */\n    .stub { flex-direction: column; align-items: flex-start; justify-content: center; gap: 14px; }\n\n    /* /solo: breadcrumb header, campaign card, vehicles, four team slots */\n    .solo { flex-direction: column; gap: 8px; }\n    .solo-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex: 0 0 auto; }\n    .crumb { position: relative; display: flex; align-items: center; gap: 12px; padding-top: 14px; }\n    .crumb h1 { margin: 0; font-size: 28px; font-weight: 900; font-style: italic; letter-spacing: 0.06em; }\n    .crumb h1 > span:first-child { color: #5aa2ff; margin-right: 8px; }\n    .back { position: relative; width: 46px; height: 46px; flex: 0 0 auto; border-radius: 50%; border: 1px solid #ffffff2e;\n      background: #ffffff12; color: #e8ecf4; font: 800 22px/1 system-ui, sans-serif; cursor: pointer; display: grid; place-items: center;\n      touch-action: none; }\n    .back svg { position: absolute; inset: -4px; width: 54px; height: 54px; transform: rotate(-90deg); pointer-events: none; }\n    .back circle { fill: none; stroke: #5aa2ff; stroke-width: 3; stroke-linecap: round; stroke-dasharray: 157; stroke-dashoffset: 157; }\n    .back.holding { background: #5aa2ff33; border-color: #9cc6ff; }\n    .back-hint { position: absolute; left: 0; top: 66px; font-size: 11px; color: #9fb4d9; letter-spacing: 0.08em; opacity: 0;\n      transition: opacity 0.2s; pointer-events: none; white-space: nowrap; }\n    .back-hint.show { opacity: 1; }\n    .campaign { appearance: none; text-align: left; cursor: pointer; border: 1px solid #ffffff2e; border-radius: 12px; padding: 8px 14px;\n      background: linear-gradient(160deg, #ffffff16, #ffffff06); color: #e8ecf4; min-width: 190px; }\n    .campaign small { display: block; font-size: 10px; letter-spacing: 0.24em; text-transform: uppercase; color: #9fb4d9; }\n    .campaign b { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 16px; font-style: italic; }\n    .campaign b i { font-style: normal; color: #5aa2ff; font-size: 12px; }\n    .bay { position: relative; flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }\n    .col { position: relative; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; min-width: 0; }\n    .vehicle { flex: 1 1 auto; min-height: 40px; border: 1.5px dashed #ffffff2a; border-radius: 50% / 40%; display: grid; place-items: center;\n      font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: #ffffff55; }\n    .opts { display: none; flex-direction: column; gap: 6px; }\n    .col.open .opts { display: flex; }\n    .col.open .vehicle { display: none; }\n    .opt { appearance: none; border: 1px solid #ffffff2e; border-radius: 10px; padding: 8px 10px; background: #0c1222e6; color: #e8ecf4;\n      font: 700 13px/1.2 system-ui, sans-serif; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n    .opt small { font-size: 10px; color: #9fb4d9; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600; }\n    .opt.go { background: linear-gradient(180deg, #6fe3a0, #2fbf71); border-color: #b5f5cf; color: #05170d; }\n    .opt:active { transform: scale(0.97); }\n    .slot { appearance: none; cursor: pointer; border: 1px solid #ffffff2e; border-radius: 12px; padding: 8px 12px; text-align: left;\n      background: linear-gradient(160deg, #ffffff14, #ffffff05); color: #e8ecf4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n    .slot b { display: block; font-size: 15px; font-style: italic; letter-spacing: 0.04em; }\n    .slot small { display: block; font-size: 10px; color: #9fb4d9; letter-spacing: 0.12em; text-transform: uppercase; }\n    .slot .mark { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-weight: 900; font-size: 15px;\n      border: 1.5px solid #ffffff40; color: transparent; flex: 0 0 auto; }\n    .slot.ready { border-color: #6fe3a088; }\n    .slot.ready .mark { background: #2fbf71; border-color: #b5f5cf; color: #05170d; }\n    .slot.cpu { cursor: default; }\n    .depart { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); z-index: 5; display: none;\n      appearance: none; cursor: pointer; border: 1px solid #ffe2a8; border-radius: 999px; padding: 14px 46px;\n      background: linear-gradient(180deg, #ffd27a, #ff9f1a); color: #1a1000; font: 900 24px/1 system-ui, sans-serif; font-style: italic;\n      letter-spacing: 0.16em; text-transform: uppercase; box-shadow: 0 0 0 0 #ffb84d88; }\n    .depart.show { display: block; animation: departIn 0.25s ease-out forwards, departPulse 1.6s ease-in-out 0.25s infinite; }\n    @keyframes departIn { to { transform: translate(-50%, -50%) scale(1); } }\n    @keyframes departPulse { 0%, 100% { box-shadow: 0 0 0 0 #ffb84d88; } 50% { box-shadow: 0 0 0 14px #ffb84d00; } }\n    /* /multiplayer hub, lobby list, lobby room */\n    .mp { flex-direction: column; gap: 14px; }\n    .mp-actions { display: flex; flex-direction: column; gap: 12px; width: min(380px, 60vw); margin-top: 18px; }\n    .mp-actions .btn { padding: 16px 20px; font-size: 18px; }\n    .list { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; touch-action: pan-y;\n      -webkit-overflow-scrolling: touch; padding-bottom: 6px; }\n    .lrow { display: flex; align-items: center; gap: 14px; padding: 10px 14px; border-radius: 12px; border: 1px solid #ffffff22;\n      background: linear-gradient(160deg, #ffffff12, #ffffff05); }\n    .lrow .who { flex: 1 1 auto; min-width: 0; }\n    .lrow b { display: block; font-size: 15px; font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n    .lrow small { display: block; font-size: 11px; color: #9fb4d9; letter-spacing: 0.08em; text-transform: uppercase; }\n    .lrow .count { font: 800 15px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; color: #cfe0ff; }\n    .lrow .btn { padding: 9px 16px; font-size: 14px; }\n    .empty-note { color: #9fb4d9; font-size: 13px; padding: 18px 4px; }\n    .col.vacant .slot { border-style: dashed; cursor: default; opacity: 0.55; }\n    .col.vacant .vehicle { opacity: 0.4; }\n    .slot .host { display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 6px; background: #ff9f1a; color: #05060a;\n      font: 800 9px/1.5 system-ui, sans-serif; letter-spacing: 0.1em; font-style: normal; vertical-align: middle; }\n    .slot.me { box-shadow: inset 0 0 0 1px #5aa2ff88; }\n    .slot small.link { color: #ffd27a; }\n    .slot small.link.open, .slot small.link.host { color: #9cf0b4; }\n    .slot small.link.failed { color: #ff9b9b; }\n    .opt[disabled] { opacity: 0.4; }\n    .room-note { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%); z-index: 4; padding: 10px 18px;\n      border-radius: 999px; background: #000a; color: #cfe0ff; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; display: none;\n      white-space: nowrap; }\n    .room-note.show { display: block; }\n    .campaign[disabled] { cursor: default; }\n    /* Stage picker (the campaign selector) */\n    .picker { position: absolute; inset: 0; z-index: 25; display: none; flex-direction: column; gap: 12px;\n      padding: calc(14px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) calc(14px + env(safe-area-inset-bottom)) calc(20px + env(safe-area-inset-left));\n      background: #05060af2; }\n    .picker.on { display: flex; }\n    .picker-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n    .picker-head b { font-size: 22px; font-weight: 900; font-style: italic; letter-spacing: 0.04em; }\n    .picker-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n      gap: 8px; align-content: start; touch-action: pan-y; }\n    .stage { appearance: none; cursor: pointer; text-align: left; border: 1px solid #ffffff22; border-radius: 12px; padding: 10px 12px;\n      background: linear-gradient(160deg, #ffffff12, #ffffff05); color: #e8ecf4; }\n    .stage b { display: block; font-size: 15px; font-style: italic; }\n    .stage small { display: block; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: #9fb4d9; }\n    .stage.on { border-color: #9cc6ff; background: linear-gradient(180deg, #6fb0ff55, #3d82e833); }\n    .stage.full { grid-column: 1 / -1; }\n\n    .toast { position: absolute; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 30;\n      padding: 7px 16px; border-radius: 999px; background: #000c; color: #fff; font-size: 13px; font-weight: 700; opacity: 0;\n      transition: opacity 0.2s; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n\n    @media (max-height: 340px) { .btn { padding: 10px 14px; font-size: 14px; } .logo { font-size: 40px; } }\n    @media (orientation: portrait) {\n      .home, .route { flex-direction: column; justify-content: center; }\n      .actions, .route .right { flex: 0 0 auto; }\n    }\n  `,\n\n  // Render once; screens switch imperatively (the URL decides which one shows).\n  render() {\n    return `<div class=\"menu\" data-d2-static>\n      <div class=\"stars\"></div>\n\n      <section class=\"screen home\" data-screen=\"home\">\n        <div class=\"brand\">\n          <div class=\"tag\">Project</div>\n          <div class=\"logo\">TFSF</div>\n          <div class=\"hint\">Tilt to steer · swipe up to somersault · swipe down to U-turn (all-range)</div>\n        </div>\n        <div class=\"actions main-list\">\n          <button class=\"btn primary\" data-nav=\"/solo\">Solo</button>\n          <button class=\"btn\" data-nav=\"/multiplayer\">Multiplayer</button>\n          <button class=\"btn\" data-nav=\"/profile\">Player profile</button>\n          <button class=\"btn\" data-nav=\"/settings\">Settings</button>\n          <button class=\"btn\" data-nav=\"/starfox64\">Star Fox 64</button>\n          <button class=\"btn danger\" data-act=\"disconnect\">Disconnect</button>\n        </div>\n      </section>\n\n      <section class=\"screen solo\" data-screen=\"solo\">\n        <div class=\"solo-head\">\n          <div class=\"crumb\">\n            <button class=\"back\" data-back=\"/\" aria-label=\"Hold to go back\">‹<svg viewBox=\"0 0 54 54\"><circle cx=\"27\" cy=\"27\" r=\"25\"></circle></svg></button>\n            <h1><span>/</span>Solo</h1>\n            <div class=\"back-hint\">Hold 3 s to go back</div>\n          </div>\n          <button class=\"campaign\" data-act=\"campaign\"><small>Campaign</small><b><span class=\"solo-campaign-name\">Andross' Attack</span> <i>▼</i></b></button>\n        </div>\n        <div class=\"bay\"></div>\n        <button class=\"depart solo-depart\">Depart</button>\n      </section>\n\n      <section class=\"screen mp\" data-screen=\"mp\">\n        <div class=\"solo-head\">${crumb('Multiplayer', '/')}</div>\n        <div class=\"mp-actions\">\n          <button class=\"btn primary\" data-act=\"create-lobby\">Create lobby <small>Host a session</small></button>\n          <button class=\"btn\" data-nav=\"/multiplayer/lobbies\">Browse lobbies <small>Join one</small></button>\n        </div>\n      </section>\n\n      <section class=\"screen mp\" data-screen=\"browse\">\n        <div class=\"solo-head\">${crumb('Lobbies', '/multiplayer')}\n          <button class=\"btn\" data-act=\"create-lobby\">Create lobby</button>\n        </div>\n        <div class=\"list\"></div>\n      </section>\n\n      <section class=\"screen solo\" data-screen=\"room\">\n        <div class=\"solo-head\">\n          ${crumb('Lobby', '/multiplayer/lobbies')}\n          <button class=\"campaign room-campaign\" data-act=\"campaign\"><small>Campaign</small><b><span class=\"room-campaign-name\"></span> <i>▼</i></b></button>\n        </div>\n        <div class=\"bay room-bay\"></div>\n        <button class=\"depart room-depart\">Depart</button>\n        <div class=\"room-note\"></div>\n      </section>\n\n      <section class=\"screen stub\" data-screen=\"stub\">\n        <div class=\"kicker\">Coming soon</div>\n        <div class=\"headline stub-title\"></div>\n        <button class=\"btn\" data-go=\"home\">Main menu</button>\n      </section>\n\n      <section class=\"screen route\" data-screen=\"map\">\n        <div class=\"left\">\n          <div class=\"kicker map-kicker\"></div>\n          <div class=\"headline good map-title\"></div>\n          <div class=\"stats map-stats\"></div>\n          <div class=\"team map-team\"></div>\n          <div class=\"path map-path\"></div>\n        </div>\n        <div class=\"right map-actions\"></div>\n      </section>\n\n      <section class=\"screen route\" data-screen=\"results\">\n        <div class=\"left\">\n          <div class=\"kicker res-kicker\"></div>\n          <div class=\"headline res-title\"></div>\n          <div class=\"stats res-stats\"></div>\n          <div class=\"path res-path\"></div>\n        </div>\n        <div class=\"right\">\n          <button class=\"btn primary\" data-go=\"home\">Main menu</button>\n        </div>\n      </section>\n\n      <section class=\"screen login\" data-screen=\"login\">\n        <div class=\"brand\">\n          <div class=\"tag\">Project</div>\n          <div class=\"logo\">TFSF</div>\n        </div>\n        <form autocomplete=\"on\">\n          <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\n          <input name=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n          <button class=\"btn primary signin\" type=\"submit\">Sign in</button>\n          <div class=\"err login-err\"></div>\n        </form>\n      </section>\n\n      <div class=\"picker\">\n        <div class=\"picker-head\"><b>Choose a stage</b><button class=\"btn\" data-act=\"picker-close\">Close</button></div>\n        <div class=\"picker-grid\"></div>\n      </div>\n\n      <div class=\"busy\">Loading…</div>\n      <div class=\"toast\"></div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const $$ = (s) => Array.from(root.querySelectorAll(s))\n    const rt = window[RT_KEY] || (window[RT_KEY] = {})\n\n    // ---- navigation (same helper as the player) --------------------------------------------\n    const routerRef = String(d2.param('routerRef', '') || '')\n    const nav = (to) => {\n      try {\n        const r = routerRef ? d2.router(routerRef) : d2.router()\n        if (r && typeof r.goTo === 'function') { r.goTo(to); return }\n      } catch (e) {}\n      try { history.pushState(null, '', to); window.dispatchEvent(new PopStateEvent('popstate', { state: null })) } catch (e) { location.href = to }\n    }\n\n    // ---- session ---------------------------------------------------------------------------\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const sessionKey = `__data2-session-${systemDomain}`\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(sessionKey)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    // Signed in = the session may read the keys DataSet (the same check the player's download does).\n    const checkSession = async () => {\n      try {\n        const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n        if (res.status === 401 || res.status === 403) return false\n        if (!res.ok) return true // a server hiccup is not a sign-out; the player will say more\n        const body = await res.json()\n        const rows = Array.isArray(body) ? body : (body && body.data) || []\n        return !!(rows[0] && rows[0].key)\n      } catch (e) { return true }\n    }\n\n    // ---- the tap into the game: sound, motion, fullscreen (must run inside the gesture) -------\n    const unlockAudio = () => {\n      try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      try {\n        const live = (rt.engine && rt.engine.SDL2 && rt.engine.SDL2.audioContext) || rt.audio\n        if (live && live.state !== 'closed') { live.resume(); return }\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx) return\n        let ac\n        try { ac = new Ctx({ sampleRate: 32000 }) } catch (e) { ac = new Ctx() } // the game mixes at 32 kHz\n        ac.resume()\n        const src = ac.createBufferSource()\n        src.buffer = ac.createBuffer(1, 1, 22050)\n        src.connect(ac.destination)\n        src.start(0)\n        rt.audio = ac\n      } catch (e) {}\n    }\n    const askMotion = () => {\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') { try { Ev.requestPermission().catch(() => {}) } catch (e) {} }\n      }\n    }\n    const fullscreen = () => {\n      const el = document.documentElement\n      try { if (el.requestFullscreen && !document.fullscreenElement) el.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}) } catch (e) {}\n      try { screen.orientation?.lock?.('landscape')?.catch?.(() => {}) } catch (e) {}\n      try { navigator.wakeLock?.request?.('screen')?.catch?.(() => {}) } catch (e) {}\n    }\n    // Motion (gyro) and sound can only be granted inside a tap, so every tap in the menus asks: by\n    // the time a pilot departs it has long been granted (it lasts as long as the page).\n    d2.on(root, 'pointerup', () => { askMotion(); unlockAudio() })\n    const play = (query, tapped = true) => {\n      unlockAudio()\n      askMotion()\n      fullscreen()\n      // A multiplayer run (rt.mp, set at the lobby's departure) stays multiplayer for every later\n      // stage launched from /map; Solo and \"End this run\" end it.\n      if (rt.mp && !/(^|&)net=1/.test(query)) query += '&net=1'\n      rt.tappedAt = tapped ? Date.now() : 0 // a tap here: the player need not ask for one\n      nav(`/play?${query}`)\n    }\n\n    // ---- small helpers -----------------------------------------------------------------------\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1600)\n    }\n    const CONTROLS_KEY = `${STORE}.controls`\n    const controlsId = () => (CONTROLS.some((c) => c.id === readKey(CONTROLS_KEY)) ? readKey(CONTROLS_KEY) : 'motion')\n    const controlsLabel = () => CONTROLS.find((c) => c.id === controlsId()).label\n\n    // ---- screens ---------------------------------------------------------------------------\n    const lastEvent = () => {\n      if (rt.lastEvent) return rt.lastEvent\n      try { return JSON.parse(sessionStorage.getItem(`${STORE}.lastEvent`) || 'null') } catch (e) { return null }\n    }\n    // The engine keeps the run's state only while the page lives: after a reload it is gone.\n    const engineAlive = () => !!(rt.engine && !rt.crashed)\n\n    const show = (name) => {\n      $$('.screen').forEach((s) => s.classList.toggle('on', s.dataset.screen === name))\n    }\n\n    const pathHtml = (ev, nextPlanet) => {\n      const route = ev.route || []\n      const hits = ev.routeHits || []\n      const medals = ev.routeMedals || []\n      const done = Math.max(0, Number(ev.mission) || 0)\n      const parts = []\n      for (let i = 0; i < route.length; i++) {\n        const p = route[i]\n        if (p < 0 || p == null) continue\n        if (i === 6 && p === 13 && i > done) continue // the reserved Venom slot, not reached yet\n        const isNext = i === done && nextPlanet != null\n        const cls = isNext ? 'next' : i < done ? 'done' : ''\n        const extra = i < done ? `<em>${hits[i] || 0}${medals[i] ? ' ★' : ''}</em>` : ''\n        parts.push(`<span class=\"p ${cls}\">${esc(planetName(isNext ? nextPlanet : p))}${extra}</span>`)\n      }\n      return parts.join('<span>›</span>')\n    }\n\n    const stat = (value, label) => `<div class=\"stat\"><b>${esc(value)}</b><span>${esc(label)}</span></div>`\n\n    const paintMap = () => {\n      const ev = lastEvent()\n      const actions = $('.map-actions')\n      if (!ev || ev.event !== 'level-end' || !engineAlive()) {\n        $('.map-kicker').textContent = 'Route'\n        $('.map-title').textContent = 'No run in progress'\n        $('.map-title').className = 'headline map-title'\n        $('.map-stats').innerHTML = ''\n        $('.map-team').innerHTML = ''\n        $('.map-path').innerHTML = ev && ev.route ? pathHtml(ev, null) : ''\n        actions.innerHTML = `<div class=\"hint\">The game keeps a run only while this page stays open.</div>\n          <button class=\"btn primary\" data-go=\"home\">Main menu</button>`\n        return\n      }\n      $('.map-kicker').textContent = planetName(ev.planet)\n      $('.map-title').textContent = STATUS[ev.status] || 'Mission over'\n      $('.map-title').className = 'headline good map-title'\n      $('.map-stats').innerHTML = [stat(ev.hits, 'Hits'), stat(ev.totalHits, 'Total hits'), stat(ev.lives, 'Lives'),\n        ev.medal ? stat('★', 'Medal') : ''].join('')\n      const names = ['Falco', 'Slippy', 'Peppy']\n      $('.map-team').innerHTML = names.map((n, i) => `<span class=\"mate ${ev.team && ev.team[i] ? '' : 'down'}\">${n}</span>`).join('')\n      $('.map-path').innerHTML = pathHtml(ev, ev.next)\n      const buttons = [`<button class=\"btn primary\" data-play=\"cmd=continue\">Fly to ${esc(planetName(ev.next))} <small>Continue</small></button>`]\n      if (ev.alt != null && ev.alt >= 0) buttons.push(`<button class=\"btn\" data-play=\"cmd=alt\">Fly to ${esc(planetName(ev.alt))} <small>Other route</small></button>`)\n      if (ev.lives > 0) buttons.push(`<button class=\"btn\" data-play=\"cmd=retry\">Retry ${esc(planetName(ev.planet))} <small>−1 life</small></button>`)\n      buttons.push(`<button class=\"btn danger\" data-act=\"abandon\">End this run</button>`)\n      actions.innerHTML = buttons.join('')\n    }\n\n    const paintResults = () => {\n      const ev = lastEvent() || {}\n      const ending = ev.event === 'ending'\n      $('.res-kicker').textContent = ending ? 'Lylat system saved' : 'Mission failed'\n      $('.res-title').textContent = ending ? 'The End' : 'Game Over'\n      $('.res-title').className = `headline ${ending ? 'good' : 'bad'} res-title`\n      $('.res-stats').innerHTML = [stat(ev.totalHits || 0, 'Total hits'), stat((Number(ev.mission) || 0) + (ending ? 1 : 0), 'Planets'),\n        ev.expert ? stat('Expert', 'Mode') : ''].join('')\n      $('.res-path').innerHTML = ev.route ? pathHtml({ ...ev, mission: (Number(ev.mission) || 0) + 1 }, null) : ''\n    }\n\n    // ---- /solo: the team lobby -------------------------------------------------------------------\n    // Ready state lives only while /solo is open: every visit starts with the player not ready.\n    const lobby = { ready: TEAM.map((m) => m.cpu) }\n    const paintSolo = () => {\n      $('.bay').innerHTML = TEAM.map((m, i) => {\n        const ready = lobby.ready[i]\n        const open = !m.cpu && !ready\n        return `<div class=\"col ${open ? 'open' : ''}\" data-key=\"${i}\">\n          <div class=\"vehicle\">Vehicle</div>\n          <div class=\"opts\">\n            <button class=\"opt\" data-opt=\"pilot\" data-i=\"${i}\">Pilot <small>${esc(m.pilot)}</small></button>\n            <button class=\"opt\" data-opt=\"controls\" data-i=\"${i}\">Controls <small>${esc(controlsLabel())}</small></button>\n            <button class=\"opt go\" data-opt=\"ready\" data-i=\"${i}\">Ready <span>✓</span></button>\n          </div>\n          <button class=\"slot ${ready ? 'ready' : ''} ${m.cpu ? 'cpu' : ''}\" data-slot=\"${i}\">\n            <span><b>${esc(m.slot)}</b><small>${esc(m.pilot)}${m.cpu ? ' · CPU' : ''}</small></span>\n            <span class=\"mark\">✓</span>\n          </button>\n        </div>`\n      }).join('')\n      $('.solo-depart').classList.toggle('show', lobby.ready.every(Boolean))\n    }\n    d2.on('[data-opt]', 'click', (e, el) => {\n      const i = Number(el.dataset.i)\n      const opt = el.dataset.opt\n      if (opt === 'pilot') return toast('Pilot selection is coming soon')\n      if (opt === 'controls') {\n        const next = CONTROLS[(CONTROLS.findIndex((c) => c.id === controlsId()) + 1) % CONTROLS.length]\n        try { localStorage.setItem(CONTROLS_KEY, next.id) } catch (err) {}\n        toast(`Controls: ${next.label}`)\n        return paintSolo()\n      }\n      if (opt === 'ready') { lobby.ready[i] = true; paintSolo() }\n    })\n    // Tapping your own ready slot takes it back (the options come back with it).\n    d2.on('[data-slot]', 'click', (e, el) => {\n      const i = Number(el.dataset.slot)\n      if (TEAM[i].cpu || !lobby.ready[i]) return\n      lobby.ready[i] = false\n      paintSolo()\n    })\n    d2.on('.solo-depart', 'click', () => {\n      if (!lobby.ready.every(Boolean)) return\n      rt.mp = null\n      const st = stageCmd(soloStage())\n      play(st.cmd === 'level' ? `cmd=level&level=${st.level}` : 'cmd=new&expert=0')\n    })\n\n    // ---- the campaign selector: a stage picker (solo, and the host of a lobby) ------------------------\n    const STAGE_KEY = `${STORE}.soloStage`\n    const soloStage = () => { const v = Number(readKey(STAGE_KEY)); return STAGES.some((st) => st.id === v) && readKey(STAGE_KEY) !== '' ? v : -1 }\n    const paintSoloCampaign = () => { $('.solo-campaign-name').textContent = stageOf(soloStage()).name }\n    const picker = { for: '' }\n    const openPicker = (forWhat, current) => {\n      picker.for = forWhat\n      $('.picker-grid').innerHTML = STAGES.map((st) => `<button class=\"stage ${st.id === current ? 'on' : ''} ${st.id < 0 ? 'full' : ''}\" data-stage=\"${st.id}\">\n        <b>${esc(st.name)}</b><small>${esc(st.kind)}</small></button>`).join('')\n      $('.picker').classList.add('on')\n    }\n    const closePicker = () => { $('.picker').classList.remove('on'); picker.for = '' }\n    d2.on('[data-stage]', 'click', (e, el) => {\n      const id = Number(el.dataset.stage)\n      const target = picker.for\n      closePicker()\n      if (target === 'solo') {\n        try { localStorage.setItem(STAGE_KEY, String(id)) } catch (err) {}\n        paintSoloCampaign()\n      } else if (target === 'room') {\n        pickRoomStage(id)\n      }\n    })\n\n    // Back buttons (every breadcrumb): hold for HOLD_BACK_MS (the ring fills) to go to data-back;\n    // a short tap only shows the hint.\n    const hold = { el: null, t0: 0, raf: 0 }\n    const stopHold = () => {\n      cancelAnimationFrame(hold.raf)\n      if (hold.el) {\n        hold.el.classList.remove('holding')\n        hold.el.querySelector('circle').style.strokeDashoffset = '157'\n      }\n      hold.el = null\n    }\n    const tickHold = () => {\n      const el = hold.el\n      if (!el) return\n      const f = Math.min(1, (performance.now() - hold.t0) / HOLD_BACK_MS)\n      el.querySelector('circle').style.strokeDashoffset = String(157 * (1 - f))\n      if (f >= 1) {\n        const to = el.dataset.back || '/'\n        stopHold()\n        try { navigator.vibrate && navigator.vibrate(20) } catch (e) {}\n        return goBack(to)\n      }\n      hold.raf = requestAnimationFrame(tickHold)\n    }\n    d2.on('.back', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      stopHold()\n      try { el.setPointerCapture(e.pointerId) } catch (err) {}\n      hold.el = el\n      hold.t0 = performance.now()\n      el.classList.add('holding')\n      const hint = el.parentElement.querySelector('.back-hint')\n      if (hint) {\n        hint.classList.add('show')\n        clearTimeout(hint._t)\n        hint._t = setTimeout(() => hint.classList.remove('show'), 2000)\n      }\n      hold.raf = requestAnimationFrame(tickHold)\n    })\n    for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) d2.on('.back', ev, stopHold)\n    d2.on('.back', 'contextmenu', (e) => e.preventDefault())\n    d2.onUnmount(() => cancelAnimationFrame(hold.raf))\n\n    // ---- multiplayer: who am I ---------------------------------------------------------------------\n    // The login form remembers the email; older sessions fall back to the auth handle, then the token.\n    const ME_KEY = `${STORE}.me`\n    const me = () => {\n      let email = readKey(ME_KEY)\n      if (!email) { try { const u = d2.auth(d2.param('auth'))?.user; email = (u && (u.email || (u.credentials && u.credentials.email))) || '' } catch (e) {} }\n      if (!email) {\n        try {\n          const part = readKey(sessionKey).split('.')[1]\n          const claims = part ? JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) : {}\n          email = claims.email || (claims.credentials && claims.credentials.email) || ''\n        } catch (e) {}\n      }\n      if (!email) {\n        // Unknown session: a stable guest id for this browser, so a lobby still knows who is who.\n        email = readKey(`${STORE}.guest`) || `guest-${Math.random().toString(36).slice(2, 8)}`\n        try { localStorage.setItem(`${STORE}.guest`, email) } catch (e) {}\n      }\n      return email\n    }\n    const nameOf = (email) => String(email || '').split('@')[0] || 'Pilot'\n    // The lobby this browser is seated in, so reopening the app can bring the pilot back to it.\n    const ROOM_KEY = `${STORE}.room`\n    const seat = (id) => { try { if (id) localStorage.setItem(ROOM_KEY, id); else localStorage.removeItem(ROOM_KEY) } catch (e) {} }\n\n    // ---- multiplayer: data -------------------------------------------------------------------------\n    const lobbiesDs = () => d2.data('lobbies')\n    const playersDs = () => d2.data('lobbyPlayers')\n    const rows = (r) => (Array.isArray(r) ? r : (r && r.data) || [])\n    const find = async (ds, q) => rows(await ds.query(q))\n    // Partial update: only the given fields change (the DataSet's update() refuses with 409 here).\n    const patch = (ds, rec) => ds.raw.saveRecord(rec)\n    // The server's clock, as seen from here: skew = server time - local time, measured on our own\n    // writes (the row comes back stamped by the server) and remembered for the next screen.\n    const SKEW_KEY = `${STORE}.clockSkew`\n    const clock = { skew: Number(readKey(SKEW_KEY)) || 0 }\n    const serverNow = () => Date.now() + clock.skew\n    // A row not stamped yet (just created, the server's copy not back) counts as written now.\n    const stampOf = (p) => Date.parse((p && (p.updatedAt || p.createdAt)) || '') || serverNow()\n    const learnSkew = (rec, sentAt) => {\n      const at = Date.parse((rec && rec.updatedAt) || '')\n      if (!at) return\n      clock.skew = Math.round(at - (sentAt + Date.now()) / 2)\n      try { localStorage.setItem(SKEW_KEY, String(clock.skew)) } catch (e) {}\n    }\n    const live = (p, now = serverNow()) => stampOf(p) > now - LIVE_MS\n    const joinedOf = (p) => Date.parse((p && p.createdAt) || '') || Number.MAX_SAFE_INTEGER\n    const byJoin = (a, b) => joinedOf(a) - joinedOf(b) || String(a._id).localeCompare(String(b._id))\n    // Heartbeat: touch our own row; the server stamps it (updatedAt), which is what the others read.\n    const touch = async (mine, fields = {}) => {\n      const sentAt = Date.now()\n      const rec = await patch(playersDs(), { _id: mine._id, seen: sentAt, ...fields })\n      learnSkew(rec, sentAt)\n      return rec\n    }\n    const newCode = () => Array.from({ length: 4 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ'[Math.floor(Math.random() * 23)]).join('')\n\n    let busy = false\n    const createLobby = async () => {\n      if (busy) return\n      busy = true\n      try {\n        const email = me()\n        const now = Date.now()\n        const lobby = await lobbiesDs().create({\n          code: newCode(), host: email, hostName: nameOf(email), maxPlayers: MAX_PLAYERS, campaign: DEFAULT_CAMPAIGN,\n          status: 'open', players: [{ email, name: nameOf(email) }],\n        })\n        if (!lobby || !lobby._id) throw new Error('no lobby id')\n        await playersDs().create({ lobby: String(lobby._id), email, name: nameOf(email), pilot: 'fox', ready: false, seen: now, joinedAt: now })\n        nav(`/multiplayer/lobbies/${lobby._id}`)\n      } catch (e) {\n        toast('Could not create the lobby')\n        console.warn('[TFSF] create lobby', e)\n      } finally {\n        busy = false\n      }\n    }\n\n    // ---- /multiplayer/lobbies: the open lobbies, with their live pilot count ------------------------\n    const browse = { timer: 0 }\n    const paintBrowse = async () => {\n      const list = $('.list')\n      try {\n        const lobbies = await find(lobbiesDs(), { status: 'open', $sort: { createdAt: -1 }, $limit: 40 })\n        const ids = lobbies.map((l) => String(l._id))\n        const now = serverNow()\n        const pilots = ids.length ? await find(playersDs(), { lobby: { $in: ids }, $limit: 200 }) : []\n        const count = {}\n        pilots.filter((p) => live(p, now)).forEach((p) => { count[p.lobby] = (count[p.lobby] || 0) + 1 })\n        const open = lobbies.filter((l) => count[String(l._id)])\n        if (current !== 'browse') return\n        list.innerHTML = open.length\n          ? open.map((l) => {\n            const n = count[String(l._id)]\n            const full = n >= (Number(l.maxPlayers) || MAX_PLAYERS)\n            return `<div class=\"lrow\" data-key=\"${esc(l._id)}\">\n              <div class=\"who\"><b>${esc(l.hostName || nameOf(l.host))}'s lobby</b><small>${esc(l.code || '')} · ${esc(l.campaign || DEFAULT_CAMPAIGN)}</small></div>\n              <div class=\"count\">${n}/${Number(l.maxPlayers) || MAX_PLAYERS}</div>\n              <button class=\"btn ${full ? '' : 'primary'}\" data-join=\"${esc(l._id)}\" ${full ? 'disabled' : ''}>${full ? 'Full' : 'Join'}</button>\n            </div>`\n          }).join('')\n          : '<div class=\"empty-note\">No open lobbies right now. Create one and invite your wingmen.</div>'\n      } catch (e) {\n        if (current === 'browse') list.innerHTML = '<div class=\"empty-note\">Could not load lobbies. Retrying…</div>'\n      }\n    }\n    const startBrowse = () => {\n      $('.list').innerHTML = '<div class=\"empty-note\">Scanning for lobbies…</div>'\n      paintBrowse()\n      clearInterval(browse.timer)\n      browse.timer = setInterval(paintBrowse, 3000)\n    }\n    const stopBrowse = () => clearInterval(browse.timer)\n    d2.onUnmount(stopBrowse)\n    d2.on('[data-join]', 'click', (e, el) => nav(`/multiplayer/lobbies/${el.dataset.join}`))\n\n    // ---- /multiplayer/lobbies/:_id: the room --------------------------------------------------------\n    const room = { id: '', lobby: null, pilots: [], mine: null, timer: 0, joining: false, gone: '' }\n    const amHost = () => !!(room.lobby && room.lobby.host === me())\n    const livePilots = () => { const now = serverNow(); return room.pilots.filter((p) => live(p, now)).sort(byJoin) }\n\n    // ---- lobby socket: instant refresh ------------------------------------------------------------\n    // One room per lobby. Every write is followed by a ping; on a ping the others re-read the tables\n    // (the rows are the truth, the ping only says \"look now\"). Polling keeps working without it.\n    const sockRoom = (id) => `tfsf-lobby-${id}`\n    const sock = () => { try { return d2.socket('socket') } catch (e) { return null } }\n    const announce = (kind) => {\n      const s = sock()\n      if (!s || !room.id) return\n      try { Promise.resolve(s.emit({ lobby: room.id, kind, from: me() }, sockRoom(room.id))).catch(() => {}) } catch (e) {}\n    }\n    let soonTimer = 0\n    const refreshSoon = () => { clearTimeout(soonTimer); soonTimer = setTimeout(() => { beat(); signal() }, 120) }\n    try {\n      const s = sock()\n      if (s && typeof s.onMessage === 'function') {\n        const off = s.onMessage((ev) => {\n          let msg = ev && ev.data\n          try { if (typeof msg === 'string') msg = JSON.parse(msg) } catch (e) { return }\n          if (!msg || current !== 'room' || msg.lobby !== room.id || msg.from === me()) return\n          refreshSoon()\n        })\n        if (typeof off === 'function') d2.onUnmount(off)\n      }\n    } catch (e) {}\n    const joinSock = (id) => { const s = sock(); if (s) { try { Promise.resolve(s.subscribe(sockRoom(id))).catch(() => {}) } catch (e) {} } }\n    const leaveSock = (id) => { const s = sock(); if (s && id) { try { Promise.resolve(s.unsubscribe(sockRoom(id))).catch(() => {}) } catch (e) {} } }\n\n    // ---- pilots -------------------------------------------------------------------------------------\n    // Each pilot is taken by at most one player; on a clash the later joiner moves to a free one.\n    const takenPilots = (exceptEmail) => livePilots().filter((p) => p.email !== exceptEmail).map((p) => p.pilot).filter(Boolean)\n    const freePilot = (exceptEmail) => { const taken = takenPilots(exceptEmail); return (PILOTS.find((p) => !taken.includes(p.id)) || PILOTS[0]).id }\n    const fixPilotClash = async () => {\n      const mine = room.mine\n      if (!mine) return\n      const earlier = livePilots().filter((p) => p.email !== mine.email && byJoin(p, mine) < 0)\n      if (!mine.pilot || earlier.some((p) => p.pilot === mine.pilot)) {\n        const taken = earlier.map((p) => p.pilot)\n        const pick = (PILOTS.find((p) => !taken.includes(p.id)) || PILOTS[0]).id\n        if (pick !== mine.pilot) {\n          mine.pilot = pick\n          await touch(mine, { pilot: pick })\n          announce('pilot')\n        }\n      }\n    }\n\n    // ---- links (WebRTC to the host) -------------------------------------------------------------------\n    const net = rt.net || (rt.net = makeNet())\n    const signalsDs = () => d2.data('signals')\n    const sid = () => Math.random().toString(36).slice(2, 10)\n    // Guest: dial the host (one offer row per attempt).\n    const dialHost = async () => {\n      const host = net.host\n      const s = sid()\n      const peer = net.newPeer(host, s)\n      peer.dialing = true\n      net.wire(peer, peer.pc.createDataChannel('u', { ordered: false, maxRetransmits: 0 }), false)\n      net.wire(peer, peer.pc.createDataChannel('r', { ordered: true }), true)\n      await peer.pc.setLocalDescription(await peer.pc.createOffer())\n      await net.gathered(peer.pc)\n      if (net.peers.get(host) !== peer) return\n      for (const old of await find(signalsDs(), { lobby: net.lobby, from: net.me, $limit: 20 })) { try { await signalsDs().remove(old) } catch (e) {} }\n      await signalsDs().create({ lobby: net.lobby, from: net.me, to: host, kind: 'offer', sid: s, sdp: peer.pc.localDescription.sdp, at: Date.now() })\n      announce('signal')\n    }\n    // One signaling pass: the host answers new offers; a guest picks up its answer, or redials.\n    const signal = async () => {\n      if (!net.lobby || !net.me) return\n      try {\n        if (net.isHost()) {\n          const offers = await find(signalsDs(), { lobby: net.lobby, to: net.me, kind: 'offer', $limit: 20 })\n          for (const o of offers) {\n            const key = `${o.from}:${o.sid}`\n            if (net.handled.has(key)) continue\n            net.handled.add(key)\n            const peer = net.newPeer(o.from, o.sid)\n            await peer.pc.setRemoteDescription({ type: 'offer', sdp: o.sdp })\n            await peer.pc.setLocalDescription(await peer.pc.createAnswer())\n            await net.gathered(peer.pc)\n            for (const old of await find(signalsDs(), { lobby: net.lobby, from: net.me, to: o.from, $limit: 20 })) { try { await signalsDs().remove(old) } catch (e) {} }\n            await signalsDs().create({ lobby: net.lobby, from: net.me, to: o.from, kind: 'answer', sid: o.sid, sdp: peer.pc.localDescription.sdp, at: Date.now() })\n            announce('signal')\n          }\n          // Guests that left: drop their links.\n          const live = new Set(livePilots().map((p) => p.email))\n          for (const email of [...net.peers.keys()]) if (!live.has(email)) net.closePeer(email)\n        } else if (net.host) {\n          const peer = net.peers.get(net.host)\n          const dead = !peer || peer.state === 'failed' || peer.state === 'closed' ||\n            (peer.state === 'linking' && Date.now() - peer.startedAt > LINK_TIMEOUT_MS)\n          if (dead) return dialHost()\n          if (peer.state === 'linking' && peer.dialing && !peer.pc.remoteDescription) {\n            const [ans] = await find(signalsDs(), { lobby: net.lobby, to: net.me, kind: 'answer', sid: peer.sid, $limit: 1 })\n            if (ans && net.peers.get(net.host) === peer && !peer.pc.remoteDescription) await peer.pc.setRemoteDescription({ type: 'answer', sdp: ans.sdp })\n          }\n        }\n      } catch (e) {\n        console.warn('[TFSF] signaling', e)\n      }\n    }\n    // Point the runtime at this lobby / host (a host change makes guests redial).\n    const syncNet = () => {\n      const L = room.lobby\n      if (!L || !room.mine) return\n      if (net.lobby !== room.id || net.me !== me()) { net.closeAll(); net.handled.clear(); net.lobby = room.id; net.me = me() }\n      if (net.host !== L.host) { net.closeAll(); net.host = L.host }\n      clearInterval(room.sigTimer)\n      room.sigTimer = setInterval(signal, SIG_MS)\n    }\n    const linkOf = (email) => {\n      if (email === me()) {\n        if (net.isHost()) return { state: 'host' }\n        const p = net.peers.get(net.host)\n        return p ? { state: p.state, rtt: p.rtt } : { state: 'linking' }\n      }\n      if (email === net.host) return { state: 'host' }\n      if (net.isHost()) { const p = net.peers.get(email); return p ? { state: p.state, rtt: p.rtt } : { state: 'linking' } }\n      return net.links[email] || { state: 'linking' }\n    }\n    const linkLabel = (l) => (l.state === 'host' ? 'Host' : l.state === 'open' ? `Linked · ${l.rtt || '…'} ms` : l.state === 'failed' ? 'No route — retrying' : 'Linking…')\n    const allLinked = () => livePilots().every((p) => { const l = linkOf(p.email); return l.state === 'host' || l.state === 'open' })\n    // Depart from the host: everyone gets it over the link (and from the lobby row as a fallback).\n    const offDepart = net.on('depart', (msg) => { if (current === 'room' && !net.isHost()) launch(msg.start) })\n    d2.onUnmount(offDepart)\n    const launch = (start) => {\n      if (room.departing) return\n      room.departing = true\n      seat('')\n      clearInterval(room.timer)\n      clearInterval(room.sigTimer)\n      rt.mp = { lobby: room.id, start, me: me(), host: net.host, pilots: Object.fromEntries(livePilots().map((p) => [p.email, p.pilot || 'fox'])), names: Object.fromEntries(livePilots().map((p) => [p.email, p.name || nameOf(p.email)])) }\n      // Guests are launched by the host's message, not by a tap of theirs: they still tap Ready.\n      const level = start && start.cmd === 'level' ? `&level=${Number(start.level) || 0}` : ''\n      play(`cmd=${encodeURIComponent((start && start.cmd) || 'new')}${level}&net=1`, net.isHost())\n    }\n\n    // The host picks the lobby's stage; everyone sees it on the lobby row.\n    const roomStage = () => { const v = room.lobby && room.lobby.stage; return Number.isFinite(Number(v)) && v !== null && v !== undefined ? Number(v) : -1 }\n    const pickRoomStage = async (id) => {\n      if (!amHost() || !room.lobby) return\n      const st = stageOf(id)\n      room.lobby = { ...room.lobby, stage: st.id, campaign: st.name }\n      paintRoom()\n      try { await patch(lobbiesDs(), { _id: room.id, stage: st.id, campaign: st.name }); announce('stage') } catch (e) { toast('Could not change the stage') }\n    }\n    const paintRoom = () => {\n      if (current !== 'room') return\n      const L = room.lobby\n      $('.room-campaign-name').textContent = (L && L.campaign) || DEFAULT_CAMPAIGN\n      $('.room-campaign').disabled = !amHost()\n      $('[data-screen=\"room\"] .crumb-title').textContent = L ? `Lobby ${L.code || ''}` : 'Lobby'\n      const note = $('.room-note')\n      const depart = $('.room-depart')\n      if (room.gone) {\n        $('.room-bay').innerHTML = ''\n        depart.classList.remove('show')\n        note.textContent = room.gone\n        note.classList.add('show')\n        return\n      }\n      const pilots = livePilots()\n      const myEmail = me()\n      const cols = []\n      for (let i = 0; i < MAX_PLAYERS; i++) {\n        const p = pilots[i]\n        if (!p) {\n          cols.push(`<div class=\"col vacant\" data-key=\"v${i}\"><div class=\"vehicle\">Vehicle</div>\n            <div class=\"slot\"><span><b>Open slot</b><small>Waiting for a pilot</small></span><span class=\"mark\"></span></div></div>`)\n          continue\n        }\n        const mine = p.email === myEmail\n        const open = mine && !p.ready\n        const host = L && p.email === L.host\n        const awayFor = mine ? 0 : serverNow() - stampOf(p)\n        const link = awayFor > AWAY_MS\n          ? { state: 'failed', label: `Disconnected · kicked in ${Math.max(0, Math.ceil((LIVE_MS - awayFor) / 1000))} s` }\n          : linkOf(p.email)\n        const taken = mine ? takenPilots(myEmail) : []\n        const opts = mine && room.picking\n          ? PILOTS.map((pl) => `<button class=\"opt ${pl.id === p.pilot ? 'go' : ''}\" data-pick=\"${pl.id}\" ${taken.includes(pl.id) ? 'disabled' : ''}>${pl.name} <small>${taken.includes(pl.id) ? 'Taken' : pl.id === p.pilot ? 'Yours' : ''}</small></button>`).join('')\n          : `<button class=\"opt\" data-ropt=\"pilot\">Pilot <small>${esc(pilotName(p.pilot))}</small></button>\n            <button class=\"opt\" data-ropt=\"controls\">Controls <small>${esc(controlsLabel())}</small></button>\n            <button class=\"opt go\" data-ropt=\"ready\">Ready <span>✓</span></button>`\n        cols.push(`<div class=\"col ${open ? 'open' : ''}\" data-key=\"${esc(p._id)}\">\n          <div class=\"vehicle\">${esc(pilotName(p.pilot))}'s Arwing</div>\n          <div class=\"opts\">${opts}</div>\n          <button class=\"slot ${p.ready ? 'ready' : ''} ${mine ? 'me' : 'cpu'}\" ${mine ? 'data-rslot=\"1\"' : ''}>\n            <span><b>${esc(pilotName(p.pilot))}${host ? '<span class=\"host\">HOST</span>' : ''}</b><small>${esc(p.name || nameOf(p.email))}${mine ? ' · You' : ''}</small><small class=\"link ${esc(link.state)}\">${esc(link.label || linkLabel(link))}</small></span>\n            <span class=\"mark\">✓</span>\n          </button>\n        </div>`)\n      }\n      $('.room-bay').innerHTML = cols.join('')\n      const allReady = pilots.length > 0 && pilots.every((p) => p.ready)\n      const linked = allLinked()\n      depart.classList.toggle('show', allReady && linked && amHost())\n      note.textContent = allReady ? (linked ? 'Waiting for the host to depart' : 'Linking pilots…') : ''\n      note.classList.toggle('show', allReady && (!amHost() || !linked))\n    }\n\n    // Join (or re-join) this lobby: one lobbyPlayers row per pilot.\n    const joinRoom = async () => {\n      const myEmail = me()\n      const lobby = (await find(lobbiesDs(), { _id: room.id }))[0]\n      if (!lobby) { room.gone = 'This lobby does not exist anymore'; return false }\n      room.lobby = lobby\n      const pilots = await find(playersDs(), { lobby: room.id, $limit: 50 })\n      room.pilots = pilots\n      let mine = pilots.find((p) => p.email === myEmail)\n      const now = Date.now()\n      if (!mine) {\n        if (lobby.status !== 'open') { room.gone = 'This lobby has already departed'; return false }\n        if (pilots.filter((p) => live(p)).length >= (Number(lobby.maxPlayers) || MAX_PLAYERS)) { room.gone = 'This lobby is full'; return false }\n        mine = await playersDs().create({ lobby: room.id, email: myEmail, name: nameOf(myEmail), pilot: freePilot(myEmail), ready: false, seen: now, joinedAt: now })\n        if (!mine || !mine._id) throw new Error('no player id')\n        learnSkew(mine, now)\n        room.pilots.push(mine)\n        // Two pilots can take the last seat at once: the later joiner gives it back.\n        const after = (await find(playersDs(), { lobby: room.id, $limit: 50 })).filter((p) => live(p)).sort(byJoin)\n        if (after.findIndex((p) => p.email === myEmail) >= (Number(lobby.maxPlayers) || MAX_PLAYERS)) {\n          try { await playersDs().remove(mine) } catch (e) {}\n          room.gone = 'This lobby is full'\n          return false\n        }\n      } else {\n        Object.assign(mine, await touch(mine) || {})\n      }\n      room.mine = mine\n      seat(room.id)\n      // The pilot was picked before this phone knew the server's clock (from the row just written),\n      // so the others' rows may have looked stale: check it again now.\n      try { await fixPilotClash() } catch (e) {}\n      announce('join')\n      return true\n    }\n\n    // Every BEAT_MS: heartbeat, refresh, and (as host) housekeeping.\n    let beat = async () => {\n      if (current !== 'room' || !room.mine) return\n      try {\n        const sentAt = Date.now()\n        const touched = await touch(room.mine)\n        const [lobby] = await find(lobbiesDs(), { _id: room.id })\n        if (!lobby) { room.gone = 'The lobby was closed'; seat(''); return paintRoom() }\n        room.lobby = lobby\n        if (lobby.status === 'departed' && !amHost()) return launch(lobby.start)\n        room.pilots = await find(playersDs(), { lobby: room.id, $limit: 50 })\n        const myRow = room.pilots.find((p) => String(p._id) === String(room.mine._id))\n        if (!myRow) { room.gone = 'You were away too long and left this lobby'; seat(''); return paintRoom() }\n        room.mine = myRow\n        // No record back from the save: our row, as just read, carries the server's stamp of it.\n        if (!(touched && touched.updatedAt)) learnSkew(myRow, sentAt)\n        const now = serverNow()\n        await fixPilotClash()\n        const pilots = livePilots()\n        // The host is gone: the earliest-joined live pilot takes over.\n        if (!pilots.some((p) => p.email === lobby.host) && pilots[0] && pilots[0].email === me()) {\n          await patch(lobbiesDs(), { _id: room.id, host: me(), hostName: nameOf(me()) })\n          room.lobby = { ...lobby, host: me(), hostName: nameOf(me()) }\n          announce('host')\n        }\n        if (net.host !== room.lobby.host) syncNet()\n        if (amHost()) {\n          // Forget pilots long gone, and keep the lobby's players snapshot current.\n          for (const p of room.pilots) if (stampOf(p) < now - PRUNE_MS) { try { await playersDs().remove(p) } catch (e) {} }\n          const snap = pilots.map((p) => ({ email: p.email, name: p.name, pilot: p.pilot || '', ready: !!p.ready }))\n          if (JSON.stringify(snap) !== JSON.stringify(room.lobby.players || [])) {\n            await patch(lobbiesDs(), { _id: room.id, players: snap })\n            room.lobby.players = snap\n          }\n        }\n      } catch (e) {\n        console.warn('[TFSF] lobby refresh', e)\n      }\n      paintRoom()\n    }\n    // beat() can be asked for by a socket ping while one is running: run one at a time.\n    const beatOnce = beat\n    let beating = null\n    beat = () => (beating || (beating = beatOnce().finally(() => { beating = null })))\n\n    const enterRoom = async (id) => {\n      leaveRoomTimers()\n      Object.assign(room, { id, lobby: null, pilots: [], mine: null, gone: '', joining: true, picking: false, departing: false })\n      $('.room-bay').innerHTML = ''\n      $('.room-depart').classList.remove('show')\n      $('.room-note').textContent = 'Joining…'\n      $('.room-note').classList.add('show')\n      try {\n        const ok = await joinRoom()\n        room.joining = false\n        paintRoom()\n        if (ok) {\n          joinSock(id)\n          beat()\n          room.timer = setInterval(() => beat(), BEAT_MS)\n          syncNet()\n          signal()\n          // Link status changes faster than the lobby beat: repaint every second.\n          room.paintTimer = setInterval(paintRoom, 1000)\n        }\n      } catch (e) {\n        room.joining = false\n        room.gone = 'Could not join this lobby'\n        paintRoom()\n      }\n    }\n    const leaveRoomTimers = () => { clearInterval(room.timer); clearInterval(room.sigTimer); clearInterval(room.paintTimer) }\n    // Leaving: drop my row; a leaving host hands the lobby on, the last pilot out closes it.\n    const leaveRoom = async () => {\n      leaveRoomTimers()\n      const mine = room.mine\n      const lobby = room.lobby\n      room.mine = null\n      seat('')\n      if (!mine) return\n      net.closeAll()\n      net.lobby = ''\n      leaveSock(room.id)\n      try {\n        for (const s of await find(signalsDs(), { lobby: room.id, from: me(), $limit: 20 })) { try { await signalsDs().remove(s) } catch (e) {} }\n        await playersDs().remove(mine)\n        announce('leave')\n        if (lobby && lobby.host === me()) {\n          const rest = (await find(playersDs(), { lobby: room.id, $limit: 50 })).filter((p) => live(p)).sort(byJoin)\n          if (rest[0]) await patch(lobbiesDs(), { _id: room.id, host: rest[0].email, hostName: rest[0].name || nameOf(rest[0].email) })\n          else {\n            for (const p of await find(playersDs(), { lobby: room.id, $limit: 50 })) { try { await playersDs().remove(p) } catch (e) {} }\n            for (const s of await find(signalsDs(), { lobby: room.id, $limit: 50 })) { try { await signalsDs().remove(s) } catch (e) {} }\n            await lobbiesDs().remove(lobby)\n          }\n        }\n      } catch (e) {\n        console.warn('[TFSF] leave lobby', e)\n      }\n    }\n    // Closing or reloading the tab is not leaving: the seat is kept for LIVE_MS (see the top).\n    let unloading = false\n    d2.on(window, 'pagehide', () => { unloading = true })\n    d2.onUnmount(() => { if (room.mine && !room.departing && !unloading) leaveRoom(); else leaveRoomTimers() })\n\n    d2.on('[data-ropt]', 'click', async (e, el) => {\n      const opt = el.dataset.ropt\n      if (opt === 'pilot') { room.picking = true; return paintRoom() }\n      if (opt === 'controls') {\n        const next = CONTROLS[(CONTROLS.findIndex((c) => c.id === controlsId()) + 1) % CONTROLS.length]\n        try { localStorage.setItem(CONTROLS_KEY, next.id) } catch (err) {}\n        toast(`Controls: ${next.label}`)\n        return paintRoom()\n      }\n      if (opt === 'ready' && room.mine) setReady(true)\n    })\n    d2.on('[data-pick]', 'click', async (e, el) => {\n      const pick = el.dataset.pick\n      room.picking = false\n      const mine = room.mine\n      if (!mine || takenPilots(mine.email).includes(pick)) return paintRoom()\n      mine.pilot = pick\n      const row = room.pilots.find((p) => String(p._id) === String(mine._id))\n      if (row) row.pilot = pick\n      paintRoom()\n      try { await touch(mine, { pilot: pick }); announce('pilot') } catch (err) { toast('Could not change pilot') }\n    })\n    d2.on('[data-rslot]', 'click', () => { if (room.mine && room.mine.ready) setReady(false) })\n    const setReady = async (ready) => {\n      const mine = room.mine\n      mine.ready = ready\n      const row = room.pilots.find((p) => String(p._id) === String(mine._id))\n      if (row) row.ready = ready\n      paintRoom()\n      try { await touch(mine, { ready }); announce('ready') } catch (e) { toast('Could not update your status') }\n    }\n    d2.on('.room-depart', 'click', async () => {\n      askMotion() // inside the tap, before any await\n      if (!amHost() || room.departing) return\n      const pilots = livePilots()\n      if (!pilots.every((p) => p.ready) || !allLinked()) return\n      const start = { ...stageCmd(roomStage()), at: Date.now() }\n      net.send({ t: 'depart', start }, { reliable: true })\n      try { await patch(lobbiesDs(), { _id: room.id, status: 'departed', start }) } catch (e) {}\n      announce('depart')\n      launch(start)\n    })\n\n    // Back targets: leaving a lobby goes through leaveRoom first.\n    const goBack = async (to) => {\n      if (current === 'room') await leaveRoom()\n      nav(to)\n    }\n\n    const STUBS = { profile: 'Player profile', settings: 'Settings', starfox64: 'Star Fox 64' }\n\n    // Which screen the URL asks for. Same artboard on several routes: follow the URL.\n    let current = ''\n    let routeKey = ''\n    let checking = false\n    const route = async () => {\n      const segs = location.pathname.replace(/\\/+$/, '').split('/').filter(Boolean)\n      let want = 'home'\n      let lobbyId = ''\n      if (segs[0] === 'multiplayer') {\n        if (segs[1] === 'lobbies' && segs[2]) { want = 'room'; lobbyId = segs[2] }\n        else if (segs[1] === 'lobbies') want = 'browse'\n        else want = 'mp'\n      } else if (['login', 'map', 'results', 'solo', ...Object.keys(STUBS)].includes(segs[0])) want = segs[0]\n      const key = want === 'room' ? `room:${lobbyId}` : want\n      if (key === routeKey) return\n      closePicker()\n      // Leaving a screen with live work: stop polling, and leave the lobby (unless going to play).\n      if (current === 'browse') stopBrowse()\n      if (current === 'room' && room.mine && want !== 'room' && !room.departing) leaveRoom()\n      routeKey = key\n      current = want\n      if (want === 'login') return show('login')\n      if (want === 'map') { paintMap(); return show('map') }\n      if (want === 'results') { paintResults(); return show('results') }\n      if (want === 'mp') show('mp')\n      else if (want === 'browse') { show('browse'); startBrowse() }\n      else if (want === 'room') { show('room'); enterRoom(lobbyId) }\n      else if (want === 'solo') { lobby.ready = TEAM.map((m) => m.cpu); paintSolo(); paintSoloCampaign(); show('solo') }\n      else if (STUBS[want]) { $('.stub-title').textContent = STUBS[want]; show('stub') }\n      else show('home')\n      if (checking) return\n      checking = true\n      const ok = await checkSession()\n      checking = false\n      // Back to the same page after signing in (a shared lobby link keeps working).\n      if (!ok) nav(`/login?next=${encodeURIComponent(location.pathname || '/')}`)\n    }\n    // Opening the app while still seated in a lobby (closed tab, phone asleep, reload): back to it.\n    const backToSeat = async () => {\n      const id = readKey(ROOM_KEY)\n      if (!id || current === 'room' || current === 'login') return\n      try {\n        const [row] = await find(playersDs(), { lobby: id, email: me(), $limit: 1 })\n        const [lobby] = row ? await find(lobbiesDs(), { _id: id }) : []\n        if (row && lobby && lobby.status === 'open' && live(row)) {\n          if (current !== 'room' && current !== 'login') nav(`/multiplayer/lobbies/${id}`)\n        } else seat('')\n      } catch (e) {}\n    }\n    route().then(backToSeat)\n    d2.on(window, 'popstate', () => setTimeout(route, 0))\n    // The router may also navigate without a popstate (its own history.push): watch the path.\n    let lastPath = location.pathname\n    const pathWatch = setInterval(() => { if (location.pathname !== lastPath) { lastPath = location.pathname; route() } }, 250)\n    d2.onUnmount(() => clearInterval(pathWatch))\n\n    // ---- actions ---------------------------------------------------------------------------\n    d2.on('[data-play]', 'click', (e, el) => play(el.dataset.play))\n    d2.on('[data-nav]', 'click', (e, el) => nav(el.dataset.nav))\n    d2.on('[data-go]', 'click', () => goBack('/'))\n    d2.on('[data-act]', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'picker-close') {\n        closePicker()\n      } else if (act === 'campaign') {\n        if (current === 'solo') openPicker('solo', soloStage())\n        else if (current === 'room') { if (amHost()) openPicker('room', roomStage()); else toast('The host picks the stage') }\n      } else if (act === 'create-lobby') {\n        createLobby()\n      } else if (act === 'disconnect') {\n        signOut()\n      } else if (act === 'abandon') {\n        rt.lastEvent = null\n        rt.mp = null\n        try { sessionStorage.removeItem(`${STORE}.lastEvent`) } catch (err) {}\n        nav('/')\n      }\n    })\n    const signOut = async () => {\n      try { const auth = d2.auth(d2.param('auth')); if (auth && typeof auth.logout === 'function') await auth.logout() } catch (e) {}\n      try { localStorage.removeItem(sessionKey) } catch (e) {}\n      try { localStorage.removeItem(ME_KEY) } catch (e) {}\n      nav('/login')\n    }\n\n    // ---- login -----------------------------------------------------------------------------\n    const form = $('.login form')\n    d2.on(form, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(form)\n      const btn = form.querySelector('.signin')\n      const err = $('.login-err')\n      btn.disabled = true\n      err.textContent = ''\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        if (!auth) throw new Error('Login is not configured')\n        // AuthenticationStrategy.login takes { credentials, metadata? } and merges credentials in.\n        await auth.login({\n          credentials: { email: String(fd.get('email') || '').trim(), password: String(fd.get('password') || '') },\n        })\n        try { localStorage.setItem(ME_KEY, String(fd.get('email') || '').trim().toLowerCase()) } catch (err) {}\n        const next = new URLSearchParams(location.search).get('next') || '/'\n        current = ''\n        routeKey = ''\n        nav(next.startsWith('/') ? next : '/')\n        setTimeout(backToSeat, 300)\n      } catch (ex) {\n        err.textContent = 'Sign-in failed. Check your email and password.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    // ---- page takeover (phones): edge to edge, no browser zoom/scroll gestures ---------------\n    const undo = []\n    const inBuilder = !/\\.data2\\.app$/.test(location.hostname)\n    if (!inBuilder) {\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n      setMeta('apple-mobile-web-app-capable', 'yes')\n      setMeta('mobile-web-app-capable', 'yes')\n      setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n      setMeta('theme-color', '#05060a')\n      const styleOf = (el, css) => {\n        const prev = el.getAttribute('style')\n        el.style.cssText += css\n        undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n      }\n      styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n      styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n      const host = d2.root && d2.root.host\n      if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n    }\n    const onDoc = (type, fn) => { document.addEventListener(type, fn, { passive: false }); undo.push(() => document.removeEventListener(type, fn, { passive: false })) }\n    for (const g of ['gesturestart', 'gesturechange', 'gestureend']) onDoc(g, (e) => e.preventDefault())\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"lobbies":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lobbies"}}}},"lobbyPlayers":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lobbyPlayers"}}}},"routerRef":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"routerRef"}}}},"signals":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"signals"}}}},"socket":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"socket"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac9574cedc6767b321d0985","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_DevLog","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF Dev Log — the story of the project told as a shelf of game boxes. Each box is a 3D cuboid\n// (CSS 3D transforms): its front is the cover, its back is the \"slide\", laid out like the back of a\n// 64-bit-era game box (screenshots, blurb, features, rating, small print). Arrows / swipe move along\n// the shelf; a tap or Space flips the box; dragging spins it. Week 3 stays shrink-wrapped.\n//\n// Screenshots come from one atlas image (tfsf-assets drive), 4 columns × 6 rows of 480×222 tiles,\n// taken from the project's own headless test runs.\nconst SHOTS = 'https://lucca-playground.data2.media/file/Sfctl-9JK093gSOlQ43CQ'\nconst SHOT_COLS = 4\nconst SHOT_ROWS = 6\n// Tile index → what it shows (kept here so the boxes below read clearly).\nconst S = { hmMenu: 0, corneria: 1, somersault: 2, uturn: 3, touch: 4, fortuna: 5, login: 6, warm: 7, title: 8,\n  settings: 9, map: 10, levels: 11, browse: 12, lobby: 13, arenaLobby: 14, depart: 15, coopA: 16, coopB: 17,\n  spectate: 18, cutscene: 19, comms: 20, arenaGo: 21, arenaWrap: 22, desktop: 23 }\n\nconst W = 1120 // box face, design px\nconst H = 800\nconst D = 190 // box depth\n\nconst BOXES = [\n  {\n    id: 'intro', week: 'Oct 2 – Oct 9, 2026', spine: 'THE MAKING OF', title: 'TFSF', sub: 'The Making Of',\n    theme: ['#1b3a8a', '#0b1640'], accent: '#ffd23f', cover: S.arenaGo, coverTag: 'DEV LOG',\n    tagline: 'From a ROM file to an online, 4-player Star Fox 64 that runs in a phone browser — in eight days.',\n    shots: [S.corneria, S.coopA, S.arenaWrap],\n    bullets: [\n      '<b>The roadmap held:</b> Week 1 — port Star Fox 64. Week 2 — multiplayer & integrations. Week 3 — classified.',\n      '<b>Two apps on data2</b> (lucca-playground): <b>dataFox2</b>, the faithful port, and <b>TFSF</b>, the modified game, with separate PRD and DEV environments.',\n      '<b>53 engine builds</b>, every one kept in the Drive and never overwritten: v1–v17 on dataFox2, v18–v53 on TFSF.',\n      '<b>First playable build 41 minutes after the ROM upload</b> (ROM 17:38, v1 18:19 on Oct 2).',\n      'Engine grew from <b>4.36 MB to 5.26 MB</b> of WebAssembly as co-op, voice and arena landed.',\n    ],\n    specs: [['Platform', 'data2 code components'], ['Engine', 'Starship → WebAssembly'], ['Renderer', 'WebGL2'], ['Network', 'WebRTC, peer to host']],\n    features: [['1–4', 'PLAYERS'], ['GYRO', 'STEERING'], ['MIC', 'RADIO'], ['P2P', 'ONLINE']],\n  },\n  {\n    id: 'port', week: 'Week 1 · Oct 2', spine: 'STAR FOX 64 → WEB', title: 'Port 64', sub: 'Star Fox 64 → WebAssembly',\n    theme: ['#7a1020', '#2a0610'], accent: '#ffb23f', cover: S.corneria, coverTag: 'WEEK 1',\n    tagline: 'A decompiled N64 game, a PC port, and a compiler that targets the browser.',\n    shots: [S.hmMenu, S.corneria, S.fortuna],\n    bullets: [\n      '<b>Sources:</b> the <b>sf64</b> decompilation (the game as C), HarbourMasters <b>Starship</b> (its PC port), <b>libultraship</b> (replaces the N64 graphics, audio and input), <b>Torch</b> (asset extractor).',\n      '<b>Torch</b> turns the legal US 1.0 ROM into <b>sf64.o2r</b> (14.2 MB) + <b>starship.o2r</b>; ROM, archives and engine live in a private Drive.',\n      '<b>Emscripten:</b> the OpenGL ES 3 path maps to <b>WebGL2</b> (FULL_ES3); MODULARIZE as <code>createStarship</code>; 256 MB initial heap, 16 MB stack.',\n      '<b>No threads on the web build:</b> thread pool, logger, resource manager and audio all run synchronously (a variadic <code>LUS_SUBMIT</code> runs jobs inline).',\n      'SDL2, zlib, ogg/vorbis, libzip and stb were <b>built from source</b> when the port downloads were blocked.',\n      'The canvas sits inside data2\\'s shadow DOM — reached through <code>specialHTMLTargets</code>.',\n    ],\n    specs: [['Compiler', 'emsdk + CMake/Ninja'], ['GL', 'GLES3 → WebGL2'], ['Assets', 'Torch → .o2r'], ['Build', '-O2, no pthreads']],\n    features: [['C', 'DECOMP'], ['WASM', 'ENGINE'], ['GL2', 'WEBGL'], ['O2R', 'ASSETS']],\n  },\n  {\n    id: 'crash', week: 'Week 1 · Oct 2–3', spine: 'CRASH HUNTING', title: 'Crash Hunt', sub: 'Making it survive a whole level',\n    theme: ['#4a1a6a', '#170826'], accent: '#ff5d8f', cover: S.fortuna, coverTag: 'WEEK 1',\n    tagline: 'Freezes, gray floors and white portraits — each one traced to a single line.',\n    shots: [S.touch, S.uturn, S.corneria],\n    bullets: [\n      '<b>AddressSanitizer builds</b> of the whole engine ran in headless Chromium to catch memory bugs at the source.',\n      'Fast3D\\'s <b>display-list stack</b> was read after being popped — corrupting state a few seconds later. Found by ASan.',\n      '<b>Texture IDs past 1024</b> overflowed a fixed array: training freezes, gray floor, white portraits → now a growable vector.',\n      'Desktop crash at <b>144 Hz</b>: the interpolation matrix stack underflowed, plus a <code>bool</code>/<code>s32</code> mismatch on a shared global.',\n      '<b>highp</b> shader precision fixed noisy textures on mobile GPUs; dropping <b>HiDPI</b> canvases (3× on iPhone) stopped lost WebGL contexts.',\n      'A <b>crash screen</b> shows the reason, a 14-frame stack and the last log lines; audio closes so a dead engine can\\'t loop a sound.',\n    ],\n    specs: [['Tool', 'ASan (-fsanitize=address)'], ['Fixes', 'v2 → v9'], ['Soak', '5 min @ 60/120 Hz'], ['Heap', 'flat at 307 MB']],\n    features: [['ASAN', 'BUILDS'], ['144', 'HZ SAFE'], ['GL', 'HIGHP'], ['LOG', 'CRASH UI']],\n  },\n  {\n    id: 'perf', week: 'Week 1 · Oct 3–7', spine: '60 FPS ON A PHONE', title: 'Full Speed', sub: '60 FPS on a phone',\n    theme: ['#0d5c4a', '#05221c'], accent: '#7dffb0', cover: S.somersault, coverTag: 'WEEK 1',\n    tagline: 'Main-thread time per frame went from 14 ms to 1.3 ms.',\n    shots: [S.somersault, S.uturn, S.fortuna],\n    bullets: [\n      '<b>Cracked audio</b> came from stepping the game too fast: <code>web_frame</code> now paces logic at the N64\\'s rate.',\n      '<b>Frame interpolation</b> at the display\\'s real refresh rate (measured, snapped to 30…144 Hz), one interpolated frame per animation tick.',\n      '<b>The big one (v16):</b> skipping the ImGui layer on web — its GL state backup made ~15 blocking <code>glGet</code> calls every frame.',\n      '<code>nanosleep</code> busy-waits under Emscripten, so the native frame limiter was removed on web.',\n      '<b>Warm-up</b> behind the loading screen: 15 color-combiner shaders and 5,479 resources, plus a list each device learns as it plays.',\n      'The first-U-turn stutter was an <b>iOS font fallback</b> for one arrow glyph — fixed by drawing it once, off-screen, while loading.',\n    ],\n    specs: [['Logic', '0.38 ms'], ['Fast3D', '0.32 ms'], ['Interp.', '0.26 ms'], ['WebGL', '0.27 ms']],\n    features: [['120', 'HZ'], ['1.3', 'MS/FRAME'], ['89%', 'IDLE'], ['WARM', 'LOADING']],\n  },\n  {\n    id: 'controls', week: 'Week 1 · Oct 4–7', spine: 'BUILT FOR PHONES', title: 'Tilt & Swipe', sub: 'Controls built for phones',\n    theme: ['#0b5f86', '#04202e'], accent: '#5ee0ff', cover: S.touch, coverTag: 'WEEK 1',\n    tagline: 'Fly by tilting the phone. Swipe up to somersault, down to U-turn.',\n    shots: [S.touch, S.somersault, S.uturn],\n    bullets: [\n      '<b>Virtual gamepad:</b> <code>navigator.getGamepads</code> is patched so SDL sees a standard pad; on-screen buttons hold at least 80 ms.',\n      '<b>Sensor fusion:</b> a complementary filter (gyroscope + accelerometer, jolt rejection), auto-calibrated axis order for iOS, yaw that recentres and learns its own bias.',\n      'Pitch drives vertical; <b>roll + yaw</b> drive horizontal. The game\\'s own ±16 stick deadzone is bypassed per axis; a 0.75% tremor filter.',\n      '<b>Swipes call the maneuvers in the engine</b> (<code>web_maneuver</code>) instead of replaying button macros.',\n      '<b>Tap-to-select menus:</b> the engine hit-tests taps in its own 320×240 space (<code>web_tap</code>).',\n      'iOS sound is unlocked inside the first tap and plays through the silent switch.',\n    ],\n    specs: [['Sensors', 'devicemotion'], ['Filter', 'complementary'], ['Deadzone', '0.75%'], ['Input', 'virtual gamepad']],\n    features: [['TILT', 'STEER'], ['SWIPE', 'MANEUVERS'], ['TAP', 'MENUS'], ['PAD', 'COMPATIBLE']],\n  },\n  {\n    id: 'data2', week: 'Week 1 · Oct 2–7', spine: 'HOSTED ON DATA2', title: 'On data2', sub: 'Hosting, security & environments',\n    theme: ['#5a3b0a', '#1f1404'], accent: '#ffcf5a', cover: S.login, coverTag: 'WEEK 1',\n    tagline: 'The whole game is one code component, a private Drive and a login.',\n    shots: [S.login, S.title, S.levels],\n    bullets: [\n      'The game is a <b>data2 code component</b> (shadow DOM, the <code>d2</code> API), served on the <code>.next.data2.app</code> player.',\n      '<b>Private Drive:</b> builder, OR a logged-in session, OR a key in the URL. iOS blocked redirects after a CORS preflight → plain GETs with <code>?t=key</code>, the key read from a session-gated table.',\n      'An <b>auth strategy</b>, users and keys tables, and a login screen.',\n      '<b>Docs artboards</b> for both apps: inner workings, files, build, modding feasibility.',\n      'Oct 7: dataFox2 frozen as \"the ideal way to play\" and <b>cloned into Project: TFSF</b> — PRD and DEV components, artboards and apps; DEV first, then promote.',\n      'Engine source archived as tarballs next to each build.',\n    ],\n    specs: [['Apps', 'datafox2, tfsf, tfsf-dev'], ['Drive', 'tfsf-rom (private)'], ['Auth', 'tfsfAuth'], ['Env', 'PRD / DEV']],\n    features: [['AUTH', 'LOGIN'], ['KEY', 'GATED'], ['PRD', 'STABLE'], ['DEV', 'BUILDS']],\n  },\n  {\n    id: 'menus', week: 'Week 2 · Oct 7–9', spine: 'MENUS & DIRECTOR', title: 'Director', sub: 'New menus, and the engine takes orders',\n    theme: ['#15306b', '#081227'], accent: '#7fb2ff', cover: S.title, coverTag: 'WEEK 2',\n    tagline: 'The page decides what to play; the engine plays it and reports back.',\n    shots: [S.title, S.settings, S.map],\n    bullets: [\n      'Two components with one job each: <b>TFSF_Menu</b> (every screen, one route each) and <b>TFSF_Player</b> (only /play).',\n      '<b>Director mode</b> in the engine: <code>web_new_game</code>, <code>web_play_level</code>, <code>web_training</code>, <code>web_route_choice</code>, <code>web_quit</code> — results come back as JSON (level end, route, game over).',\n      '<b>One engine per page:</b> leaving /play pauses it and parks the canvas; the next game adopts it instantly.',\n      '<b>Title screen:</b> one tap grants sound, motion and the microphone, then a radio check.',\n      'Player profile with nickname; a <b>mixer</b> (master, effects, music, radio, voice 0–200%); level select; route map.',\n      '<b>Menu music:</b> the Corneria theme — its loop found by spectral correlation (83.208 s), joined with a 120 ms crossfade.',\n    ],\n    specs: [['Routes', '/, /solo, /multiplayer, /play…'], ['Engine API', 'EMSCRIPTEN_KEEPALIVE'], ['Reports', 'Module.onDirector'], ['Audio', 'AAC 160 kbps loop']],\n    features: [['ROUTER', 'SCREENS'], ['MIX', '0–200%'], ['BGM', 'LOOP'], ['1 TAP', 'PERMISSIONS']],\n  },\n  {\n    id: 'lobbies', week: 'Week 2 · Oct 8', spine: 'LOBBIES', title: 'Lobbies', sub: 'Finding each other',\n    theme: ['#245c1a', '#0b2108'], accent: '#b6ff6a', cover: S.lobby, coverTag: 'WEEK 2',\n    tagline: 'Create a lobby, pick a pilot, ready up, depart.',\n    shots: [S.browse, S.lobby, S.arenaLobby],\n    bullets: [\n      'Three data2 tables: <b>lobbies</b>, <b>lobbyPlayers</b>, <b>signals</b>. Each pilot writes only its own row; only the host writes the lobby.',\n      '<b>Heartbeat</b> every 4 s, judged on the <b>server\\'s clock</b> — one tester\\'s phone ran 86 s behind and kept getting kicked.',\n      'Away after 12 s, removed after 60 s; if the host leaves, the <b>earliest-joined pilot takes over</b>.',\n      'A <b>PowerCord socket</b> announces every write so screens refresh at once; polling stays as the fallback.',\n      'Pilots: Fox, Falco, Slippy, Peppy — and <b>Star Wolf</b> (Wolf, Leon, Pigma, Andrew) flying Wolfens with green lasers.',\n      '<b>Arena lobbies:</b> two teams of four seats, seat moves and swap requests.',\n    ],\n    specs: [['Presence', 'server time'], ['Beat', '4 s'], ['Socket', 'PowerCord'], ['Pilots', '8']],\n    features: [['8', 'PILOTS'], ['HOST', 'MIGRATION'], ['LIVE', 'SOCKET'], ['2×4', 'ARENA']],\n  },\n  {\n    id: 'netcode', week: 'Week 2 · Oct 8', spine: 'NETCODE', title: 'Lockstep', sub: 'Netcode, built twice in one day',\n    theme: ['#6a2a0a', '#240d03'], accent: '#ff9a3d', cover: S.coopB, coverTag: 'WEEK 2',\n    tagline: 'Every phone runs the whole game. Only the inputs travel.',\n    shots: [S.depart, S.coopA, S.coopB],\n    bullets: [\n      'Inspired by <b>sm64coopdx</b>: peer-to-peer to the host, <b>no TURN or relay</b> — signaling through a data2 table, public STUN only, full SDP after ICE gathering.',\n      '<b>WebRTC DataChannels</b> in a star: an unreliable channel and a reliable one; the host relays between guests.',\n      '<b>Version 1</b> (first): state sync — 30 Hz poses, interpolated puppets, enemy snapshots. It drifted. <b>Rebuilt the same day.</b>',\n      '<b>Version 2: deterministic lockstep.</b> Shared seed, a 256-tick input ring, binary input packets sent 8× redundant.',\n      '<b>Host authority:</b> the host commits every tick for every slot — a lagging pilot gets repeated input, then autopilot (700 ms), then is dropped (20 s). Nobody else freezes.',\n      '<b>Desync check:</b> a state hash every 30 ticks, split in 10 parts to pinpoint the culprit.',\n    ],\n    specs: [['1 input', 'slot · n · u16 b, i8 x, i8 y'], ['2 hash', 'tick · u32'], ['3 voice', 'μ-law 8 kHz · 20 ms'], ['4 commit', '4 slots × u32 per tick']],\n    features: [['P2P', 'NO RELAY'], ['30', 'TICK HASH'], ['8×', 'REDUNDANT'], ['HOST', 'AUTHORITY']],\n  },\n  {\n    id: 'coop', week: 'Week 2 · Oct 8', spine: 'CO-OP CAMPAIGN', title: 'Co-op', sub: 'Four pilots, one Lylat campaign',\n    theme: ['#0d4f6e', '#041b26'], accent: '#ffe066', cover: S.coopA, coverTag: 'WEEK 2',\n    tagline: 'A game written for one ship now simulates four.',\n    shots: [S.coopA, S.spectate, S.cutscene],\n    bullets: [\n      'Up to <b>4 real player ships</b> in one simulation; each phone draws from its own pilot.',\n      'Per-pilot <b>path progress</b> on rails; enemies target the nearest pilot; gates hold until the team catches up.',\n      '<b>Down? Spectate</b> a teammate; the team retries when everyone is down.',\n      '<b>Cutscenes:</b> one pilot owns it, the others fly in formation; no wingman bots in multiplayer.',\n      'Radio chatter made <b>deterministic</b> (timers instead of \"is the voice still playing?\").',\n      'Drawing must not touch the simulation: a <b>snapshot/restore</b> around every view — including the random seeds.',\n      'Status of Team shows the <b>lobby\\'s pilots</b>, not Peppy, Slippy and Falco.',\n    ],\n    specs: [['Ships', 'gPlayer[0..3]'], ['Draw', 'per-pilot view'], ['Spectate', 'teammate cam'], ['Bots', 'none']],\n    features: [['4P', 'CO-OP'], ['SPEC', 'TATE'], ['TEAM', 'RETRY'], ['SYNC', 'CUTSCENES']],\n  },\n  {\n    id: 'radio', week: 'Week 2 · Oct 8–9', spine: 'RADIO COMMS', title: 'Radio', sub: 'Push-to-talk voice chat',\n    theme: ['#5b0f4a', '#1f0519'], accent: '#ff7ad9', cover: S.comms, coverTag: 'WEEK 2',\n    tagline: 'Hold TALK: your portrait pops up on everyone\\'s screen, mouth moving, like the original radio.',\n    shots: [S.comms, S.coopB, S.title],\n    bullets: [\n      '<b>Voice over the same DataChannel:</b> μ-law at 8 kHz, 20 ms frames, as binary packets beside the inputs.',\n      'The engine draws the speaker\\'s <b>portrait and name</b> with the game\\'s own mouth animation.',\n      'TALK plays the start of a <b>real radio line</b> (Peppy, voice 20237) cut at 10 frames — picked from samples.',\n      'The mic opens <b>once</b>, on the title tap, on the game\\'s own audio context — muted until TALK.',\n      '<b>Lesson learned:</b> opening and closing the mic per press made iOS reroute audio. Reverted.',\n      'Second-session silence: iOS wouldn\\'t resume a suspended context outside a tap → the engine\\'s output is unplugged instead.',\n    ],\n    specs: [['Codec', 'μ-law 8 kHz'], ['Frame', '20 ms'], ['Gain', '2.5× mic'], ['Session', 'play-and-record']],\n    features: [['PTT', 'TALK'], ['FACE', 'PORTRAIT'], ['8K', 'VOICE'], ['iOS', 'SAFE']],\n  },\n  {\n    id: 'arena', week: 'Week 2 · Oct 9', spine: 'ARENA', title: 'Arena', sub: 'Team versus, one screen per phone',\n    theme: ['#7a0d0d', '#2a0404'], accent: '#5aa2ff', cover: S.arenaWrap, coverTag: 'WEEK 2',\n    tagline: 'Blue vs Red. No respawns. First team to 3 wins.',\n    shots: [S.arenaGo, S.arenaWrap, S.arenaLobby],\n    bullets: [\n      'The original <b>VS mode</b>, rebuilt for teams and full screen per phone — up to 4 pilots.',\n      '<b>An endless arena:</b> everything is drawn at its nearest copy across the wrap (24,000 units) and the ground tiles around the camera.',\n      '<b>Rounds:</b> downed pilots spectate; wipe the other team for a point; a 2:00 clock — more pilots alive wins, ties score both. First to 3, 5 or 10.',\n      'Team portraits over the score, <b>dimmed when down</b>; team-colored lasers and boost glow; no friendly fire.',\n      'Ships have <b>40% less health</b> (every hit ×5/3) — \"too tanky\".',\n      'A false \"out of sync\" alarm traced to <b>random numbers used while drawing</b> the extra views — seeds now restored after each view.',\n    ],\n    specs: [['Teams', 'Blue / Red'], ['Round', '120 s'], ['Win', 'first to 3/5/10'], ['Wrap', '24,000 units']],\n    features: [['2v2', 'TEAMS'], ['∞', 'ARENA'], ['3', 'ROUNDS'], ['−40%', 'HEALTH']],\n  },\n  {\n    id: 'polish', week: 'Week 2 · Oct 9', spine: 'POLISH', title: 'Polish', sub: 'The bugs you only find by playing',\n    theme: ['#3c3f46', '#121317'], accent: '#ffd23f', cover: S.desktop, coverTag: 'WEEK 2',\n    tagline: 'Rotate the phone, press F11, quit and play again — it all has to just work.',\n    shots: [S.desktop, S.spectate, S.settings],\n    bullets: [\n      '<b>F11 froze the game:</b> the engine still had the PC version\\'s own fullscreen toggle, fighting the browser\\'s. Disabled on web.',\n      '<b>Rotation / resize:</b> the engine now re-checks the real canvas size twice a second instead of trusting one resize event.',\n      '<b>\"Treadmill\" ground</b> while spectating: the frame interpolation stepped from the team\\'s scroll, not the watched pilot\\'s.',\n      '<b>Second game silent:</b> every state change faded all music to zero after the restore — volumes now return once the level runs.',\n      'iOS magnifier on long-press suppressed; the app installs as a <b>PWA</b>.',\n      'Boost gauge sized to its frame; radar to the top right; camera pulled back for the arena.',\n    ],\n    specs: [['Builds', 'v44 → v53'], ['Resize', '2 checks / s'], ['Audio', 'restore after fade'], ['Fullscreen', 'browser-owned']],\n    features: [['F11', 'SAFE'], ['ROT', 'ATION'], ['PWA', 'INSTALL'], ['AUDIO', 'FIXED']],\n  },\n  {\n    id: 'tools', week: 'Oct 2 – Oct 9', spine: 'SOURCES & TOOLS', title: 'Credits', sub: 'Sources, tools & how it was tested',\n    theme: ['#22333b', '#0b1216'], accent: '#9be7ff', cover: S.levels, coverTag: 'CREDITS',\n    tagline: 'Standing on the shoulders of the decomp and porting communities.',\n    shots: [S.levels, S.map, S.browse],\n    bullets: [\n      '<b>sf64</b> decompilation (github.com/sonicdcer/sf64) · <b>Starship</b> (github.com/HarbourMasters/Starship) · <b>libultraship</b> (github.com/Kenix3/libultraship) · <b>Torch</b> (github.com/HarbourMasters/Torch).',\n      '<b>sm64coopdx</b> (github.com/coop-deluxe/sm64coopdx) — the model for peer-to-host co-op. <b>Emscripten</b>, SDL2, WebRTC, Web Audio, DeviceMotion.',\n      '<b>Testing:</b> headless Chromium + Playwright on SwiftShader WebGL; a local stand-in for the data2 app (auth, tables, files); two-browser lockstep matches; 5-minute soaks; ASan builds; audio peak analysers.',\n      '<b>Deploy:</b> build → upload a numbered <code>starship-vN</code> to the Drive → point the DEV block → publish → promote to PRD.',\n      '<b>Made with</b> Claude Code and the data2 MCP, play-tested on real iPhones and Androids by the team.',\n      'Built from a legally owned US 1.0 cartridge dump; Star Fox 64 belongs to Nintendo.',\n    ],\n    specs: [['Tests', 'Playwright'], ['GPU', 'SwiftShader'], ['Builds', '53'], ['Days', '8']],\n    features: [['OSS', 'SOURCES'], ['E2E', 'TESTED'], ['53', 'BUILDS'], ['D2', 'MCP']],\n  },\n  {\n    id: 'week3', week: 'Week 3', spine: '??? · CLASSIFIED', title: '???', sub: 'Week 3', sealed: true,\n    theme: ['#141414', '#050505'], accent: '#ff3b3b', cover: null, coverTag: 'WEEK 3',\n    tagline: 'Under wraps.',\n    shots: [], bullets: [], specs: [], features: [],\n  },\n]\n\nconst esc = (s) => String(s).replace(/[&<>\"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' })[c])\n\n// A screenshot tile from the atlas, as a div background.\nconst shot = (i, cls) => {\n  const x = (i % SHOT_COLS) / (SHOT_COLS - 1) * 100\n  const y = Math.floor(i / SHOT_COLS) / (SHOT_ROWS - 1) * 100\n  return `<div class=\"shot ${cls || ''}\" style=\"background-image:url('${SHOTS}');background-size:${SHOT_COLS * 100}% ${SHOT_ROWS * 100}%;background-position:${x}% ${y}%\"></div>`\n}\n// The console emblem: four colored blocks, and the \"64\".\nconst emblem = (small) => `<div class=\"emblem${small ? ' small' : ''}\"><i style=\"background:#e4232b\"></i><i style=\"background:#1f9d4c\"></i><i style=\"background:#2b5fd9\"></i><i style=\"background:#f6c118\"></i><b>64</b></div>`\nconst barcode = (seed) => {\n  let s = ''\n  let v = seed * 9301 + 49297\n  for (let i = 0; i < 30; i++) { v = (v * 9301 + 49297) % 233280; s += `<i style=\"width:${1 + (v % 3)}px\"></i>` }\n  return `<div class=\"barcode\">${s}<span>0 45496 ${String(10000 + seed * 731).slice(0, 5)} ${seed % 10}</span></div>`\n}\n\n// The game's own Arwing model, rendered by the engine (showcase mode) and cut out on a green key.\nconst ARWING = 'https://lucca-playground.data2.media/file/7OwGDZOjeXwnZvfcVfdo2'\nconst arwing = () => `<img class=\"cw-ship\" src=\"${ARWING}\" alt=\"\" draggable=\"false\">`\n\nconst front = (b, n) => {\n  if (b.sealed) {\n    return `<div class=\"face front sealed-front\" style=\"background:linear-gradient(160deg,#1d1d1f,#050505)\">\n      <div class=\"band\">${emblem()}<span>TFSF</span><em>DEV LOG ${String(n).padStart(2, '0')}</em></div>\n      <div class=\"q\">???</div>\n      <div class=\"stamp\">CLASSIFIED</div>\n      <div class=\"sticker\">WEEK 3<br><small>DO NOT OPEN</small></div>\n      <div class=\"wrap\"></div>\n    </div>`\n  }\n  return `<div class=\"face front cover-w\" style=\"--a:${b.accent};--wing:${b.theme[0]}\">\n    <div class=\"cw-glow\"></div>\n    ${arwing()}\n    <div class=\"cw-top\">${emblem(true)}<em>DEV LOG ${String(n).padStart(2, '0')} · ${esc(b.week)}</em></div>\n    <div class=\"cw-logo\"><div class=\"cw-tfsf\">TFSF</div><div class=\"cw-name\">${esc(b.title === 'TFSF' ? b.sub : b.title)}</div><div class=\"cw-sub\">${esc(b.title === 'TFSF' ? b.week : b.sub)}</div></div>\n    <div class=\"seal\"><b>DATA2</b><span>BUILT ON</span></div>\n  </div>`\n}\n\nconst back = (b, n) => {\n  if (b.sealed) {\n    const bars = Array.from({ length: 9 }, (_, i) => `<i style=\"width:${55 + ((i * 37) % 40)}%\"></i>`).join('')\n    return `<div class=\"face back sealed-back\">\n      <div class=\"side\">${emblem(true)}<span>TFSF</span></div>\n      <div class=\"body\">\n        <h1 class=\"b-title\">???</h1>\n        <p class=\"b-tag\">Week 3 is under wraps. This box opens when it ships.</p>\n        <div class=\"redact\">${bars}</div>\n        <div class=\"stamp big\">CLASSIFIED</div>\n      </div>\n      <div class=\"wrap\"></div>\n    </div>`\n  }\n  return `<div class=\"face back\" style=\"--a:${b.accent};background:linear-gradient(170deg,${b.theme[0]} 0%,${b.theme[1]} 100%)\">\n    <div class=\"side\">${emblem(true)}<span>TFSF</span><em>${esc(b.week)}</em></div>\n    <div class=\"body\">\n      <div class=\"b-head\"><h1 class=\"b-title\">${esc(b.title)}</h1><div class=\"b-sub\">${esc(b.sub)}</div></div>\n      <p class=\"b-tag\">${esc(b.tagline)}</p>\n      <div class=\"b-main\">\n        <div class=\"b-shots\">${b.shots.map((s, i) => shot(s, 's' + i)).join('')}</div>\n        <ul class=\"b-list\">${b.bullets.map((t) => `<li>${t}</li>`).join('')}</ul>\n      </div>\n      <div class=\"b-foot\">\n        <div class=\"rating\"><b>DEV</b><span>DEVELOPER<br>LOG</span></div>\n        <div class=\"feats\">${b.features.map(([k, v]) => `<div class=\"feat\"><b>${esc(k)}</b><span>${esc(v)}</span></div>`).join('')}</div>\n        <table class=\"specs\">${b.specs.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join('')}</table>\n        ${barcode(n)}\n      </div>\n      <div class=\"legal\">TFSF ${String(n).padStart(2, '0')}/${BOXES.length} · ${esc(b.week)} · Star Fox 64 is a trademark of Nintendo. Fan project for personal use, built from a legally owned cartridge. Runs on data2.</div>\n    </div>\n  </div>`\n}\n\nconst spine = (b, n, side) => `<div class=\"face spine ${side}\" style=\"--a:${b.accent};background:${b.sealed ? '#0b0b0b' : `linear-gradient(90deg,${b.theme[1]},${b.theme[0]},${b.theme[1]})`}\">\n  <div class=\"sp-in\">${emblem(true)}<span class=\"sp-title\">${esc(b.spine)}</span><span class=\"sp-n\">${String(n).padStart(2, '0')}</span></div>\n  ${b.sealed ? '<div class=\"wrap\"></div>' : ''}\n</div>`\nconst lid = (b, cls) => `<div class=\"face ${cls}\" style=\"background:${b.sealed ? '#0b0b0b' : b.theme[1]}\">${b.sealed ? '<div class=\"wrap\"></div>' : ''}</div>`\n\nconst boxHtml = (b, i) => `<div class=\"box\" data-i=\"${i}\">\n  ${front(b, i + 1)}${back(b, i + 1)}${spine(b, i + 1, 'right')}${spine(b, i + 1, 'left')}${lid(b, 'top')}${lid(b, 'bottom')}\n</div>`\n\nexport default {\n  css: `\n    .dl, .dl * { box-sizing: border-box; }\n    .dl { position: relative; width: 100%; height: 100%; overflow: hidden; user-select: none; -webkit-user-select: none;\n      font-family: 'Arial Narrow', Arial, Helvetica, sans-serif; color: #f4f1e8; touch-action: none;\n      background: radial-gradient(ellipse at 50% 35%, #2a2f45 0%, #11131d 55%, #07080c 100%); }\n    .dl .floor { position: absolute; left: -20%; right: -20%; bottom: 0; height: 34%;\n      background: linear-gradient(180deg, #0000 0%, #0c0d14 30%, #050508 100%); }\n    .dl .floor::before { content: ''; position: absolute; inset: 0;\n      background: repeating-linear-gradient(90deg, #ffffff06 0 1px, transparent 1px 90px); transform: perspective(500px) rotateX(55deg); transform-origin: top; }\n    .dl .hud { position: absolute; left: 0; right: 0; top: 0; display: flex; align-items: center; justify-content: space-between;\n      padding: 14px 20px; font: 700 13px/1.2 system-ui, sans-serif; letter-spacing: 0.12em; color: #b9c0d6; z-index: 5; pointer-events: none; }\n    .dl .hud b { color: #fff; font-size: 15px; }\n    .dl .ctl { position: absolute; left: 0; right: 0; bottom: 14px; display: flex; justify-content: center; gap: 10px; z-index: 5; }\n    .dl .ctl button { border: 1px solid #ffffff26; background: #ffffff10; color: #e8ecf4; border-radius: 999px; padding: 9px 16px;\n      font: 700 13px system-ui, sans-serif; letter-spacing: 0.06em; cursor: pointer; backdrop-filter: blur(6px); }\n    .dl .ctl button:hover { background: #ffffff22; }\n    .dl .dots { position: absolute; left: 0; right: 0; bottom: 56px; display: flex; justify-content: center; gap: 6px; z-index: 5; }\n    .dl .dots i { width: 22px; height: 6px; border-radius: 3px; background: #ffffff22; cursor: pointer; }\n    .dl .dots i.on { background: #ffd23f; }\n\n    .dl .scene { position: absolute; inset: 0; perspective: 2600px; perspective-origin: 50% 42%; }\n    .dl .world { position: absolute; left: 50%; top: 46%; width: 0; height: 0; transform-style: preserve-3d; }\n    .dl .box { position: absolute; left: ${-W / 2}px; top: ${-H / 2}px; width: ${W}px; height: ${H}px; transform-style: preserve-3d;\n      transition: transform 0.75s cubic-bezier(.2,.75,.2,1), opacity 0.5s; cursor: pointer; }\n    .dl .box.drag { transition: none; }\n    .dl .box.off { display: none; }\n    .dl .face { position: absolute; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden;\n      box-shadow: inset 0 0 0 1px #ffffff14; }\n    .dl .front, .dl .back { width: ${W}px; height: ${H}px; }\n    .dl .front { transform: translateZ(${D / 2}px); }\n    .dl .back { transform: rotateY(180deg) translateZ(${D / 2}px); }\n    .dl .spine { width: ${D}px; height: ${H}px; left: ${(W - D) / 2}px; }\n    .dl .spine.right { transform: rotateY(90deg) translateZ(${W / 2}px); }\n    .dl .spine.left { transform: rotateY(-90deg) translateZ(${W / 2}px); }\n    .dl .top, .dl .bottom { width: ${W}px; height: ${D}px; top: ${(H - D) / 2}px; }\n    .dl .top { transform: rotateX(90deg) translateZ(${H / 2}px); }\n    .dl .bottom { transform: rotateX(-90deg) translateZ(${H / 2}px); }\n    /* Cardboard sheen over every face. */\n    .dl .face::after { content: ''; position: absolute; inset: 0; pointer-events: none;\n      background: linear-gradient(125deg, #ffffff12 0%, transparent 35%, transparent 70%, #00000030 100%); }\n\n    .dl .shot { background-color: #000; background-repeat: no-repeat; }\n    .dl .emblem { position: relative; width: 58px; height: 58px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; transform: rotate(45deg); }\n    .dl .emblem i { display: block; border-radius: 3px; box-shadow: inset 0 -4px 0 #0004, inset 0 3px 0 #fff4; }\n    .dl .emblem b { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; transform: rotate(-45deg);\n      font: italic 900 26px/1 Arial Black, Arial, sans-serif; color: #fff; text-shadow: 0 2px 0 #000, 0 0 6px #000; }\n    .dl .emblem.small { width: 38px; height: 38px; gap: 2px; }\n    .dl .emblem.small b { font-size: 17px; }\n\n    /* ---- front cover ---- */\n    .dl .cover-art { position: absolute; left: 0; right: 0; top: 96px; bottom: 0; background-size: 400% 600%; filter: saturate(1.15) contrast(1.05); }\n    .dl .cover-fade { position: absolute; inset: 96px 0 0 0; }\n    .dl .band { position: absolute; left: 0; right: 0; top: 0; height: 96px; background: #0a0a0c; display: flex; align-items: center; gap: 22px;\n      padding: 0 34px; border-bottom: 4px solid var(--a, #ffd23f); }\n    .dl .band span { font: italic 900 46px/1 Arial Black, Arial, sans-serif; letter-spacing: 0.04em; color: #fff; }\n    .dl .band em { margin-left: auto; font: 700 20px/1 Arial, sans-serif; letter-spacing: 0.3em; color: #9aa3b8; font-style: normal; }\n    .dl .cover-title { position: absolute; left: 50px; right: 50px; bottom: 120px; }\n    .dl .cover-title h1 { margin: 0; font: italic 900 150px/0.9 'Arial Black', Arial, sans-serif; letter-spacing: -0.02em;\n      background: linear-gradient(180deg, #fff 0%, #ffe9a8 45%, var(--a) 100%); -webkit-background-clip: text; background-clip: text; color: transparent;\n      filter: drop-shadow(0 6px 0 #000a) drop-shadow(0 0 30px #0008); -webkit-text-stroke: 2px #0006; }\n    .dl .cover-title h2 { margin: 12px 0 0; font: 800 38px/1.1 Arial, sans-serif; color: #fff; text-shadow: 0 3px 10px #000; letter-spacing: 0.01em; }\n    .dl .cover-tag { position: absolute; right: 40px; top: 130px; padding: 10px 22px; background: var(--a); color: #111; transform: rotate(4deg);\n      font: 900 30px/1 'Arial Black', Arial, sans-serif; letter-spacing: 0.08em; box-shadow: 0 6px 18px #0009; }\n    .dl .cover-week { position: absolute; left: 52px; bottom: 50px; font: 700 26px/1 Arial, sans-serif; letter-spacing: 0.2em; color: #e6e9f2; text-transform: uppercase; }\n    .dl .seal { position: absolute; right: 44px; bottom: 36px; width: 128px; height: 84px; border-radius: 50%; border: 4px solid #e8c45a;\n      display: flex; flex-direction: column; align-items: center; justify-content: center; color: #e8c45a; background: #0008; }\n    .dl .seal b { font: 900 26px/1 Arial Black, Arial, sans-serif; } .dl .seal span { font: 700 13px/1.3 Arial, sans-serif; letter-spacing: 0.2em; }\n\n\n    /* ---- front cover, white (after the Star Fox Command art) ---- */\n    .dl .cover-w { background: radial-gradient(ellipse at 30% 25%, #ffffff 0%, #f3f4f6 55%, #dfe2e8 100%); }\n    .dl .cw-glow { position: absolute; inset: 0; background: radial-gradient(ellipse at 60% 45%, #ffffff 0%, #ffffff00 60%); }\n    .dl .cw-ship { position: absolute; left: 380px; top: -390px; height: 1300px; width: auto; transform: rotate(38deg); transform-origin: 50% 50%;\n      filter: drop-shadow(-40px 50px 34px #00000040); pointer-events: none; }\n    .dl .cw-top { position: absolute; left: 36px; top: 30px; display: flex; align-items: center; gap: 18px; }\n    .dl .cw-top em { font: 800 18px/1 Arial, sans-serif; letter-spacing: 0.24em; color: #6b7280; font-style: normal; text-transform: uppercase; }\n    .dl .cw-logo { position: absolute; left: 0; right: 0; bottom: 64px; display: flex; flex-direction: column; align-items: center; }\n    .dl .cw-tfsf { font: italic 900 170px/0.9 'Arial Black', Arial, sans-serif; letter-spacing: -0.01em;\n      background: linear-gradient(180deg, #f6ffb0 0%, #a8f04a 38%, #3cb83a 62%, #156b23 100%); -webkit-background-clip: text; background-clip: text; color: transparent;\n      -webkit-text-stroke: 4px #0b3a14; filter: drop-shadow(0 0 0 #fff) drop-shadow(3px 3px 0 #ffffff) drop-shadow(-3px -3px 0 #ffffff) drop-shadow(0 10px 10px #0006); }\n    .dl .cw-name { margin-top: 6px; font: 900 58px/1 'Arial Black', Arial, sans-serif; letter-spacing: 0.32em; text-transform: uppercase; padding-left: 0.32em;\n      background: linear-gradient(180deg, #ffd27a 0%, #ff8a1f 55%, #d44a00 100%); -webkit-background-clip: text; background-clip: text; color: transparent;\n      -webkit-text-stroke: 2px #6a2400; filter: drop-shadow(2px 2px 0 #fff) drop-shadow(0 4px 4px #0005); white-space: nowrap; }\n    .dl .cw-sub { margin-top: 12px; font: 800 26px/1 Arial, sans-serif; letter-spacing: 0.12em; color: #1a1d24; text-transform: uppercase; }\n    .dl .cover-w .seal { color: #b88a1c; border-color: #c99a2a; background: #ffffffcc; }\n\n    /* ---- back: the slide ---- */\n    .dl .back { display: flex; }\n    .dl .side { width: 92px; flex: none; background: #0a0a0c; display: flex; flex-direction: column; align-items: center; padding: 26px 0; gap: 22px;\n      border-right: 4px solid var(--a, #ffd23f); }\n    .dl .side span { writing-mode: vertical-rl; transform: rotate(180deg); font: italic 900 40px/1 Arial Black, Arial, sans-serif; color: #fff; letter-spacing: 0.06em; }\n    .dl .side em { margin-top: auto; writing-mode: vertical-rl; transform: rotate(180deg); font: 700 15px/1 Arial, sans-serif; letter-spacing: 0.22em;\n      color: #9aa3b8; font-style: normal; text-transform: uppercase; }\n    .dl .body { position: relative; flex: 1; display: flex; flex-direction: column; padding: 26px 34px 16px 30px; min-width: 0; }\n    .dl .b-head { display: flex; align-items: baseline; gap: 20px; }\n    .dl .b-title { margin: 0; font: italic 900 66px/0.95 'Arial Black', Arial, sans-serif; letter-spacing: -0.01em;\n      background: linear-gradient(180deg, #fff 0%, #ffe9a8 50%, var(--a, #fff) 100%); -webkit-background-clip: text; background-clip: text; color: transparent;\n      filter: drop-shadow(0 4px 0 #0009); }\n    .dl .b-sub { font: 800 24px/1.1 Arial, sans-serif; color: var(--a); text-transform: uppercase; letter-spacing: 0.04em; }\n    .dl .b-tag { margin: 10px 0 14px; font: italic 700 23px/1.25 Arial, sans-serif; color: #fff; }\n    .dl .b-main { flex: 1; display: flex; gap: 24px; min-height: 0; }\n    .dl .b-shots { position: relative; width: 330px; flex: none; }\n    .dl .b-shots .shot { position: absolute; width: 300px; height: 139px; border: 6px solid #f4f1e8; box-shadow: 0 10px 24px #000a; }\n    .dl .b-shots .s0 { left: 0; top: 0; transform: rotate(-3deg); }\n    .dl .b-shots .s1 { left: 26px; top: 150px; transform: rotate(2.5deg); }\n    .dl .b-shots .s2 { left: 4px; top: 300px; transform: rotate(-1.5deg); }\n    .dl .b-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; font: 400 17.5px/1.32 Arial, sans-serif; color: #eef0f6; }\n    .dl .b-list li { position: relative; padding-left: 22px; }\n    .dl .b-list li::before { content: ''; position: absolute; left: 0; top: 7px; width: 10px; height: 10px; background: var(--a); transform: rotate(45deg); }\n    .dl .b-list b { color: #fff; }\n    .dl .b-list code { font: 600 15.5px/1 'Courier New', monospace; color: var(--a); background: #0005; padding: 1px 5px; border-radius: 3px; }\n    .dl .b-foot { display: flex; align-items: stretch; gap: 14px; margin-top: 12px; height: 92px; }\n    .dl .rating { width: 76px; flex: none; background: #fff; color: #000; border: 3px solid #000; display: flex; flex-direction: column; align-items: center; justify-content: center; }\n    .dl .rating b { font: 900 30px/1 'Arial Black', Arial, sans-serif; } .dl .rating span { font: 800 10px/1.15 Arial, sans-serif; text-align: center; letter-spacing: 0.05em; }\n    .dl .feats { display: flex; gap: 8px; }\n    .dl .feat { width: 82px; background: #0008; border: 2px solid var(--a); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }\n    .dl .feat b { font: 900 22px/1 'Arial Black', Arial, sans-serif; color: var(--a); } .dl .feat span { font: 800 11px/1.1 Arial, sans-serif; letter-spacing: 0.08em; text-align: center; }\n    .dl .specs { flex: 1; border-collapse: collapse; font: 13.5px/1.2 Arial, sans-serif; background: #0006; }\n    .dl .specs th { text-align: left; padding: 2px 8px; color: var(--a); font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; width: 1%; }\n    .dl .specs td { padding: 2px 8px; color: #fff; }\n    .dl .barcode { width: 112px; flex: none; background: #fff; padding: 8px 6px 4px; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 0 1px; overflow: hidden; }\n    .dl .barcode i { display: block; height: 58px; background: #000; flex: none; }\n    .dl .barcode i:nth-child(odd) { margin-right: 1px; }\n    .dl .barcode span { width: 100%; text-align: center; font: 10px/1.2 'Courier New', monospace; color: #000; }\n    .dl .legal { margin-top: 8px; font: 11px/1.3 Arial, sans-serif; color: #ffffff8c; }\n\n    /* ---- spines & lids ---- */\n    .dl .sp-in { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; padding: 28px 0 22px; gap: 24px; }\n    .dl .sp-title { writing-mode: vertical-rl; transform: rotate(180deg); font: italic 900 46px/1 'Arial Black', Arial, sans-serif; color: #fff;\n      letter-spacing: 0.02em; text-shadow: 0 3px 0 #0008; flex: 1; text-align: center; white-space: nowrap; overflow: hidden; }\n    .dl .sp-n { font: 900 30px/1 'Arial Black', Arial, sans-serif; color: var(--a); }\n\n    /* ---- the sealed box ---- */\n    .dl .q { position: absolute; left: 0; right: 0; top: 200px; text-align: center; font: italic 900 300px/1 'Arial Black', Arial, sans-serif;\n      color: #1d1d20; -webkit-text-stroke: 3px #2e2e33; }\n    .dl .stamp { position: absolute; left: 50%; top: 56%; transform: translate(-50%, -50%) rotate(-12deg); padding: 14px 36px; border: 8px solid #ff3b3b;\n      color: #ff3b3b; font: 900 84px/1 'Arial Black', Arial, sans-serif; letter-spacing: 0.08em; opacity: 0.9; border-radius: 10px; }\n    .dl .stamp.big { top: 60%; }\n    .dl .sticker { position: absolute; right: 50px; top: 140px; width: 180px; height: 180px; border-radius: 50%; background: #ffd23f; color: #111;\n      display: flex; flex-direction: column; align-items: center; justify-content: center; font: 900 36px/1.05 'Arial Black', Arial, sans-serif;\n      transform: rotate(10deg); box-shadow: 0 8px 20px #000a; text-align: center; }\n    .dl .sticker small { font: 800 15px/1.3 Arial, sans-serif; letter-spacing: 0.1em; }\n    .dl .redact { display: flex; flex-direction: column; gap: 18px; margin-top: 30px; }\n    .dl .redact i { display: block; height: 30px; background: #000; box-shadow: 0 0 0 1px #222; }\n    .dl .sealed-back .b-title { color: #fff; background: none; -webkit-text-fill-color: #fff; }\n    .dl .wrap { position: absolute; inset: 0; pointer-events: none;\n      background: linear-gradient(115deg, #ffffff00 0%, #ffffff00 38%, #ffffff2a 44%, #ffffff00 52%, #ffffff00 66%, #ffffff1c 70%, #ffffff00 76%),\n        linear-gradient(180deg, #ffffff0d, #ffffff00 40%, #ffffff0a); }\n\n    .dl .hint { position: absolute; left: 50%; bottom: 74px; transform: translateX(-50%); z-index: 5; font: 600 13px system-ui, sans-serif; color: #8f97ad;\n      letter-spacing: 0.04em; pointer-events: none; transition: opacity 0.6s; }\n  `,\n\n  render() {\n    return `<div class=\"dl\">\n      <div class=\"floor\"></div>\n      <div class=\"hud\"><span><b>TFSF</b> · DEV LOG</span><span class=\"count\">01 / ${BOXES.length}</span></div>\n      <div class=\"hint\">← → to browse · tap or Space to flip · drag to spin</div>\n      <div class=\"scene\"><div class=\"world\">${BOXES.map(boxHtml).join('')}</div></div>\n      <div class=\"dots\">${BOXES.map((b, i) => `<i data-go=\"${i}\" title=\"${esc(b.spine)}\"></i>`).join('')}</div>\n      <div class=\"ctl\"><button data-act=\"prev\">‹ PREV</button><button data-act=\"flip\">FLIP</button><button data-act=\"next\">NEXT ›</button></div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const el = root.querySelector('.dl')\n    const world = root.querySelector('.world')\n    const boxes = [...root.querySelectorAll('.box')]\n    const dots = [...root.querySelectorAll('.dots i')]\n    const count = root.querySelector('.count')\n    const hint = root.querySelector('.hint')\n    const st = { cur: 0, flip: false, spin: 0, tilt: 0, scale: 1, timer: 0 }\n    const drag = { on: false, x: 0, y: 0, t: 0, moved: false, base: 0 }\n    const undo = []\n    const on = (t, ev, fn, o) => { t.addEventListener(ev, fn, o); undo.push(() => t.removeEventListener(ev, fn, o)) }\n\n    // Fit the current box to the viewport (design size W×H, with room for the controls).\n    const fit = () => {\n      const r = el.getBoundingClientRect()\n      st.scale = Math.max(0.2, Math.min((r.width * 0.86) / W, (r.height * 0.74) / H))\n      layout()\n    }\n    const layout = () => {\n      boxes.forEach((bx, i) => {\n        const d = i - st.cur\n        const a = Math.abs(d)\n        bx.classList.toggle('off', a > 3)\n        if (a > 3) return\n        let t\n        if (d === 0) {\n          // A slight resting angle shows the box's depth; dragging adds to it.\n          const ry = (st.flip ? 180 : 0) - 9 + st.spin\n          t = `translate3d(0,0,0) rotateX(${st.tilt + 3}deg) rotateY(${ry}deg)`\n        } else {\n          // The rest of the collection fans out on either side, covers turned toward the middle.\n          const x = Math.sign(d) * (W * 0.62 + (a - 1) * 300)\n          t = `translate3d(${x}px,0,${-360 - a * 170}px) rotateY(${d < 0 ? -62 : 62}deg)`\n        }\n        bx.style.transform = t\n        bx.style.opacity = a === 0 ? 1 : Math.max(0.35, 1 - a * 0.2)\n      })\n      world.style.transform = `scale(${st.scale})`\n      dots.forEach((dt, i) => dt.classList.toggle('on', i === st.cur))\n      count.textContent = `${String(st.cur + 1).padStart(2, '0')} / ${BOXES.length}`\n    }\n    const go = (i) => {\n      const n = Math.max(0, Math.min(BOXES.length - 1, i))\n      if (n === st.cur) return\n      st.cur = n\n      st.flip = false\n      st.spin = 0\n      st.tilt = 0\n      layout()\n      // The box arrives cover first, then turns to show its back (the slide). The sealed one stays shut.\n      clearTimeout(st.timer)\n      if (!BOXES[n].sealed) st.timer = setTimeout(() => { st.flip = true; layout() }, 1100)\n    }\n    const flip = () => {\n      clearTimeout(st.timer)\n      if (BOXES[st.cur].sealed) { st.spin = st.spin ? 0 : 24; st.tilt = st.tilt ? 0 : -6; return layout() } // just a wiggle\n      st.flip = !st.flip\n      st.spin = 0\n      st.tilt = 0\n      layout()\n    }\n    const quiet = () => { hint.style.opacity = '0' }\n\n    on(window, 'resize', fit)\n    on(window, 'keydown', (e) => {\n      if (e.key === 'ArrowRight' || e.key === 'PageDown') { go(st.cur + 1); quiet() }\n      else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { go(st.cur - 1); quiet() }\n      else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); flip(); quiet() }\n      else if (e.key === 'Home') go(0)\n      else if (e.key === 'End') go(BOXES.length - 1)\n    })\n    root.querySelector('[data-act=\"prev\"]').addEventListener('click', () => { go(st.cur - 1); quiet() })\n    root.querySelector('[data-act=\"next\"]').addEventListener('click', () => { go(st.cur + 1); quiet() })\n    root.querySelector('[data-act=\"flip\"]').addEventListener('click', () => { flip(); quiet() })\n    dots.forEach((dt) => dt.addEventListener('click', () => { go(Number(dt.dataset.go)); quiet() }))\n    boxes.forEach((bx, i) => bx.addEventListener('click', (e) => { if (drag.moved) return; if (i !== st.cur) go(i); else flip(); quiet() }))\n\n    // Drag: on the current box it spins it (released, it settles on the nearest side); a quick\n    // horizontal swipe elsewhere, or a long one, moves along the shelf.\n    on(el, 'pointerdown', (e) => {\n      if (e.target.closest('button, .dots')) return\n      drag.on = true; drag.moved = false; drag.x = e.clientX; drag.y = e.clientY; drag.t = performance.now()\n      drag.base = (st.flip ? 180 : 0)\n    })\n    on(window, 'pointermove', (e) => {\n      if (!drag.on) return\n      const dx = e.clientX - drag.x\n      const dy = e.clientY - drag.y\n      if (!drag.moved && Math.hypot(dx, dy) < 8) return\n      drag.moved = true\n      clearTimeout(st.timer)\n      const bx = boxes[st.cur]\n      bx.classList.add('drag')\n      st.spin = dx * 0.45\n      st.tilt = Math.max(-25, Math.min(25, -dy * 0.15))\n      layout()\n    })\n    on(window, 'pointerup', (e) => {\n      if (!drag.on) return\n      drag.on = false\n      boxes[st.cur].classList.remove('drag')\n      if (!drag.moved) return\n      quiet()\n      const dx = e.clientX - drag.x\n      const fast = performance.now() - drag.t < 260 && Math.abs(dx) > 60\n      if (fast) { st.spin = 0; st.tilt = 0; return go(st.cur + (dx < 0 ? 1 : -1)) }\n      const total = drag.base + st.spin\n      const side = ((Math.round(total / 180) % 2) + 2) % 2\n      st.flip = BOXES[st.cur].sealed ? false : side === 1\n      st.spin = 0\n      st.tilt = 0\n      layout()\n      setTimeout(() => { drag.moved = false }, 0)\n    })\n\n    fit()\n    st.timer = setTimeout(() => { st.flip = true; layout() }, 1600) // the first box shows its back after a beat\n    d2.onUnmount(() => { clearTimeout(st.timer); while (undo.length) { try { undo.pop()() } catch (e) {} } })\n  },\n}\n"}}}}}}}},{"_id":"6aca734b342eee249378eef8","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_MainMenu_DEV","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF DEV menus, the new look (concept: artboard N7pQjv2jXMacQmWpQkSHr): the main menu (\"/\"), the\n// player profile (\"/profile\") and the settings (\"/settings\"), as screens over one WebGL scene.\n// TFSF_Menu_DEV keeps every other screen (title, sign in, solo, multiplayer, map, results).\n//\n// Two layers:\n//   - one WebGL2 canvas behind everything: the cosmic background (nebula, planet, glow), far stars\n//     drifting past behind the ship, and the game's own Arwing (the model captured from the engine:\n//     291 triangles, its N64 textures in a texture array, each with the game's wrap mode) flying\n//     toward the bottom right at an isometric angle, with the game's engine glow at its tail (its orb,\n//     colours and boost sizes);\n//   - the HTML on top: the glass menu and a few quiet instruments (rulers, stripes, readouts). Only\n//     transforms and opacity animate, so the compositor does the work.\n// It is the app's home route (\"/\"). Everything around it stays in TFSF_Menu_DEV: the title tap at\n// launch (sound, motion, microphone: this menu sends a fresh page there first, on /title), sign in,\n// and every other screen. The menu music is shared with it through the runtime (rt.menuMusic).\n// The ship files live in the private TFSF ROM drive (they come from the ROM) and are read with the\n// session's download key, as the player reads the engine.\nconst RT_KEY = '__TFSF_DEV__'\nconst STORE = 'tfsf-dev'\nconst ASSET = (ref) => `https://lucca-playground.data2.media/file/${ref}` // public TFSF assets drive\nconst FONT_REF = 'y6WN_Va9uik5nCCa_K5yu'   // MGS1 HUD Round\nconst LOGO_REF = 'K-Mxtq6dXVza0PmQVpruu'   // Star Fox 64 logo\nconst MESH_REF = '_WGiHFdnrd3Wn79lKT6SP'   // arwing-mesh-v3.bin (private): nose +z, glow at the tail\nconst TEX_REF = 'xR2u2doLaEiKBuHRsXsSq'    // arwing-tex-v2.png (private): 64x64 layers, stacked\nconst WOLF_MESH_REF = 'sszINe13rly_8jdBC5YBO' // wolfen-mesh-v2.bin (private): Star Wolf's ship, from the game's own DL, AWM2 as the Arwing's\nconst MAP_ATLAS_REF = 'M0CsRMPeHm4Ij_ZBAifWP' // map-atlas-v1.png (private): the map's planet, nebula, station and sun textures\nconst WOLF_TEX_REF = 'xMmITYI4SuU1HrmvirEHZ'  // wolfen-tex-v2.png (private): the engine glow, then its 7 hull textures\nconst FACES_REF = 'eOBICme5YD2gORILNUezz'     // portraits-v1.png (private): the 8 pilots' comms portraits, 44x44, still above talking\nconst W0 = 1366\nconst H0 = 768\n\n// Each screen: its title and the slabs of its column (the same perspective column as the main menu).\nconst ITEMS = [\n  { id: 'solo', label: 'Solo', to: '/solo', code: 'SL-01' },\n  { id: 'mp', label: 'Multiplayer', to: '/multiplayer', code: 'MP-02' },\n  { id: 'profile', label: 'Player Profile', to: '/profile', code: 'PF-03' },\n  { id: 'settings', label: 'Settings', to: '/settings', code: 'ST-04' },\n  { id: 'sf64', label: 'Star Fox 64', act: 'starfox64', code: 'SF-64', logo: true },\n  { id: 'disconnect', label: 'Disconnect', act: 'disconnect', code: 'DC-06', danger: true },\n]\nconst SCREENS = {\n  home: { path: '/', title: 'MAIN MENU', items: ITEMS },\n  profile: { path: '/profile', title: 'PLAYER PROFILE', items: [\n    { id: 'nick', label: 'Edit Nickname', act: 'nick', code: 'PF-N' },\n    { id: 'password', label: 'Change Password', act: 'password', code: 'PF-P' },\n    { id: 'pback', label: 'Back', to: '/', code: 'ESC' },\n  ] },\n  settings: { path: '/settings', title: 'SETTINGS', items: [{ id: 'sback', label: 'Back', to: '/', code: 'ESC' }] },\n  // straight to the open lobbies (the old create / browse hub is gone); a lobby itself is TFSF_Menu_DEV's\n  // the hangar (one layout): solo, and an adventure lobby (its path is /multiplayer/lobbies/:_id)\n  solo: { path: '/solo', title: 'SOLO MISSION', items: [\n    { id: 'sready', label: 'Ready', act: 'ready', code: 'RDY' },\n    { id: 'smission', label: 'Mission', act: 'mission', code: 'MSN' },\n    { id: 'scontrols', label: 'Controls', act: 'controls', code: 'CTL' },\n    { id: 'soback', label: 'Back', to: '/', code: 'ESC' },\n  ] },\n  room: { path: '/multiplayer/lobbies', title: 'LOBBY', items: [\n    { id: 'rready', label: 'Ready', act: 'ready', code: 'RDY' },\n    { id: 'rpilot', label: 'Pilot', act: 'pilot', code: 'PLT' },\n    { id: 'rmission', label: 'Mission', act: 'mission', code: 'MSN' },\n    { id: 'rcontrols', label: 'Controls', act: 'controls', code: 'CTL' },\n    { id: 'rleave', label: 'Leave', act: 'leave', code: 'ESC', danger: true },\n  ] },\n  mp: { path: '/multiplayer', title: 'MULTIPLAYER', items: [\n    { id: 'create', label: 'Create Lobby', act: 'create', code: 'MP-C' },\n    { id: 'mback', label: 'Back', to: '/', code: 'ESC' },\n  ] },\n}\n// Where the ship sits on each screen. Its `pivot` (a point of the model, whose nose is +z: the centre,\n// or the cockpit) is put on the screen point `at` (-1..1 across and up) at depth `z`; yaw / pitch / roll\n// turn it; `wob` scales its idle banking and bobbing. Landscape and portrait (`port`) each have a place.\nconst POSES = {\n  home: { pivot: [0, 0, 0], yaw: 0.78, pitch: 0.6, roll: 0, wob: 1, land: { at: [0.22, -0.02], z: -5.2 }, port: { at: [0, -0.45], z: -6.4 } },\n  // a close look at the cockpit (Fox in it), nose toward the right\n  profile: { pivot: [0, 0.14, 0.07], yaw: 0.5, pitch: 1.2, roll: -0.1, wob: 0.45, land: { at: [-0.7, -0.56], z: -2.5 }, port: { at: [0.1, -0.98], z: -3.2 } },\n  // side on, nose to the right\n  settings: { pivot: [0, 0, 0], yaw: Math.PI / 2, pitch: 0.12, roll: 0, wob: 0.6, land: { at: [-0.64, -0.64], z: -4.6 }, port: { at: [0, -0.68], z: -5.6 } },\n  // far off, crossing the screen nose to the right, a thin trail of boost behind it\n  // the hangar: one ship per slot, over its platform (placed from the slots' cards, not from here)\n  solo: { pivot: [0, 0, 0], yaw: 0.62, pitch: 0.42, roll: 0, wob: 0.6, hangar: true, land: { at: [0, 0], z: -6 }, port: { at: [0, 0], z: -7 } },\n  room: { pivot: [0, 0, 0], yaw: 0.62, pitch: 0.42, roll: 0, wob: 0.6, hangar: true, land: { at: [0, 0], z: -6 }, port: { at: [0, 0], z: -7 } },\n  mp: { pivot: [0, 0, 0], yaw: Math.PI / 2, pitch: 0.04, roll: 0, wob: 1, trail: true, land: { at: [0, -0.6], z: -32 }, port: { at: [0, -0.84], z: -36 } },\n}\nconst screenOf = (path) => (/^\\/profile\\/?$/.test(path) ? 'profile' : /^\\/settings\\/?$/.test(path) ? 'settings'\n  : /^\\/multiplayer(\\/lobbies)?\\/?$/.test(path) ? 'mp' : /^\\/multiplayer\\/lobbies\\/[^/]+\\/?$/.test(path) ? 'room'\n  : /^\\/solo\\/?$/.test(path) ? 'solo' : 'home')\n// LevelId (engine order) -> name, for the pilot's record\nconst LEVELS = { 0: 'Corneria', 1: 'Meteo', 2: 'Sector X', 3: 'Area 6', 5: 'Sector Y', 6: 'Venom', 7: 'Solar', 8: 'Zoness', 9: 'Venom',\n  10: 'Training', 11: 'Macbeth', 12: 'Titania', 13: 'Aquas', 14: 'Fortuna', 16: 'Katina', 17: 'Bolse', 18: 'Sector Z', 19: 'Venom' }\nconst levelName = (id) => LEVELS[id] || '—'\n\n// ---- solo and adventure lobbies: the hangar ------------------------------------------------------\n// The campaign selector: the whole campaign, or one stage (engine LevelId, played with cmd=level).\nconst CAMPAIGN = \"Andross' Attack\"\nconst STAGES = [\n  { id: -1, name: CAMPAIGN, kind: 'Full campaign' },\n  { id: 0, name: 'Corneria', kind: 'Planet' }, { id: 1, name: 'Meteo', kind: 'Space' },\n  { id: 5, name: 'Sector Y', kind: 'All-range' }, { id: 14, name: 'Fortuna', kind: 'All-range' },\n  { id: 16, name: 'Katina', kind: 'All-range' }, { id: 13, name: 'Aquas', kind: 'Blue Marine' },\n  { id: 2, name: 'Sector X', kind: 'Space' }, { id: 7, name: 'Solar', kind: 'Planet' },\n  { id: 8, name: 'Zoness', kind: 'Planet' }, { id: 12, name: 'Titania', kind: 'Landmaster' },\n  { id: 11, name: 'Macbeth', kind: 'Landmaster' }, { id: 18, name: 'Sector Z', kind: 'All-range' },\n  { id: 17, name: 'Bolse', kind: 'All-range' }, { id: 3, name: 'Area 6', kind: 'Space' },\n  { id: 6, name: 'Venom', kind: 'Planet' }, { id: 19, name: 'Venom (hard)', kind: 'Planet' },\n  { id: 9, name: 'Andross', kind: 'Boss' },\n]\nconst stageOf = (id) => STAGES.find((st) => st.id === Number(id)) || STAGES[0]\n// What /play is told for a stage: the campaign from the start, or that one level.\nconst stageCmd = (id) => (Number(id) >= 0 ? { cmd: 'level', level: Number(id) } : { cmd: 'new' })\n// The McCloud team, then Star Wolf's crew (they fly Wolfens; here, a red-tinted Arwing until that model is in).\nconst PILOTS = [{ id: 'fox', name: 'Fox' }, { id: 'falco', name: 'Falco' }, { id: 'slippy', name: 'Slippy' }, { id: 'peppy', name: 'Peppy' },\n  { id: 'wolf', name: 'Wolf' }, { id: 'leon', name: 'Leon' }, { id: 'pigma', name: 'Pigma' }, { id: 'andrew', name: 'Andrew' }]\nconst WOLF_CREW = ['wolf', 'leon', 'pigma', 'andrew']\nconst pilotName = (id) => (PILOTS.find((p) => p.id === id) || PILOTS[0]).name\n// The team on /solo: the player flies Fox, the CPUs fly the wingmen and are always ready.\nconst TEAM = [{ pilot: 'fox', cpu: false }, { pilot: 'falco', cpu: true }, { pilot: 'slippy', cpu: true }, { pilot: 'peppy', cpu: true }]\n// Control schemes (the player component reads `${STORE}.controls`).\nconst CONTROLS = [{ id: 'motion', label: 'Motion' }, { id: 'touch', label: 'Touch' }, { id: 'mouse', label: 'Mouse & keys' }]\n// Lobby timing, as TFSF_Menu_DEV has it (presence runs on the server's clock).\nconst MAX_PLAYERS = 4\n// Arena lobbies: two teams of four seats (a pilot's `team` and `seat` on their row), the host's map and\n// match type on the lobby row (`vsStage`, `vsMatch`), seat swaps asked for and answered on the rows.\nconst ARENA_MAX = 8           // pilots per Arena battle (4v4)\nconst ARENA_MINUTES = 3       // a Time Trial's length\nconst SWAP_MS = 20000         // a swap request stays open this long\nconst VS_ROUNDS = [3, 5, 10]  // round points that win a match, per `vsMatch`\nconst TEAMS = [{ id: 'blue', name: 'TEAM BLUE' }, { id: 'red', name: 'TEAM RED' }]\nconst TEAM_TINT = { blue: [0.55, 0.8, 1.3], red: [1.3, 0.52, 0.46] } // the hangar's ships, per arena team\nconst AWAY_MS = 12000         // no heartbeat for this long: shown as disconnected\nconst LIVE_MS = 60000         // no heartbeat for this long: kicked from the lobby\nconst PRUNE_MS = LIVE_MS      // the host deletes the rows of kicked pilots\nconst BEAT_MS = 4000          // heartbeat + refresh period inside a lobby\nconst SIG_MS = 1500           // signaling poll while linking\nconst LINK_TIMEOUT_MS = 15000 // a link that has not opened by then is retried\nconst STALE_MS = 6000         // no packet for this long: the link is dead\n\n// The network runtime, as TFSF_Menu_DEV has it: whichever menu runs first makes window[RT_KEY].net,\n// and it outlives both (the player flies on it).\n// ---- the network runtime (one per page, outlives this component) -----------------------------------\n// net.send(msg, { reliable, to }) — guests always send to the host; the host sends to one guest or\n// all of them, and relays guest messages to the other guests. net.on(type, fn) subscribes.\nconst ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:global.stun.twilio.com:3478' }]\nfunction makeNet() {\n  const net = { lobby: '', me: '', host: '', peers: new Map(), handlers: new Map(), handled: new Set(), links: {} }\n  net.isHost = () => !!net.me && net.me === net.host\n  net.on = (type, fn) => {\n    if (!net.handlers.has(type)) net.handlers.set(type, new Set())\n    net.handlers.get(type).add(fn)\n    return () => net.handlers.get(type).delete(fn)\n  }\n  const dispatch = (msg, from) => { for (const fn of net.handlers.get(msg.t) || []) { try { fn(msg, from) } catch (e) {} } }\n  const raw = (peer, msg, reliable) => {\n    const ch = reliable ? peer.r : (peer.u && peer.u.readyState === 'open' ? peer.u : peer.r)\n    if (ch && ch.readyState === 'open') { try { ch.send(typeof msg === 'string' ? msg : JSON.stringify(msg)) } catch (e) {} }\n  }\n  net.send = (msg, opts = {}) => {\n    const reliable = !!opts.reliable\n    if (net.isHost()) {\n      for (const [email, peer] of net.peers) if (!opts.to || opts.to === email) raw(peer, msg, reliable)\n    } else {\n      const host = net.peers.get(net.host)\n      if (host) raw(host, msg, reliable)\n    }\n  }\n  // Binary (ArrayBuffer) on the unreliable channel: co-op inputs. A guest's goes to the host; the\n  // host's goes to every guest (opts.except: all but one, for the host relaying a guest's packet).\n  // It arrives as { t: 'bin', data } and is not relayed here: the receiver decides.\n  net.sendBin = (buf, opts = {}) => {\n    const targets = net.isHost() ? [...net.peers.values()].filter((p) => p.email !== opts.except) : [net.peers.get(net.host)].filter(Boolean)\n    for (const peer of targets) {\n      const ch = peer.u && peer.u.readyState === 'open' ? peer.u : peer.r\n      if (ch && ch.readyState === 'open') { try { ch.send(buf) } catch (e) {} }\n    }\n  }\n  net.peerState = (email) => { const p = net.peers.get(email); return p ? p.state : 'none' }\n  net.closePeer = (email) => {\n    const p = net.peers.get(email)\n    if (!p) return\n    net.peers.delete(email)\n    try { p.u && p.u.close() } catch (e) {}\n    try { p.r && p.r.close() } catch (e) {}\n    try { p.pc.close() } catch (e) {}\n  }\n  net.closeAll = () => { for (const email of [...net.peers.keys()]) net.closePeer(email) }\n  // Wire a data channel: control messages (ping/pong) are handled here; the rest is dispatched, and\n  // the host relays guest messages to the other guests.\n  net.wire = (peer, ch, reliable) => {\n    if (reliable) peer.r = ch\n    else peer.u = ch\n    ch.binaryType = 'arraybuffer'\n    ch.onopen = () => { if (peer.r && peer.r.readyState === 'open') { peer.state = 'open'; peer.lastRx = Date.now() } }\n    ch.onclose = () => { if (net.peers.get(peer.email) === peer) peer.state = 'closed' }\n    ch.onmessage = (e) => {\n      peer.lastRx = Date.now()\n      if (typeof e.data !== 'string') return dispatch({ t: 'bin', data: e.data }, peer.email)\n      let msg\n      try { msg = JSON.parse(e.data) } catch (err) { return }\n      if (msg.t === 'ping') return raw(peer, { t: 'pong', ts: msg.ts }, false)\n      if (msg.t === 'pong') { peer.rtt = Date.now() - msg.ts; return }\n      const from = msg.from || peer.email\n      if (net.isHost() && !msg.from) {\n        const fwd = JSON.stringify({ ...msg, from: peer.email })\n        for (const [email, other] of net.peers) if (email !== peer.email) raw(other, fwd, reliable)\n      }\n      dispatch(msg, from)\n    }\n  }\n  net.newPeer = (email, sid) => {\n    net.closePeer(email)\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS })\n    const peer = { email, sid, pc, u: null, r: null, state: 'linking', rtt: 0, lastRx: 0, startedAt: Date.now() }\n    pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') peer.state = 'failed' }\n    pc.ondatachannel = (e) => net.wire(peer, e.channel, e.channel.label === 'r')\n    net.peers.set(email, peer)\n    return peer\n  }\n  // Non-trickle: wait until ICE gathering is done (STUN answers in well under a second; cap 5 s).\n  net.gathered = (pc) => new Promise((resolve) => {\n    if (pc.iceGatheringState === 'complete') return resolve()\n    const t = setTimeout(resolve, 5000)\n    pc.addEventListener('icegatheringstatechange', () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); resolve() } })\n  })\n  // Keep-alive: ping every second; a link with no traffic for STALE_MS is closed (guests then redial).\n  net.pinger = setInterval(() => {\n    const now = Date.now()\n    for (const [email, p] of net.peers) {\n      if (p.state === 'open') {\n        raw(p, { t: 'ping', ts: now }, false)\n        if (now - p.lastRx > STALE_MS) p.state = 'closed'\n      }\n    }\n    // The host tells everyone how each guest's link is doing (guests only see their own).\n    if (net.isHost()) {\n      const links = {}\n      for (const [email, p] of net.peers) links[email] = { state: p.state, rtt: p.rtt }\n      net.links = links\n      net.send({ t: 'links', links })\n    }\n  }, 1000)\n  net.on('links', (msg) => { net.links = msg.links || {} })\n  return net\n}\nconst MIXER = [{ id: 'master', label: 'MASTER' }, { id: 'sfx', label: 'SOUND EFFECTS' }, { id: 'bgm', label: 'MUSIC' },\n  { id: 'radio', label: 'RADIO (CPU)' }, { id: 'voice', label: 'VOICE CHAT' }]\nconst esc = (v) => String(v == null ? '' : v).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]))\n\n// One period of the horizontal ruler: 31 labels, 62.5px apart (1937.5px), drawn twice so the track\n// can slide by exactly one period and loop without a jump.\nconst RULER_H = ['-1F', '-1E', '-1D', '-1C', '-1B', '-1A', '-19', '-18', '-17', '-16', '-15', '-14', '-13', '-12', '-11', '-10', '-F', '-E', '-D', '-C', '-B', '-A', '-9', '-8', '-7', '-6', '-5', '-4', '-3', '-2', '-1']\nconst RULER_P = RULER_H.length * 62.5\n\nexport default {\n  css: `\n    :host { display: block; }\n    .mm { position: relative; width: 100%; height: 100%; overflow: hidden; background: #03080c; color: #fff;\n      font-family: 'MGS', 'Arial Narrow', system-ui, sans-serif; user-select: none; -webkit-user-select: none;\n      -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; touch-action: none; }\n    .gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n    .stage { position: absolute; top: 50%; width: ${W0}px; height: ${H0}px; transform-origin: 0 50%; pointer-events: none; }\n    .stage.left { left: 0; transform: translateY(-50%) scale(var(--s)); }\n    .stage.right { right: 0; transform-origin: 100% 50%; transform: translateY(-50%) scale(var(--s)); }\n    .stage * { box-sizing: border-box; }\n    .abs { position: absolute; }\n    .mono, .cap, .status, .item .code, .hruler .lab, .vruler .lab, .reticle .rd {\n      font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }\n    .boot .fade { opacity: 0; }\n    .fade { transition: opacity .6s ease .1s; }\n\n    /* thin stripes that loop: vertical stripes on a skewed strip, slid by exactly one period */\n    .stripe { overflow: hidden; }\n    .stripe i { position: absolute; top: 0; bottom: 0; left: -40px; right: -40px; --p: 10px;\n      background: repeating-linear-gradient(90deg, currentColor 0 2px, transparent 2px var(--p));\n      transform: skewX(-40deg); animation: stripe 1.2s linear infinite; }\n    @keyframes stripe { from { transform: translateX(0) skewX(-40deg); } to { transform: translateX(calc(-1 * var(--p))) skewX(-40deg); } }\n\n    /* header: one hairline, a caption over it, a short stripe at its end */\n    .hair { left: 76px; top: 44px; width: calc(831px + var(--ext, 0px)); height: 1px; background: linear-gradient(90deg, #ffffffcc, #ffffff55 70%, transparent);\n      transform-origin: 0 50%; transition: transform .8s cubic-bezier(.2,.8,.2,1); }\n    .boot .hair { transform: scaleX(0); }\n    .cap { left: 77px; top: 26px; font-size: 11px; letter-spacing: .3em; color: #8fd6ff; white-space: nowrap; }\n    .cap b { color: #ff6b5c; font-weight: 400; }\n    .cap span { color: #fff; }\n    .hstripe { left: calc(700px + var(--ext, 0px)); top: 36px; width: 150px; height: 6px; color: #ffffff; opacity: .5; }\n    .title { left: 80px; top: 58px; font-size: 112px; line-height: 1; letter-spacing: 1px; white-space: nowrap;\n      text-shadow: 0 0 22px #2d90ff55, 2px 3px 0 #0b1f45; }\n    .title .cur { display: inline-block; width: 16px; height: 35px; margin-left: 10px; background: #00dbff; vertical-align: baseline;\n      animation: blink 1s steps(1) infinite; box-shadow: 0 0 12px #00dbff; }\n    @keyframes blink { 50% { opacity: 0; } }\n\n    /* the menu: a perspective column, as in the concept */\n    .menuwrap { left: 108px; top: 212px; width: 300px; transform: scale(1.4, 1.22) rotateY(32deg) rotateX(-9deg);\n      transform-origin: left center; perspective: 900px; pointer-events: auto; }\n    .items { display: flex; flex-direction: column; gap: 0; }\n    .item { position: relative; display: block; width: 280px; height: 66px; padding: 7px 0 0 15px; appearance: none; border: 0;\n      background: none; color: inherit; font: inherit; cursor: pointer; outline: none; text-align: right;\n      transform: translateX(calc(var(--i) * -6px)); }\n    .item .box { position: absolute; left: 15px; top: 7px; width: 261px; height: 55px; border-radius: 13px;\n      box-shadow: inset 0 0 0 3px #fff; transition: box-shadow .14s ease, background .14s ease, transform .14s ease; overflow: hidden;\n      background: linear-gradient(90deg, #0a1a33cc, #04101e99);\n      -webkit-backdrop-filter: blur(10px) saturate(1.6) brightness(1.1); backdrop-filter: blur(10px) saturate(1.6) brightness(1.1); }\n    .lg .item .box { -webkit-backdrop-filter: none; backdrop-filter: none; background: linear-gradient(90deg, #0a1a3366, #04101e40); }\n    .lg .item.on .box { background: linear-gradient(90deg, #0e4f86a6, #0a2a5580); }\n    .lg .item.danger.on .box { background: linear-gradient(90deg, #6a1020a6, #2a071080); }\n    .lgm { position: absolute; width: 0; height: 0; pointer-events: none; }\n    .item .box::before { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 30%, #6be3ff55 48%, transparent 66%);\n      transform: translateX(-120%); }\n    /* low on the slab, as in the concept: the line box sits on the slab's bottom edge (MGS centres its\n       capitals in the line box, so they land about two thirds of the way down) */\n    .item .lbl { position: absolute; right: 16px; top: 7px; height: 55px; display: flex; align-items: flex-end;\n      font-size: 38px; line-height: 1; letter-spacing: -.5px; white-space: nowrap;\n      transition: transform .14s ease, color .14s ease; }\n    .item .code { position: absolute; left: 30px; top: 23px; font-size: 10px; letter-spacing: .15em; color: #8fd6ff; opacity: 0;\n      transition: opacity .14s ease; }\n    .item .logo { position: absolute; right: 14px; top: 13px; width: 170px; height: 46px; background: center right / contain no-repeat; }\n    .item .tick { position: absolute; left: 0; top: 26px; width: 10px; height: 14px; background: #00dbff; opacity: 0;\n      clip-path: polygon(0 0, 100% 50%, 0 100%); transition: opacity .14s ease, transform .14s ease; transform: translateX(-6px); }\n    .item.on .box { box-shadow: inset 0 0 0 3px #fff, 0 0 22px #00dbff99, inset 0 0 26px #00dbff55;\n      background: linear-gradient(90deg, #0e4f86e6, #0a2a55cc); transform: translateX(6px); }\n    .item.on .box::before { animation: sweep 1.4s ease-in-out infinite; }\n    @keyframes sweep { to { transform: translateX(120%); } }\n    .item.on .lbl { transform: translateX(6px); color: #fff; text-shadow: 0 0 10px #8ff0ff; }\n    .item.on .code { opacity: .9; }\n    .item.on .tick { opacity: 1; transform: none; animation: blink .5s steps(1) infinite; }\n    .item.danger.on .box { box-shadow: inset 0 0 0 3px #fff, 0 0 22px #ff3b3b88, inset 0 0 26px #ff3b3b44;\n      background: linear-gradient(90deg, #6a1020e6, #2a0710cc); }\n    .item.danger.on .tick { background: #ff4d4d; }\n    .item:active .box { transform: translateX(6px) scale(.97); }\n    .item.go .box { animation: flash .36s steps(2) 1; }\n    @keyframes flash { 50% { background: #fff; } }\n    .boot .item { opacity: 0; transform: translateX(-40px); }\n    .item { transition: opacity .35s ease calc(var(--i) * 70ms + 250ms), transform .45s cubic-bezier(.2,.8,.2,1) calc(var(--i) * 70ms + 250ms); }\n\n    /* bottom instrument: ruler over the baseline, sliding by one period */\n    .hruler { left: 0; top: 678px; width: calc(1366px + var(--ext, 0px)); height: 36px; overflow: hidden; opacity: .5;\n      mask-image: linear-gradient(90deg, transparent, #000 4%); -webkit-mask-image: linear-gradient(90deg, transparent, #000 4%); }\n    .hruler .track { position: absolute; left: 0; top: 0; height: 36px; width: ${RULER_P * 2}px; animation: rulerx 40s linear infinite; }\n    @keyframes rulerx { to { transform: translateX(-${RULER_P}px); } }\n    .hruler .ticks { position: absolute; left: 0; bottom: 0; width: 100%; height: 14px;\n      background: repeating-linear-gradient(90deg, #fff 0 1px, transparent 1px 12.5px) 0 100% / 100% 6px no-repeat,\n                  repeating-linear-gradient(90deg, #fff 0 1.5px, transparent 1.5px 62.5px) 0 100% / 100% 14px no-repeat; }\n    .hruler .lab { position: absolute; top: 4px; font-size: 11px; line-height: 1; color: #c7d4e6; transform: translateX(-50%); }\n    .baseline { left: 0; top: 718px; width: calc(1366px + var(--ext, 0px)); height: 1.5px; background: linear-gradient(90deg, #ffffffee, #ffffffaa); box-shadow: 0 0 8px #6bc3df66; }\n    .bstripe { left: calc(1180px + var(--ext, 0px)); top: 726px; width: 140px; height: 8px; color: #ffb347; opacity: .45; }\n\n    /* right edge: vertical ruler + thin stripe column */\n    .vruler { right: 46px; top: 90px; width: 40px; height: 520px; overflow: hidden; opacity: .45;\n      mask-image: linear-gradient(transparent, #000 8%, #000 92%, transparent); -webkit-mask-image: linear-gradient(transparent, #000 8%, #000 92%, transparent); }\n    .vruler .ticks { position: absolute; right: 0; top: 0; width: 14px; height: 100%;\n      background: repeating-linear-gradient(180deg, #fff 0 1px, transparent 1px 10.4px) 100% 0 / 6px 100% no-repeat,\n                  repeating-linear-gradient(180deg, #fff 0 1.5px, transparent 1.5px 52px) 100% 0 / 14px 100% no-repeat; }\n    .vruler .lab { position: absolute; right: 18px; font-size: 10px; line-height: 1; color: #c7d4e6; }\n    .vstripe { right: 22px; top: 90px; width: 8px; height: 520px; color: #fff; opacity: .3; }\n    .vstripe i { left: 0; right: 0; top: -40px; bottom: -40px; --p: 10px;\n      background: repeating-linear-gradient(180deg, currentColor 0 2px, transparent 2px var(--p));\n      transform: skewY(-40deg); animation: vstripe 1.2s linear infinite; }\n    @keyframes vstripe { from { transform: translateY(0) skewY(-40deg); } to { transform: translateY(calc(-1 * var(--p))) skewY(-40deg); } }\n\n    /* status strip, top right */\n    .status { right: max(110px, calc(112px / var(--s))); top: 60px; display: flex; gap: 22px; font-size: 11px; letter-spacing: .2em; color: #8fa6c9; white-space: nowrap; }\n    .status b { color: #fff; font-weight: 400; }\n    .status .ok b { color: #3dff8a; }\n\n\n    /* reticle on the ship */\n    .reticle { position: absolute; left: 0; top: 0; width: 0; height: 0; pointer-events: none; will-change: transform; }\n    .reticle .br { position: absolute; width: 18px; height: 18px; border: 1.5px solid #00dbffcc; }\n    .reticle .tl { left: -120px; top: -90px; border-right: 0; border-bottom: 0; }\n    .reticle .tr { left: 102px; top: -90px; border-left: 0; border-bottom: 0; }\n    .reticle .bl { left: -120px; top: 72px; border-right: 0; border-top: 0; }\n    .reticle .brr { left: 102px; top: 72px; border-left: 0; border-top: 0; }\n    .reticle .rd { position: absolute; left: -120px; top: 98px; font-size: 10px; line-height: 14px; letter-spacing: .14em; color: #9fe8ff;\n      white-space: nowrap; text-shadow: 0 0 6px #00dbff; }\n    .reticle .rd b { color: #fff; font-weight: 400; }\n    .reticle .off { display: none; position: absolute; left: 0; top: 0; transform: translate(-50%, -50%); font-size: 11px; letter-spacing: .3em;\n      color: #ff8a7a; white-space: nowrap; text-shadow: 0 0 8px #ff3b3b; }\n    .noship .reticle .off { display: block; }\n    .noship .reticle .rd, .noship .reticle .br { opacity: .35; }\n    .noship .reticle .br { border-color: #ff6b5c; }\n\n    /* corner buttons, top right */\n    .corner { position: absolute; z-index: 5; top: calc(var(--ty, 32px) - 22px); width: 44px; height: 44px; border-radius: 12px;\n      display: flex; align-items: center; justify-content: center; appearance: none; border: 0; padding: 0; cursor: pointer; pointer-events: auto;\n      color: #fff; box-shadow: inset 0 0 0 2.5px #fff; background: linear-gradient(90deg, #0a1a33cc, #04101e99);\n      -webkit-backdrop-filter: blur(10px) saturate(1.6) brightness(1.1); backdrop-filter: blur(10px) saturate(1.6) brightness(1.1);\n      transition: box-shadow .14s ease, background .14s ease, transform .14s ease; outline: none; }\n    .lg .corner { -webkit-backdrop-filter: none; backdrop-filter: none; background: linear-gradient(90deg, #0a1a3366, #04101e40); }\n    .corner svg { width: 22px; height: 22px; filter: drop-shadow(0 0 6px #8ff0ff66); }\n    .corner:hover, .corner.open { box-shadow: inset 0 0 0 2.5px #fff, 0 0 18px #00dbff88, inset 0 0 18px #00dbff44; background: linear-gradient(90deg, #0e4f86b0, #0a2a5588); }\n    .corner:active { transform: scale(.94); }\n    .corner.fs { right: calc(10px + env(safe-area-inset-right)); }\n    .corner.snd { right: calc(62px + env(safe-area-inset-right)); }\n    .corner.fs[hidden] + .corner.snd { right: calc(10px + env(safe-area-inset-right)); }\n    .corner .off, .corner.muted .on, .corner .ex, .corner.full .en { display: none; }\n    .corner.muted .off, .corner.full .ex { display: block; }\n    .corner.muted { color: #ff9a9a; }\n    .corner[hidden] { display: none; }\n    .sndpop { position: absolute; z-index: 6; top: calc(var(--ty, 32px) + 32px); right: calc(10px + env(safe-area-inset-right));\n      width: 290px; padding: 16px 18px 18px; border-radius: 16px; color: #fff; pointer-events: auto;\n      box-shadow: inset 0 0 0 3px #fff, 0 12px 40px -12px #000c; background: linear-gradient(90deg, #0a1a33d9, #04101eb3);\n      -webkit-backdrop-filter: blur(12px) saturate(1.6) brightness(1.1); backdrop-filter: blur(12px) saturate(1.6) brightness(1.1);\n      transform-origin: 100% 0; transition: opacity .16s ease, transform .16s cubic-bezier(.2,.8,.2,1); }\n    .lg .sndpop { -webkit-backdrop-filter: none; backdrop-filter: none; background: linear-gradient(90deg, #0a1a3399, #04101e73); }\n    .sndpop[hidden] { display: block; opacity: 0; transform: scale(.92) translateY(-6px); pointer-events: none; visibility: hidden; }\n    .sndpop .head { display: flex; align-items: center; gap: 14px; font-size: 26px; line-height: 1; margin-bottom: 12px; }\n    .sndpop .head .x { margin-left: auto; }\n    .sndpop .x { appearance: none; border: 0; background: none; color: #fff; font: 400 26px/1 system-ui, sans-serif; padding: 0 2px; cursor: pointer; opacity: .8; }\n    .sndpop .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-top: 1px solid #ffffff26;\n      font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; letter-spacing: .2em; color: #cfe6ff; }\n    .sndpop .row b { color: #fff; font-weight: 400; letter-spacing: .08em; }\n    .sndpop .col { flex-direction: column; align-items: stretch; gap: 10px; }\n    .sndpop .lab { display: flex; justify-content: space-between; }\n    .sw { position: relative; width: 50px; height: 28px; border-radius: 14px; appearance: none; border: 0; padding: 0; cursor: pointer; flex: none;\n      background: #ffffff26; box-shadow: inset 0 0 0 2px #ffffffb3; transition: background .16s ease; }\n    .sw::after { content: ''; position: absolute; left: 4px; top: 4px; width: 20px; height: 20px; border-radius: 50%; background: #fff;\n      box-shadow: 0 1px 4px #0008; transition: transform .16s cubic-bezier(.2,.8,.2,1); }\n    .sw.on { background: #00b4e6; box-shadow: inset 0 0 0 2px #fff, 0 0 12px #00dbff88; }\n    .sw.on::after { transform: translateX(22px); }\n    .sndpop input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 28px; background: none; margin: 0; touch-action: none; }\n    .sndpop input[type=range]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px;\n      background: linear-gradient(90deg, #00dbff var(--fill, 50%), #ffffff33 var(--fill, 50%)); }\n    .sndpop input[type=range]::-moz-range-track { height: 6px; border-radius: 3px; background: linear-gradient(90deg, #00dbff var(--fill, 50%), #ffffff33 var(--fill, 50%)); }\n    .sndpop input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; margin-top: -8px; border-radius: 50%;\n      background: #fff; box-shadow: 0 0 0 3px #00dbff, 0 2px 6px #0008; }\n    .sndpop input[type=range]::-moz-range-thumb { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px #00dbff; }\n    .sndpop.muted input[type=range] { opacity: .4; }\n    /* screens: one column (and panel) each; the one on shows */\n    /* every screen stays laid out and painted, the others transparent and out of reach: putting one up\n       is then an opacity change for the compositor, not a layout and a repaint of the whole screen in\n       the middle of the transition's frames */\n    .scr { display: block; }\n    .scr:not(.on) { opacity: 0 !important; pointer-events: none !important; }\n    .scr:not(.on) * { pointer-events: none !important; }\n    .menuwrap.enter .item { opacity: 0; transform: translateX(-40px); transition: none; }\n    .stage.left .scr, .title { transition: opacity .28s ease; }\n    .leaving .stage.left .scr, .leaving .title { opacity: 0; pointer-events: none; }\n    .sub .reticle { opacity: 0; }\n    /* profile and settings: read-outs straight on the stage, no box: glowing type, hairlines, ticks */\n    /* the read-outs run from the button column to the right-hand instruments, as wide as the screen allows */\n    .panel { left: 600px; top: 150px; width: max(700px, calc(var(--ext, 0px) + 620px)); color: #fff; pointer-events: auto;\n      text-shadow: 0 0 10px #000d, 0 0 2px #000; isolation: isolate; }\n    /* a soft wash, darkest at the right edge and gone by the column's side; no box edges */\n    .panel::before { content: ''; position: absolute; z-index: -1; left: -220px; right: -600px; top: -140px; bottom: -110px; pointer-events: none;\n      background: linear-gradient(270deg, #01050ba6, #01050b80 40%, #01050b00);\n      mask-image: linear-gradient(transparent, #000 22%, #000 80%, transparent); -webkit-mask-image: linear-gradient(transparent, #000 22%, #000 80%, transparent); }\n    .panel.on { animation: panelIn .45s cubic-bezier(.2,.8,.2,1); }\n    @keyframes panelIn { from { opacity: 0; transform: translateX(30px); } }\n    @keyframes hudIn { from { opacity: 0; transform: translateY(8px); } }\n    .sec { display: flex; align-items: center; gap: 12px; margin-top: 22px; font-size: 12px; letter-spacing: .32em; color: #00dbff; white-space: nowrap; }\n    .sec::before { content: ''; width: 7px; height: 7px; flex: none; background: #00dbff; box-shadow: 0 0 8px #00dbff; }\n    .sec::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, #ffffff88, #ffffff1a 70%, transparent); }\n    .sec:first-child { margin-top: 0; }\n    /* profile */\n    .pf-head { position: relative; display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; padding-bottom: 14px; }\n    .pf-head::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1.5px; background: linear-gradient(90deg, #fff, #ffffff33 75%, transparent); box-shadow: 0 0 8px #6bc3df66; }\n    .pf-head::before { content: ''; position: absolute; left: 0; bottom: -7px; width: 64px; height: 4px; background: #ffb347; opacity: .8; }\n    .pf-tag { font-size: 11px; letter-spacing: .32em; color: #8fd6ff; margin-bottom: 6px; }\n    .pf-call { font-size: 72px; line-height: .9; white-space: nowrap; text-shadow: 0 0 22px #2d90ffaa, 0 0 3px #000; }\n    .pf-mail { margin-top: 10px; font-size: 14px; letter-spacing: .14em; color: #8fd6ff; }\n    .pf-dates { display: grid; grid-template-columns: auto auto; gap: 6px 16px; font-size: 13px; letter-spacing: .16em; color: #8fa6c9; text-align: right; }\n    .pf-dates b { color: #fff; font-weight: 400; }\n    .pf-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px 22px; margin-top: 22px; }\n    .tile { position: relative; padding: 2px 0 2px 14px; animation: hudIn .4s cubic-bezier(.2,.8,.2,1) backwards; animation-delay: calc(var(--i, 0) * 35ms); }\n    .tile::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 1.5px; background: linear-gradient(#fff, #ffffff22); }\n    .tile::after { content: ''; position: absolute; left: 0; top: 0; width: 6px; height: 1.5px; background: #fff; }\n    .tile .k { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; letter-spacing: .18em; color: #8fd6ff; white-space: nowrap; }\n    .tile .v { margin-top: 6px; font: 300 44px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: -.02em; white-space: nowrap; text-shadow: 0 0 14px #2d90ff88, 0 0 2px #000; }\n    .tile .v small { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 13px; letter-spacing: .1em; color: #8fa6c9; margin-left: 8px; }\n    .tile.arena .k { color: #ffb347; }\n    .tile.arena::before { background: linear-gradient(#ffb347, #ffb34722); }\n    .tile.arena::after { background: #ffb347; }\n    .pf-recent { margin-top: 8px; font-size: 14px; letter-spacing: .08em; }\n    .pf-recent .r { display: grid; grid-template-columns: 16px 110px 84px 1fr auto; gap: 14px; padding: 4px 0; color: #cfe6ff; animation: hudIn .4s cubic-bezier(.2,.8,.2,1) backwards; }\n    .pf-recent .r::before { content: '›'; color: #00dbff; }\n    .pf-recent .r:nth-child(2) { opacity: .8; } .pf-recent .r:nth-child(n+3) { opacity: .62; }\n    .pf-recent .r .d { color: #8fa6c9; } .pf-recent .r .m { color: #00dbff; } .pf-recent .r .x { color: #fff; text-align: right; }\n    .pf-recent .r.bad .x { color: #ff8a7a; } .pf-recent .r.good .x { color: #3dff8a; }\n    .pf-recent .none { padding: 8px 0; color: #8fa6c9; }\n    /* multiplayer: the open lobbies */\n    .sec b { color: #fff; font-weight: 400; letter-spacing: .1em; }\n    .mp-list { margin-top: 14px; max-height: 452px; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;\n      scrollbar-width: thin; scrollbar-color: #00dbff55 transparent; display: flex; flex-direction: column; gap: 14px; padding: 3px 10px 3px 3px; }\n    /* a lobby: a card, as big as a slab, the whole of it a button */\n    .lrow { position: relative; appearance: none; border: 0; color: #fff; font: inherit; text-align: left; cursor: pointer; outline: none; flex: none;\n      display: grid; grid-template-columns: minmax(0, 1fr) auto 200px; align-items: center; gap: 30px; min-height: 132px; padding: 18px 22px 18px 34px;\n      border-radius: 16px; background: linear-gradient(90deg, #0a1a33d9, #04101eb3); box-shadow: inset 0 0 0 2px #ffffffb3, 0 14px 34px -18px #000c;\n      text-shadow: 0 0 10px #000d; transition: box-shadow .14s ease, background .14s ease, transform .14s ease; }\n    .lrow::before { content: ''; position: absolute; left: 14px; top: 20px; bottom: 20px; width: 4px; border-radius: 2px; background: #00dbff; box-shadow: 0 0 10px #00dbff; }\n    .lrow.arena::before { background: #ffb347; box-shadow: 0 0 10px #ffb347; }\n    .lrow:hover, .lrow:focus-visible { background: linear-gradient(90deg, #0e4f86c4, #0a2a55a6); box-shadow: inset 0 0 0 2.5px #fff, 0 0 24px #00dbff88, inset 0 0 26px #00dbff33; }\n    .lrow.arena:hover, .lrow.arena:focus-visible { background: linear-gradient(90deg, #6a3e0ac4, #3a2206a6); box-shadow: inset 0 0 0 2.5px #fff, 0 0 24px #ffb34788, inset 0 0 26px #ffb34733; }\n    .lrow:active { transform: scale(.985); }\n    .lrow.full { opacity: .5; cursor: default; }\n    .lrow.full:hover { background: linear-gradient(90deg, #0a1a33d9, #04101eb3); box-shadow: inset 0 0 0 2px #ffffff80; }\n    .lmain { min-width: 0; }\n    .ltop { display: flex; align-items: center; gap: 14px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; letter-spacing: .22em;\n      color: #8fa6c9; white-space: nowrap; overflow: hidden; }\n    .lmode { flex: none; padding: 3px 8px; border-radius: 4px; background: #00dbff; color: #021018; text-shadow: none; font-weight: 400; letter-spacing: .2em; }\n    .lrow.arena .lmode { background: #ffb347; }\n    .lname { display: block; margin-top: 10px; font-size: 46px; line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n      text-shadow: 0 0 18px #2d90ff88, 0 0 2px #000; }\n    .lcrew { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }\n    .lcrew span { padding: 3px 9px; border-radius: 4px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 11px; letter-spacing: .14em;\n      color: #cfe6ff; box-shadow: inset 0 0 0 1px #ffffff40; text-shadow: none; }\n    .lcrew span.rdy { color: #b9ffd4; box-shadow: inset 0 0 0 1px #3dff8a99; }\n    .lseats { text-align: right; }\n    .lcount { font: 300 50px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }\n    .lcount small { font-size: 22px; color: #8fa6c9; }\n    .lpips { display: flex; gap: 5px; justify-content: flex-end; margin-top: 10px; }\n    .lpips i { width: 9px; height: 20px; border-radius: 2px; box-shadow: inset 0 0 0 1px #ffffff59; }\n    .lpips i.on { background: #00dbff; box-shadow: 0 0 6px #00dbff; }\n    .lrow.arena .lpips i.on { background: #ffb347; box-shadow: 0 0 6px #ffb347; }\n    /* JOIN: the card's whole right end, top to bottom (the card's own outline drawn again over it) */\n    .ljoin { display: flex; align-items: center; justify-content: center; gap: 14px; align-self: stretch; margin: -18px -22px -18px 0;\n      border-radius: 0 16px 16px 0; font-size: 34px; letter-spacing: .06em; background: linear-gradient(90deg, #0e4f86e6, #0a2a55cc);\n      box-shadow: inset 0 0 0 2px #ffffffb3, inset 2px 0 0 #fff; transition: background .14s ease, color .14s ease, box-shadow .14s ease; }\n    .lrow:hover .ljoin, .lrow:focus-visible .ljoin { background: #00dbff; color: #021018; text-shadow: none; box-shadow: inset 0 0 0 2.5px #fff; }\n    .lrow.arena .ljoin { background: linear-gradient(90deg, #6a3e0ae6, #3a2206cc); }\n    .lrow.arena:hover .ljoin, .lrow.arena:focus-visible .ljoin { background: #ffb347; }\n    .lrow.full .ljoin { background: none; box-shadow: inset 2px 0 0 #ffffff33; font-size: 22px; color: #8fa6c9; }\n    .mp-list.fresh .lrow { animation: hudIn .4s cubic-bezier(.2,.8,.2,1) backwards; animation-delay: calc(var(--i, 0) * 45ms); }\n    .mp-list .none { padding: 18px 0; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 14px; letter-spacing: .14em; color: #8fa6c9; }\n    .mp-foot { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 11px; letter-spacing: .24em; color: #8fa6c9; }\n    .mp-foot::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #3dff8a; box-shadow: 0 0 8px #3dff8a; animation: blink 1.6s ease-in-out infinite; }\n    @keyframes blink { 50% { opacity: .25; } }\n    /* the create-lobby dialog: name and mode */\n    .seg { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n    .seg button { appearance: none; border: 0; border-radius: 10px; padding: 10px 12px; text-align: left; font: inherit; font-size: 18px; color: #fff; cursor: pointer;\n      background: #ffffff0f; box-shadow: inset 0 0 0 1.5px #ffffff40; transition: box-shadow .14s ease, background .14s ease; }\n    .seg button small { display: block; margin-top: 5px; font: 400 10px/1.2 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: .14em; color: #8fa6c9; }\n    .seg button.on { background: #00dbff1f; box-shadow: inset 0 0 0 2px #00dbff, 0 0 12px #00dbff55; }\n    .seg button[data-mode=\"arena\"].on { background: #ffb3471f; box-shadow: inset 0 0 0 2px #ffb347, 0 0 12px #ffb34755; }\n    /* the controls window */\n    /* a full-screen sheet: no frame, no glass, edge to edge (inside the notch) */\n    .dlg[data-dlg=\"controls\"] { align-items: stretch; justify-content: stretch; }\n    .dlg-box.sheet { box-sizing: border-box; width: 100%; height: 100%; max-height: none; border-radius: 0; gap: 18px; box-shadow: none;\n      padding: max(28px, env(safe-area-inset-top)) max(56px, calc(env(safe-area-inset-right) + 28px)) max(22px, env(safe-area-inset-bottom)) max(56px, calc(env(safe-area-inset-left) + 28px));\n      background: linear-gradient(90deg, #020814f7, #0a1a33f2 55%, #04101ef7); -webkit-backdrop-filter: none; backdrop-filter: none; }\n    .dlg-box.sheet .dlg-title { font-size: 40px; }\n    .dlg-box.sheet .cl-body { flex: 1; min-height: 0; gap: 40px; }\n    .dlg-box.sheet .cl-svg { max-height: 100%; }\n    .dlg-box.sheet .cl-art { height: 100%; min-height: 0; display: flex; align-items: center; }\n    .dlg-box.sheet .cl-list { max-height: 100%; height: 100%; }\n    .cl-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }\n    .cl-tabs .pick small { color: #8fd6ff; }\n    .cl-body { display: grid; grid-template-columns: 1.1fr 1fr; gap: 18px; align-items: center; min-height: 0; }\n    .cl-svg { display: block; width: 100%; max-height: 56vh; }\n    .cl-list { max-height: 56vh; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding-right: 6px; }\n    .cl-row { display: grid; grid-template-columns: 132px minmax(0, 1fr); gap: 12px; align-items: center; padding: 6px 0; border-bottom: 1px solid #ffffff14; }\n    .cl-k { display: flex; flex-wrap: wrap; gap: 4px; }\n    .cl-k kbd { padding: 3px 7px; border-radius: 5px; font: 400 11px/1.2 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: .1em; color: #fff;\n      box-shadow: inset 0 0 0 1px #ffffff80, 0 2px 0 #ffffff26; background: #ffffff0f; white-space: nowrap; }\n    .cl-v { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 11px; letter-spacing: .08em; color: #cfe6ff; }\n    .cl-note { margin-right: auto; align-self: center; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 11px; letter-spacing: .2em; color: #00dbff; }\n    .sv-frame { fill: #04101e; stroke: #fff; stroke-width: 2.5; }\n    .sv-scr { fill: #0a1a33; stroke: #ffffff40; stroke-width: 1; }\n    .sv-btn { fill: #ffffff14; stroke: #ffffffb3; stroke-width: 1.5; }\n    .sv-btn.hi { fill: #00dbff26; stroke: #00dbff; }\n    .sv-t { fill: #fff; font: 400 11px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; text-anchor: middle; letter-spacing: .06em; }\n    .sv-t.s { font-size: 7px; }\n    .sv-ct { fill: #00dbff; font: 400 10px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; text-anchor: middle; letter-spacing: .16em; }\n    .sv-ct.s { font-size: 8px; }\n    .sv-a { fill: none; stroke: #00dbff; stroke-width: 2; }\n    .sv-zone { fill: #00dbff0d; stroke: #00dbff88; stroke-width: 1; stroke-dasharray: 4 4; }\n    .sv-stick { fill: #ffffff0d; stroke: #ffffffaa; stroke-width: 1.5; stroke-dasharray: 3 3; }\n    .sv-knob { fill: #00dbff; }\n    .sv-ghost { fill: none; stroke: #ffffff59; stroke-width: 1.5; stroke-dasharray: 4 4; }\n    .sv-you { fill: none; stroke: #00dbff; stroke-width: 1.5; }\n    .sv-sep { stroke: #ffffff1f; stroke-width: 1; }\n    .sv-h { fill: #fff; font: 400 14px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; text-anchor: middle; letter-spacing: .24em; }\n    .sv-key { fill: #ffffff0f; stroke: #ffffff99; stroke-width: 1.2; }\n    .sv-key.steer { fill: #00dbff26; stroke: #00dbff; }\n    .sv-key.speed { fill: #3dff8a22; stroke: #3dff8a; }\n    .sv-key.bank { fill: #ffb34722; stroke: #ffb347; }\n    .sv-key.move { fill: #b48cff26; stroke: #b48cff; }\n    .sv-key.sys { fill: #ffffff14; stroke: #ffffffcc; }\n    .sv-key.hi { fill: #00dbff40; stroke: #00dbff; }\n    .sv-key.bomb { fill: #ff6b5c33; stroke: #ff6b5c; }\n    .sv-line { stroke: #ffffff99; stroke-width: 1.2; fill: none; }\n    .sv-legend text { fill: #cfe6ff; font: 400 9px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: .12em; }\n    /* the hangar (solo, adventure lobby): four slots across, a ship over a hex platform each; the\n       platform's front edges are the top of its card (drawn by the scene, the card's frame follows) */\n    .hangar { left: 540px; top: 150px; width: max(760px, calc(var(--ext, 0px) + 700px)); height: 500px; pointer-events: none; }\n    .hg-mission { display: flex; align-items: baseline; gap: 14px; font-size: 12px; letter-spacing: .28em; color: #00dbff; white-space: nowrap;\n      text-shadow: 0 0 10px #000d, 0 0 2px #000; }\n    .hg-mission::before { content: ''; width: 7px; height: 7px; flex: none; align-self: center; background: #00dbff; box-shadow: 0 0 8px #00dbff; }\n    .hg-mission b { font-family: inherit; font-weight: 400; font-size: 30px; letter-spacing: .04em; color: #fff; text-shadow: 0 0 16px #2d90ff99, 0 0 2px #000; }\n    .hg-mission small { font-size: 12px; letter-spacing: .2em; color: #00dbff; text-shadow: 0 0 8px #00dbff66, 0 0 2px #000; }\n    .hg-mission [data-hg-k] { display: none; }\n    .hg-mission.gone, .hg-mission.gone b { color: #ff8a7a; }\n    .hg-mission.gone::before { background: #ff6b5c; box-shadow: 0 0 8px #ff6b5c; }\n    .hg-rule { margin-top: 10px; height: 1px; background: linear-gradient(90deg, #ffffff88, #ffffff1a 70%, transparent); }\n    .hg-slots { position: absolute; left: 0; right: 0; top: 262px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 30px; }\n    /* the arena: a row of four per team, a smaller platform and card each; an open seat takes a tap (move\n       there), another pilot's asks them to swap */\n    /* two sections, a team each (2 x 2), a separator between them */\n    .hg-arena { display: none; position: absolute; left: 0; right: 0; top: 46px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 64px; }\n    .hangar.arena .hg-slots { display: none; }\n    .hangar.arena .hg-arena { display: grid; }\n    .hg-arena::before { content: ''; position: absolute; left: 50%; top: 4px; bottom: 0; width: 1px; transform: translateX(-.5px);\n      background: linear-gradient(transparent, #ffffff55 18%, #ffffff55 82%, transparent); }\n    .ar-vs { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 8px 6px; font-size: 22px; letter-spacing: .06em; color: #fff;\n      background: #04101e; box-shadow: inset 0 0 0 1.5px #ffffff88; border-radius: 8px; text-shadow: 0 0 12px #2d90ffaa; }\n    .ar-head { position: relative; z-index: 1; display: flex; width: max-content; align-items: center; gap: 12px; height: 26px; padding: 0 12px; border-radius: 6px;\n      background: #04101ee0; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tc) 45%, transparent); font-size: 12px; letter-spacing: .3em; color: var(--tc); white-space: nowrap; }\n    .ar-head::before { content: ''; width: 18px; height: 3px; background: var(--tc); box-shadow: 0 0 8px var(--tc); }\n    .ar-head b { font-weight: 400; color: #fff; letter-spacing: .2em; }\n    .ar-row.red .ar-head { flex-direction: row-reverse; margin-left: auto; }\n    .ar-row { --tc: #4da3ff; }\n    .ar-row.red { --tc: #ff5a5a; }\n    .ar-slots { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 74px 20px; margin-top: 64px; }\n    .slotc.mini { height: 156px; pointer-events: auto; cursor: pointer; }\n    .slotc.mini .sc-body { left: 12px; right: 10px; top: calc(var(--dip) + 6px); bottom: 10px; display: flex; flex-direction: column; }\n    .slotc.mini .sc-top { font-size: 10px; letter-spacing: .18em; }\n    .slotc.mini .sc-top [data-s-tag] { color: var(--tc); }\n    .slotc.mini .sc-pilot { margin-top: 3px; font-size: 24px; flex: none; }\n    .slotc.mini .sc-name { margin-top: 2px; font-size: 11px; flex: none; }\n    .slotc.mini .sc-link { position: static; margin-top: 2px; font-size: 9px; letter-spacing: .12em; flex: none; }\n    .slotc.mini .sc-state { position: static; margin-top: auto; flex: none; height: 24px; font-size: 11px; letter-spacing: .24em; }\n    /* an open seat: the card itself is the button (lit in its team's colour under the pointer / finger) */\n    .slotc.mini.open .sc-state { display: none; }\n    .slotc.mini.open .sc-pilot { opacity: .55; }\n    .slotc.mini.open:hover, .slotc.mini.open:active { --c: var(--tc); }\n    .slotc.mini.open:hover .sf-fill, .slotc.mini.open:active .sf-fill { fill: color-mix(in srgb, var(--tc) 22%, #020a16cc); }\n    .slotc.mini.open:hover .sf-edge, .slotc.mini.open:active .sf-edge { opacity: 1; stroke-width: 2.5; }\n    .slotc.mini.open:hover .sc-pilot, .slotc.mini.open:active .sc-pilot { opacity: 1; }\n    .slotc.mini.swap:hover .sf-edge { stroke: #ffd27a; opacity: 1; }\n    .slotc.mini .sc-state .alt { display: none; }\n    .slotc.mini.swap:hover .sc-state .now { display: none; }\n    .slotc.mini.swap:hover .sc-state .alt { display: inline; }\n    .slotc.mini.swap:hover .sc-state { background: #ffd27a; color: #1a1200; box-shadow: none; }\n    .slotc.mini.asked { --c: #ffd27a; }\n    .slotc.mini.asked .sc-state { animation: blink 1s steps(1) infinite; }\n    .slotc.mini.asked .sc-state::before { content: none; }\n    .slotc.mini.you .sf-edge { opacity: .95; stroke-width: 2.5; }\n    .slotc.mini.self { cursor: default; }\n    .slotc { position: relative; height: 216px; --c: #00dbff; --dip: 24px; transition: opacity .3s ease; }\n    .slotc.ready { --c: #3dff8a; }\n    .slotc.away { --c: #ff6b5c; }\n    .slotc.open { --c: #8fa6c9; }\n    .slotc svg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: visible; }\n    .sf-fill { fill: #020a16b8; transition: fill .3s ease; }\n    .slotc.ready .sf-fill { fill: #03180ec4; }\n    .slotc.open .sf-fill { fill: #020a1666; }\n    .sf-edge { fill: none; stroke: var(--c); stroke-width: 1.5; opacity: .55; vector-effect: non-scaling-stroke; }\n    .sc-body { position: absolute; left: 16px; right: 14px; top: calc(var(--dip) + 8px); bottom: 12px; color: #fff; text-shadow: 0 0 10px #000d, 0 0 2px #000; }\n    .sc-top { display: flex; justify-content: space-between; align-items: center; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;\n      font-size: 11px; letter-spacing: .22em; color: var(--c); white-space: nowrap; }\n    .sc-host { padding: 2px 6px; border-radius: 3px; background: #ffb347; color: #000; text-shadow: none; letter-spacing: .16em; }\n    .sc-host[hidden] { display: none; }\n    .sc-pilot { margin-top: 6px; font-size: 38px; line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-shadow: 0 0 18px var(--c), 0 0 2px #000; }\n    .sc-name { margin-top: 6px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 13px; letter-spacing: .12em; color: #cfe6ff;\n      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n    .sc-link { position: absolute; left: 0; right: 0; bottom: 42px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 10px;\n      letter-spacing: .16em; color: #8fa6c9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n    .sc-state { position: absolute; left: 0; right: 0; bottom: 0; height: 34px; display: flex; align-items: center; justify-content: center; gap: 10px;\n      border-radius: 6px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 13px; letter-spacing: .3em; color: var(--c);\n      box-shadow: inset 0 0 0 1.5px var(--c); text-shadow: none; transition: background .2s ease, color .2s ease; }\n    .slotc.ready .sc-state { background: var(--c); color: #021208; box-shadow: 0 0 18px #3dff8a88; }\n    .slotc.ready .sc-state::before { content: '✓'; letter-spacing: 0; }\n    .slotc.open .sc-pilot, .slotc.open .sc-name { opacity: .4; }\n    .slotc.open .sc-state { box-shadow: inset 0 0 0 1px #8fa6c955; }\n    .slotc.away .sc-state { animation: blink 1s steps(1) infinite; }\n    .slotc.you .sc-top::after { content: ''; }\n    .hangar.on .slotc { animation: hudIn .5s cubic-bezier(.2,.8,.2,1) backwards; animation-delay: calc(var(--i, 0) * 70ms + .2s); }\n    .item.dim { opacity: .42; }\n    .item.gone { display: none; }\n    /* the arena host's settings: top right of the hangar, on the stage's line */\n    .hg-opts { display: none; position: absolute; right: 0; top: -6px; gap: 10px; pointer-events: auto; }\n    .hangar.arena.host .hg-opts { display: flex; }\n    .hg-opt { appearance: none; border: 0; display: flex; align-items: baseline; gap: 10px; height: 40px; padding: 0 14px; border-radius: 10px; cursor: pointer;\n      font: inherit; color: #fff; background: linear-gradient(90deg, #0e4f86d9, #0a2a55c4); box-shadow: inset 0 0 0 2px #ffffffd9, 0 0 14px #00dbff44;\n      align-items: center; transition: background .14s ease, color .14s ease; }\n    .hg-opt small { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: .24em; color: #8fd6ff; }\n    .hg-opt b { font-weight: 400; font-size: 17px; letter-spacing: .04em; white-space: nowrap; }\n    .hg-opt i { font-style: normal; font-size: 14px; opacity: .8; }\n    .hg-opt:hover, .hg-opt:focus-visible, .hg-opt:active { background: #00dbff; color: #021018; outline: none; }\n    .hg-opt:hover small, .hg-opt:focus-visible small, .hg-opt:active small { color: #02324a; }\n    .dlg[data-part=\"stage\"] [data-part=\"rules\"], .dlg[data-part=\"rules\"] [data-part=\"stage\"] { display: none; }\n    .item.armed .box { box-shadow: inset 0 0 0 3px #3dff8a, 0 0 18px #3dff8a88, inset 0 0 18px #3dff8a44; }\n    .item.armed .lbl { color: #b9ffd4; }\n    .title.long { font-size: 74px; top: 74px; }\n    /* pickers: the mission (stage) and the pilot */\n    .dlg-box.wide { width: min(780px, 94vw); }\n    .pick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; max-height: min(52vh, 380px); overflow-y: auto;\n      overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 2px; }\n    .pick { appearance: none; border: 0; border-radius: 10px; padding: 10px 12px; text-align: left; font: inherit; font-size: 18px; color: #fff; cursor: pointer;\n      background: #ffffff0f; box-shadow: inset 0 0 0 1.5px #ffffff40; transition: box-shadow .14s ease, background .14s ease; }\n    .pick small { display: block; margin-top: 5px; font: 400 10px/1.2 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: .14em; color: #8fa6c9; }\n    .pick.on { background: #00dbff1f; box-shadow: inset 0 0 0 2px #00dbff, 0 0 12px #00dbff55; }\n    .pick.wolf small { color: #ff8a7a; }\n    .pick:disabled { opacity: .35; cursor: default; }\n    /* the mission picker: in sections, each mission with its world (as the game's map draws it) */\n    .pick-grid.missions { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); max-height: min(60vh, 440px); }\n    .pick-sec { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; margin: 10px 0 2px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;\n      font-size: 11px; letter-spacing: .32em; color: #00dbff; }\n    .pick-sec:first-child { margin-top: 0; }\n    .pick-sec::before { content: ''; width: 6px; height: 6px; background: #00dbff; box-shadow: 0 0 8px #00dbff; }\n    .pick-sec::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, #ffffff40, transparent); }\n    .pick.art { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: center; gap: 12px; padding: 8px 12px 8px 8px; }\n    .pick.art.wide { grid-column: 1 / -1; grid-template-columns: 150px minmax(0, 1fr); }\n    .pick.art canvas { display: block; width: 64px; height: 64px; }\n    .pick.art.wide canvas { width: 150px; }\n    .pick.art .pk { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n    .dlg.show .pick.art { animation: pickIn .5s cubic-bezier(.2,.8,.2,1) backwards; animation-delay: calc(var(--k, 0) * 28ms + 60ms); }\n    @keyframes pickIn { from { opacity: 0; transform: translateY(10px) scale(.96); } }\n    .pick-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); max-height: none; }\n    /* the tall pickers (mission, pilot): CLOSE at the top right, the list down to the box's bottom */\n    .dlg-box.tall { box-sizing: border-box; height: calc(100% - 2 * max(14px, env(safe-area-inset-bottom))); max-height: 820px; padding-bottom: 14px; }\n    .dlg-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: none; }\n    .dlg-box.tall .pick-grid, .dlg-box.tall .pk-wrap { flex: 1; min-height: 0; max-height: none; align-content: start; }\n    /* the pilot picker: each pilot's comms portrait and ship (the ships, one WebGL canvas over the list) */\n    .pk-wrap { position: relative; display: flex; flex-direction: column; }\n    .pk-wrap .pick-grid { flex: 1; min-height: 0; max-height: none; }\n    .pk-gl { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }\n    .pick-grid.pilots { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); align-content: start; }\n    .pick.pil { position: relative; display: grid; grid-template-columns: 72px minmax(0, 1fr) 150px; align-items: center; gap: 14px; height: 96px; padding: 0 8px 0 12px; overflow: hidden; }\n    .pick.pil canvas { display: block; width: 72px; height: 72px; border-radius: 6px; image-rendering: pixelated; box-shadow: 0 0 0 1.5px #ffffff59, 0 0 14px #00dbff33; background: #020814; }\n    .pick.pil.wolf canvas { box-shadow: 0 0 0 1.5px #ffffff59, 0 0 14px #ff5a4a33; }\n    .pick.pil.on canvas { box-shadow: 0 0 0 2px #00dbff, 0 0 16px #00dbff88; }\n    .pick.pil .pk { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 22px; }\n    .pick.pil .pk-ship { align-self: stretch; }\n    .pick.pil::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 170px; pointer-events: none;\n      background: radial-gradient(ellipse at 60% 60%, #00dbff1c, transparent 70%); }\n    .pick.pil.wolf::after { background: radial-gradient(ellipse at 60% 60%, #ff5a4a1c, transparent 70%); }\n    .dlg.show .pick.pil { animation: pickIn .5s cubic-bezier(.2,.8,.2,1) backwards; animation-delay: calc(var(--k, 0) * 45ms + 60ms); }\n    .dlg .sec:first-of-type { margin-top: 4px; }\n    .sw-msg { font-size: 14px; letter-spacing: .14em; line-height: 1.5; color: #cfe6ff; }\n    .sw-msg b { color: #fff; font-weight: 400; }\n    /* everyone ready: the alert, laid out as an emergency screen (striped bands, a tag, the word running\n       across) but in the menu's own dress: navy glass, white hairlines, cyan light, the menu's face.\n       DEPART for the host (lit like a picked slab, a button), AWAITING DEPART for the others */\n    .alert { left: 520px; top: 268px; width: max(800px, calc(var(--ext, 0px) + 740px)); height: 220px; z-index: 4; pointer-events: none; --a: #00dbff;\n      opacity: 0; visibility: hidden; transition: opacity .2s ease, visibility 0s linear .2s; }\n    .alert.on { opacity: 1; visibility: visible; transition: opacity .12s ease; }\n    .leaving .alert, .dlgon .alert { opacity: 0; }\n    .al-band { position: relative; height: 40px; overflow: hidden; background: linear-gradient(90deg, #020814b3, #020814e6 30%, #020814e6 70%, #020814b3); }\n    .al-band i { position: absolute; top: 9px; bottom: 9px; width: 50%; opacity: .5; }\n    .al-band.bot i { top: 9px; bottom: 9px; }\n    .al-band i.l { left: 0; background: repeating-linear-gradient(-50deg, #fff 0 2px, transparent 2px 13px); animation: hzL 1.4s linear infinite;\n      mask-image: linear-gradient(90deg, transparent, #000 30%); -webkit-mask-image: linear-gradient(90deg, transparent, #000 30%); }\n    .al-band i.r { right: 0; background: repeating-linear-gradient(50deg, #fff 0 2px, transparent 2px 13px); animation: hzR 1.4s linear infinite;\n      mask-image: linear-gradient(270deg, transparent, #000 30%); -webkit-mask-image: linear-gradient(270deg, transparent, #000 30%); }\n    @keyframes hzL { to { background-position: 16.97px 0; } }\n    @keyframes hzR { to { background-position: -16.97px 0; } }\n    .al-band .kj { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 14px; padding: 0 22px;\n      white-space: nowrap; background: linear-gradient(90deg, transparent, #04101e 18%, #04101e 82%, transparent); color: #8fd6ff;\n      font: 400 11px/40px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: .3em; text-shadow: 0 0 8px #000; }\n    .al-band .kj b { font: 700 20px/40px 'Hiragino Sans', 'Yu Gothic', 'Noto Sans JP', 'Noto Sans CJK JP', sans-serif; letter-spacing: .18em; color: #fff;\n      text-shadow: 0 0 12px var(--a); }\n    .al-band .kj::before, .al-band .kj::after { content: ''; width: 7px; height: 7px; flex: none; background: var(--a); box-shadow: 0 0 8px var(--a); }\n    .al-mid { position: relative; display: block; width: 100%; height: 118px; margin: 6px 0; padding: 0; border: 0; appearance: none; overflow: hidden; outline: none;\n      font: inherit; color: #fff; cursor: default; background: linear-gradient(90deg, #0a1a33f5, #04101ef0 50%, #0a1a33f5);\n      box-shadow: inset 0 1.5px 0 #ffffffcc, inset 0 -1.5px 0 #ffffffcc, 0 0 24px -6px #6bc3df88;\n      /* (no backdrop filter: under one, iOS Safari stops repainting the running word, cut and frozen) */\n      transition: background .16s ease, box-shadow .16s ease, transform .14s ease; }\n    .al-mid::before { content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 1;\n      background: linear-gradient(90deg, #020814 0, transparent 9%, transparent 91%, #020814 100%); }\n    /* the running words: a few short blocks, each moved on its own (one long strip is a layer wider\n       than iOS Safari will animate: it shows it cut, and frozen) */\n    .al-track { position: absolute; inset: 0; }\n    .al-it { position: absolute; left: 0; top: 0; height: 100%; display: flex; align-items: center; white-space: nowrap;\n      will-change: transform; -webkit-backface-visibility: hidden; backface-visibility: hidden; }\n    .al-track span { padding: 0 36px; font-size: 84px; line-height: 1; letter-spacing: 2px; color: #fff; text-shadow: 0 0 26px #2d90ffaa, 2px 3px 0 #0b1f45; }\n    .al-track em { font-style: normal; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 26px; letter-spacing: -.1em; color: var(--a);\n      text-shadow: 0 0 10px var(--a); }\n    .al-cta { position: absolute; right: 14%; bottom: 8px; z-index: 2; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px;\n      letter-spacing: .3em; color: var(--a); text-shadow: 0 0 8px var(--a); display: none; animation: blink 1s steps(1) infinite; }\n    .al-rule { display: none; }\n    .alert.on .al-band.top { animation: alL .5s cubic-bezier(.2,.8,.2,1) both; }\n    .alert.on .al-band.bot { animation: alR .5s cubic-bezier(.2,.8,.2,1) both; }\n    .alert.on .al-mid { animation: alIn .45s cubic-bezier(.2,.8,.2,1) both; }\n    @keyframes alL { from { transform: translateX(-30%); opacity: 0; } }\n    @keyframes alR { from { transform: translateX(30%); opacity: 0; } }\n    @keyframes alIn { from { transform: scaleY(0); opacity: 0; } }\n    /* waiting: dimmer, slower */\n    .alert.wait .al-track span { color: #cfe6ffcc; text-shadow: 0 0 18px #2d90ff66, 2px 3px 0 #0b1f45; }\n    .alert.wait .al-band i { animation-duration: 3s; opacity: .3; }\n    /* the host's: a button, lit like a picked slab; pressed, it gives */\n    .alert.go .al-mid { pointer-events: auto; cursor: pointer; background: linear-gradient(90deg, #0e4f86d9, #0a2a55cc 50%, #0e4f86d9);\n      box-shadow: inset 0 0 0 3px #fff, 0 0 26px #00dbff99, inset 0 0 30px #00dbff55; animation: alIn .45s cubic-bezier(.2,.8,.2,1) both, alGo 1.4s ease-in-out .45s infinite; }\n    .alert.go .al-cta { display: block; }\n    /* DEPART: across the whole screen, at its middle (the waiting ones sit in front of the ships) */\n    .alert.go { left: 0; top: 274px; width: calc(1366px + var(--ext, 0px)); }\n    .alert.go .al-track span { font-size: 96px; }\n    @keyframes alGo { 50% { box-shadow: inset 0 0 0 3px #fff, 0 0 44px #00dbffcc, inset 0 0 50px #00dbff88; } }\n    .alert.go .al-mid:hover, .alert.go .al-mid:focus-visible { background: linear-gradient(90deg, #1466a8e6, #0e4f86d9 50%, #1466a8e6); }\n    .alert.go .al-mid:active { transform: scale(.985); }\n    .alert.go .al-band i { opacity: .7; }\n    /* settings */\n    .st-row { display: flex; align-items: center; gap: 28px; min-height: 58px; padding: 2px 0;\n      font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 15px; letter-spacing: .2em; color: #cfe6ff; }\n    .st-row > span { width: 230px; flex: none; }\n    .st-row b { width: 76px; text-align: right; color: #fff; font-weight: 400; font-size: 18px; letter-spacing: .06em; }\n    .st-row .na { font-style: normal; font-size: 13px; color: #8fa6c9; letter-spacing: .14em; }\n    .st-row input[type=range], .sndpop input[type=range] { flex: 1; }\n    .panel.muted .mix { opacity: .4; }\n    .panel input[type=range] { -webkit-appearance: none; appearance: none; height: 30px; margin: 0; touch-action: none; cursor: pointer;\n      background: repeating-linear-gradient(90deg, #ffffff66 0 1px, transparent 1px 12.5%) 4px 100% / calc(100% - 8px) 6px no-repeat,\n                  linear-gradient(#ffffff66, #ffffff66) right 3px bottom 0 / 1px 6px no-repeat; }\n    .panel input[type=range]::-webkit-slider-runnable-track { height: 2px; background: linear-gradient(90deg, #00dbff var(--fill, 50%), #ffffff40 var(--fill, 50%)); box-shadow: 0 0 6px #00dbff55; }\n    .panel input[type=range]::-moz-range-track { height: 2px; background: linear-gradient(90deg, #00dbff var(--fill, 50%), #ffffff40 var(--fill, 50%)); }\n    .panel input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 8px; height: 22px; margin-top: -10px; border-radius: 2px; background: #fff; box-shadow: 0 0 10px #00dbff, 0 0 2px #000; }\n    .panel input[type=range]::-moz-range-thumb { width: 8px; height: 22px; border: 0; border-radius: 2px; background: #fff; box-shadow: 0 0 10px #00dbff; }\n    /* dialogs: nickname, password */\n    .dlg { position: absolute; inset: 0; z-index: 8; display: flex; align-items: center; justify-content: center; pointer-events: auto;\n      background: radial-gradient(ellipse at center, #02060c66, #02060ccc); opacity: 0; transition: opacity .24s ease; }\n    .dlg.show { opacity: 1; }\n    .dlg:not(.show) { pointer-events: none; }\n    .dlg[hidden] { display: none; }\n    .dlg .dlg-box { transform: translateY(14px) scale(.97); transition: transform .3s cubic-bezier(.2,.8,.2,1); }\n    .dlg.show .dlg-box { transform: none; }\n    .stage, .reticle { transition: opacity .24s ease; }\n    .trv .reticle { opacity: 0; transition-duration: .16s; }\n    .dlgon .stage, .dlgon .reticle { opacity: .08; pointer-events: none; }\n    .dlg-box { width: min(420px, 92vw); padding: 20px 22px; border-radius: 16px; color: #fff; display: flex; flex-direction: column; gap: 12px;\n      box-shadow: inset 0 0 0 3px #fff, 0 18px 50px -12px #000e; background: linear-gradient(90deg, #0a1a33f0, #04101ee6);\n      -webkit-backdrop-filter: blur(12px) saturate(1.6) brightness(1.1); backdrop-filter: blur(12px) saturate(1.6) brightness(1.1); }\n    .lg .dlg-box { -webkit-backdrop-filter: none; backdrop-filter: none; background: linear-gradient(90deg, #0a1a33d9, #04101ec7); }\n    .dlg-title { font-size: 28px; line-height: 1; }\n    .fld { display: flex; flex-direction: column; gap: 6px; }\n    .fld span { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: .18em; color: #8fd6ff; }\n    .fld input { appearance: none; border: 0; border-radius: 10px; padding: 11px 13px; font: 400 16px/1.2 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;\n      color: #fff; background: #ffffff14; box-shadow: inset 0 0 0 1.5px #ffffff55; outline: none; -webkit-user-select: text; user-select: text; }\n    .fld input:focus { box-shadow: inset 0 0 0 2px #00dbff, 0 0 12px #00dbff55; }\n    .dlg-err { min-height: 16px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; color: #ff8a7a; }\n    .dlg-btns { display: flex; justify-content: flex-end; gap: 10px; }\n    .dbtn { appearance: none; border: 0; padding: 0 18px; height: 44px; border-radius: 12px; font: inherit; font-size: 18px; color: #fff; cursor: pointer;\n      box-shadow: inset 0 0 0 2.5px #fff; background: linear-gradient(90deg, #0a1a33cc, #04101e99); }\n    .dbtn.go { box-shadow: inset 0 0 0 2.5px #fff, 0 0 18px #00dbff88, inset 0 0 18px #00dbff44; background: linear-gradient(90deg, #0e4f86e6, #0a2a55cc); }\n    .dbtn:disabled { opacity: .5; }\n    .toast { position: absolute; z-index: 9; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom)); transform: translateX(-50%); padding: 9px 18px;\n      border-radius: 12px; font-size: 12px; letter-spacing: .16em; color: #fff; background: #04101ee6; box-shadow: inset 0 0 0 1.5px #ffffffb3;\n      opacity: 0; transition: opacity .2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* portrait (a phone held upright): the menu on top, the ship below it, the instruments away */\n    .portrait .stage.left { top: calc(58px + env(safe-area-inset-top)); transform: scale(var(--s)) translateX(-50px); transform-origin: 0 0; }\n    .portrait .corner { top: calc(10px + env(safe-area-inset-top)); }\n    .portrait .sndpop { top: calc(64px + env(safe-area-inset-top)); }\n    .portrait .panel { left: 60px; top: 470px; width: 580px; }\n    .portrait .pf-call { font-size: 52px; }\n    .portrait .pf-grid { grid-template-columns: repeat(3, 1fr); gap: 14px 14px; }\n    .portrait .pf-head { flex-direction: column; align-items: flex-start; }\n    .portrait .pf-dates { text-align: left; }\n    .portrait .st-row > span { width: 150px; }\n    .portrait .st-row { gap: 18px; }\n    .portrait .pf-recent { font-size: 12px; }\n    .portrait .pf-recent .r { grid-template-columns: 12px 84px 62px 1fr auto; gap: 10px; }\n    .portrait .lrow { grid-template-columns: minmax(0, 1fr) 110px; gap: 14px; }\n    .portrait .lseats { display: none; }\n    .portrait .mp-list { max-height: 520px; }\n    .portrait .hangar { left: 60px; top: 690px; width: 580px; height: 600px; }\n    .portrait .hg-mission b { font-size: 22px; }\n    .portrait .hg-mission small { display: none; }\n    .portrait .hg-slots { top: 170px; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 12px; }\n    .portrait .slotc { height: 196px; }\n    .portrait .sc-body { left: 8px; right: 6px; }\n    .portrait .sc-top { font-size: 9px; letter-spacing: .12em; }\n    .portrait .sc-host { font-size: 8px; padding: 1px 4px; }\n    .portrait .sc-pilot { font-size: 21px; }\n    .portrait .sc-name { font-size: 10px; letter-spacing: .06em; }\n    .portrait .sc-link { bottom: 32px; font-size: 8px; letter-spacing: .08em; }\n    .portrait .sc-state { height: 26px; font-size: 10px; letter-spacing: .14em; }\n    .portrait .alert { top: 780px; left: 50px; width: 640px; }\n    .portrait .al-track span { font-size: 64px; }\n    .portrait .hruler, .portrait .baseline, .portrait .bstripe, .portrait .stage.right, .portrait .reticle .rd { display: none; }\n    .nogl .gl { display: none; }\n    .nogl { background: radial-gradient(ellipse at 15% 0%, #12306a 0%, #03080c 55%); }\n  `,\n\n  render() {\n    const hlabs = RULER_H.concat(RULER_H).map((t, i) => `<span class=\"lab\" style=\"left:${31.25 + i * 62.5}px\">${t}</span>`).join('')\n    const vlabs = ['+4', '+3', '+2', '+1', '±0', '-1', '-2', '-3', '-4', '-5'].map((t, i) => `<span class=\"lab\" style=\"top:${i * 52 + 4}px\">${t}</span>`).join('')\n    return `<div class=\"mm boot\" data-d2-static>\n      <canvas class=\"gl\"></canvas>\n      <div class=\"stage left\">\n        <div class=\"abs cap fade\">TFSF // FLIGHT SYSTEM <b>DEV</b> · PILOT <span data-pilot>—</span></div>\n        <div class=\"abs hair\"></div>\n        <div class=\"abs stripe hstripe fade\"><i></i></div>\n        <div class=\"abs title fade\"><span data-title>MAIN MENU</span><span class=\"cur\"></span></div>\n        ${Object.entries(SCREENS).map(([key, sc]) => `<div class=\"abs menuwrap scr\" data-scr=\"${key}\"><div class=\"items\">\n          ${sc.items.map((it, i) => `<button class=\"item ${it.danger ? 'danger' : ''}\" data-item=\"${it.id}\" style=\"--i:${i}\" aria-label=\"${it.label}\">\n            <span class=\"tick\"></span><span class=\"box\"></span><span class=\"code\">${it.code}</span>\n            ${it.logo ? `<span class=\"logo\" style=\"background-image:url(${ASSET(LOGO_REF)})\"></span>` : `<span class=\"lbl\">${it.label}</span>`}\n          </button>`).join('')}\n        </div></div>`).join('')}\n        <div class=\"abs panel scr\" data-scr=\"profile\">\n          <div class=\"pf-head\">\n            <div class=\"pf-id\"><div class=\"pf-tag mono\">CALLSIGN</div><div class=\"pf-call\" data-pf-call>—</div><div class=\"pf-mail mono\" data-pf-mail>—</div></div>\n            <div class=\"pf-dates mono\"><span>FIRST FLIGHT</span><b data-pf-first>—</b><span>LAST FLIGHT</span><b data-pf-last>—</b></div>\n          </div>\n          <div class=\"pf-grid\" data-pf-grid></div>\n          <div class=\"sec mono\">RECENT FLIGHTS</div>\n          <div class=\"pf-recent mono\" data-pf-recent></div>\n        </div>\n        <div class=\"abs hangar scr\" data-scr=\"solo room\">\n          <div class=\"hg-mission mono\"><span data-hg-k>MISSION</span><b data-hg-mission>—</b><small data-hg-kind></small></div>\n          <div class=\"hg-opts\"><button type=\"button\" class=\"hg-opt\" data-opt=\"stage\"><small>STAGE</small><b data-opt-stage>—</b><i>✎</i></button>\n            <button type=\"button\" class=\"hg-opt\" data-opt=\"rules\"><small>RULES</small><b data-opt-rules>—</b><i>✎</i></button></div>\n          <div class=\"hg-rule\"></div>\n          <div class=\"hg-slots\">${[0, 1, 2, 3].map((i) => `<div class=\"slotc open\" data-slot=\"${i}\" style=\"--i:${i}\">\n            <svg class=\"sframe\" preserveAspectRatio=\"none\" aria-hidden=\"true\"><path class=\"sf-fill\"/><path class=\"sf-edge\"/></svg>\n            <div class=\"sc-body\"><div class=\"sc-top\"><span data-s-tag>P${i + 1}</span><span class=\"sc-host\" data-s-host hidden>HOST</span></div>\n              <div class=\"sc-pilot\" data-s-pilot>—</div><div class=\"sc-name\" data-s-name></div>\n              <div class=\"sc-link\" data-s-link></div><div class=\"sc-state\" data-s-state>OPEN</div></div>\n          </div>`).join('')}</div>\n          <div class=\"hg-arena\"><b class=\"ar-vs\">VS</b>${TEAMS.map((tm, ti) => `<div class=\"ar-row ${tm.id}\">\n            <div class=\"ar-head mono\">${tm.name} <b data-ar-count=\"${tm.id}\">0 / 4</b></div>\n            <div class=\"ar-slots\">${[0, 1, 2, 3].map((i) => `<div class=\"slotc mini open\" data-seat=\"${tm.id}:${i}\" style=\"--i:${ti * 4 + i}\">\n              <svg class=\"sframe\" preserveAspectRatio=\"none\" aria-hidden=\"true\"><path class=\"sf-fill\"/><path class=\"sf-edge\"/></svg>\n              <div class=\"sc-body\"><div class=\"sc-top\"><span data-s-tag>${tm.id.toUpperCase()} ${i + 1}</span><span class=\"sc-host\" data-s-host hidden>HOST</span></div>\n                <div class=\"sc-pilot\" data-s-pilot>OPEN</div><div class=\"sc-name\" data-s-name></div>\n                <div class=\"sc-link\" data-s-link></div><div class=\"sc-state\"><span class=\"now\" data-s-state>OPEN</span><span class=\"alt\">⇄ ASK TO SWAP</span></div></div>\n            </div>`).join('')}</div>\n          </div>`).join('')}</div>\n        </div>\n        <div class=\"abs alert\" data-alert aria-live=\"polite\">\n          <div class=\"al-band top\"><i class=\"l\"></i><i class=\"r\"></i><span class=\"kj\"><span data-al-c1>LAUNCH SEQUENCE</span><b data-al-kj1>発進準備完了</b></span></div>\n          <button class=\"al-mid\" data-al-go tabindex=\"-1\" aria-label=\"Depart\"><span class=\"al-track\" data-al-track></span><b class=\"al-cta\">▶ TAP TO LAUNCH</b></button>\n          <div class=\"al-rule\"></div>\n          <div class=\"al-band bot\"><i class=\"l\"></i><i class=\"r\"></i><span class=\"kj\"><b data-al-kj2>全機発進</b><span data-al-c2>ALL WINGS READY</span></span></div>\n        </div>\n        <div class=\"abs panel scr\" data-scr=\"mp\">\n          <div class=\"sec mono\">OPEN LOBBIES <b data-mp-count></b></div>\n          <div class=\"mp-list scroll\" data-mp-list></div>\n          <div class=\"mp-foot mono\">LIVE · TAP A LOBBY TO JOIN IT</div>\n        </div>\n        <div class=\"abs panel scr\" data-scr=\"settings\">\n          <div class=\"sec mono\">AUDIO</div>\n          <div class=\"st-row\"><span>SOUND</span><button class=\"sw\" data-st-sound role=\"switch\" aria-label=\"Sound\"></button></div>\n          ${MIXER.map((m) => `<div class=\"st-row mix\" data-mix=\"${m.id}\"><span>${m.label}</span>\n            <input type=\"range\" min=\"0\" max=\"200\" step=\"5\" value=\"100\" data-vol=\"${m.id}\" aria-label=\"${m.label}\"><b>100%</b></div>`).join('')}\n          <div class=\"sec mono\">DISPLAY</div>\n          <div class=\"st-row\"><span>FULLSCREEN</span><button class=\"sw\" data-st-fs role=\"switch\" aria-label=\"Fullscreen\"></button><em class=\"na\" hidden>NOT AVAILABLE ON THIS DEVICE</em></div>\n        </div>\n        <div class=\"abs hruler fade\"><div class=\"track\">${hlabs}<div class=\"ticks\"></div></div></div>\n        <div class=\"abs baseline fade\"></div>\n        <div class=\"abs stripe bstripe fade\"><i></i></div>\n      </div>\n      <div class=\"stage right\">\n        <div class=\"abs status fade\">\n          <span class=\"ok\">SYNC <b data-sync>98.6</b>%</span><span>VEL <b data-vel>0.00</b></span><span>G-DIFF <b data-gd>OK</b></span><span><b data-clock>00:00:00</b></span>\n        </div>\n        <div class=\"abs vruler fade\"><div class=\"ticks\"></div>${vlabs}</div>\n        <div class=\"abs stripe vstripe fade\"><i></i></div>\n      </div>\n      <div class=\"reticle fade\"><span class=\"br tl\"></span><span class=\"br tr\"></span><span class=\"br bl\"></span><span class=\"br brr\"></span>\n        <div class=\"rd\">ARWING <b>FOX-01</b><br>HDG <b data-hdg>000</b> · ALT <b data-alt>000</b><br>BOOST <b data-boost>0%</b></div>\n        <div class=\"off mono\">NO SIGNAL · SIGN IN</div></div>\n      <button class=\"corner fs\" data-act=\"fullscreen\" aria-label=\"Fullscreen\" hidden>\n        <svg class=\"en\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/></svg>\n        <svg class=\"ex\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/></svg>\n      </button>\n      <div class=\"dlg\" data-dlg=\"controls\" hidden><div class=\"dlg-box sheet\">\n        <div class=\"dlg-title\">CONTROLS</div>\n        <div class=\"cl-tabs\" data-cl-tabs></div>\n        <div class=\"cl-body\"><div class=\"cl-art\" data-cl-art></div><div class=\"cl-list scroll\" data-cl-list></div></div>\n        <div class=\"dlg-btns\"><span class=\"cl-note\" data-cl-note></span><button type=\"button\" class=\"dbtn\" data-dlg-close>CLOSE</button></div>\n      </div></div>\n      <div class=\"dlg\" data-dlg=\"mission\" hidden><div class=\"dlg-box wide tall pane\" data-r=\"16\">\n        <div class=\"dlg-head\"><div class=\"dlg-title\">MISSION</div><button type=\"button\" class=\"dbtn\" data-dlg-close>CLOSE</button></div>\n        <div class=\"pick-grid scroll\" data-pick-mission></div>\n      </div></div>\n      <div class=\"dlg\" data-dlg=\"battle\" hidden><div class=\"dlg-box wide pane\" data-r=\"16\">\n        <div class=\"dlg-title\" data-battle-title>BATTLE</div>\n        <div class=\"pick-grid three\" data-part=\"stage\" data-pick-vsstage></div>\n        <div class=\"pick-grid three\" data-part=\"rules\" data-pick-vsmatch></div>\n        <div class=\"dlg-btns\"><button type=\"button\" class=\"dbtn\" data-dlg-close>DONE</button></div>\n      </div></div>\n      <div class=\"dlg\" data-dlg=\"swap\" hidden><div class=\"dlg-box pane\" data-r=\"16\">\n        <div class=\"dlg-title\">SWAP SEATS?</div>\n        <div class=\"sw-msg mono\" data-sw-msg></div>\n        <div class=\"dlg-btns\"><button type=\"button\" class=\"dbtn\" data-sw=\"no\">NO</button><button type=\"button\" class=\"dbtn go\" data-sw=\"yes\">SWAP</button></div>\n      </div></div>\n      <div class=\"dlg\" data-dlg=\"pilot\" hidden><div class=\"dlg-box wide tall pane\" data-r=\"16\">\n        <div class=\"dlg-head\"><div class=\"dlg-title\">PILOT</div><button type=\"button\" class=\"dbtn\" data-dlg-close>CLOSE</button></div>\n        <div class=\"pk-wrap\"><div class=\"pick-grid pilots scroll\" data-pick-pilot></div><canvas class=\"pk-gl\"></canvas></div>\n      </div></div>\n      <div class=\"dlg\" data-dlg=\"create\" hidden><form class=\"dlg-box pane\" data-r=\"16\" autocomplete=\"off\">\n        <div class=\"dlg-title\">CREATE LOBBY</div>\n        <label class=\"fld\"><span>LOBBY NAME</span><input name=\"lname\" type=\"text\" maxlength=\"24\" autocomplete=\"off\" spellcheck=\"false\"></label>\n        <div class=\"fld\"><span>MODE</span><div class=\"seg\">\n          <button type=\"button\" data-mode=\"adventure\">ADVENTURE<small>4 PILOTS · CO-OP</small></button>\n          <button type=\"button\" data-mode=\"arena\">ARENA<small>8 PILOTS · VERSUS</small></button>\n        </div></div>\n        <div class=\"dlg-err\"></div>\n        <div class=\"dlg-btns\"><button type=\"button\" class=\"dbtn\" data-dlg-close>CANCEL</button><button type=\"submit\" class=\"dbtn go\">CREATE</button></div>\n      </form></div>\n      <div class=\"dlg\" data-dlg=\"nick\" hidden><form class=\"dlg-box pane\" data-r=\"16\" autocomplete=\"off\">\n        <div class=\"dlg-title\">NICKNAME</div>\n        <label class=\"fld\"><span>YOUR NAME IN LOBBIES AND ON THE RADIO</span><input name=\"nick\" type=\"text\" maxlength=\"16\" autocomplete=\"nickname\" spellcheck=\"false\"></label>\n        <div class=\"dlg-err\"></div>\n        <div class=\"dlg-btns\"><button type=\"button\" class=\"dbtn\" data-dlg-close>CANCEL</button><button type=\"submit\" class=\"dbtn go\">SAVE</button></div>\n      </form></div>\n      <div class=\"dlg\" data-dlg=\"password\" hidden><form class=\"dlg-box pane\" data-r=\"16\" autocomplete=\"off\">\n        <div class=\"dlg-title\">CHANGE PASSWORD</div>\n        <label class=\"fld\"><span>CURRENT PASSWORD</span><input name=\"current\" type=\"password\" autocomplete=\"current-password\"></label>\n        <label class=\"fld\"><span>NEW PASSWORD</span><input name=\"next\" type=\"password\" autocomplete=\"new-password\"></label>\n        <label class=\"fld\"><span>CONFIRM NEW PASSWORD</span><input name=\"confirm\" type=\"password\" autocomplete=\"new-password\"></label>\n        <div class=\"dlg-err\"></div>\n        <div class=\"dlg-btns\"><button type=\"button\" class=\"dbtn\" data-dlg-close>CANCEL</button><button type=\"submit\" class=\"dbtn go\">CHANGE</button></div>\n      </form></div>\n      <div class=\"toast mono\" aria-live=\"polite\"></div>\n      <div class=\"sndpop\" hidden role=\"dialog\" aria-label=\"Sound\">\n        <div class=\"head\"><span>SOUND</span><button class=\"sw\" data-sw role=\"switch\" aria-label=\"Sound\"></button><button class=\"x\" data-pop-close aria-label=\"Close\">×</button></div>\n        <input type=\"range\" min=\"0\" max=\"200\" step=\"1\" value=\"100\" data-master aria-label=\"Master volume\">\n      </div>\n      <button class=\"corner snd\" data-act=\"sound\" aria-label=\"Sound\">\n        <svg class=\"on\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9h4l5-4v14l-5-4H4z\"/><path d=\"M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10\"/></svg>\n        <svg class=\"off\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9h4l5-4v14l-5-4H4z\"/><path d=\"M16 9l5 6M21 9l-5 6\"/></svg>\n      </button>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s)\n    const qa = (s) => Array.from(root.querySelectorAll(s))\n    const rt = window[RT_KEY] || (window[RT_KEY] = {})\n    const mm = $('.mm')\n    let alive = true\n    const undo = []\n    const on = (t, ev, fn, opt) => { t.addEventListener(ev, fn, opt); undo.push(() => t.removeEventListener(ev, fn, opt)) }\n    d2.onUnmount(() => { alive = false; undo.forEach((f) => { try { f() } catch (e) {} }) })\n\n    // ---- params, session, navigation (as TFSF_Menu_DEV) -------------------------------------\n    const filesBase = String(d2.param('filesBase', 'https://lucca-playground.data2apis.com')).replace(/\\/$/, '')\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const routerRef = String(d2.param('routerRef', '') || '')\n    const sessionKey = `__data2-session-${systemDomain}`\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(sessionKey)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    const nav = (to) => {\n      try {\n        const r = routerRef ? d2.router(routerRef) : d2.router()\n        if (r && typeof r.goTo === 'function') { r.goTo(to); return }\n      } catch (e) {}\n      try { history.pushState(null, '', to); window.dispatchEvent(new PopStateEvent('popstate', { state: null })) } catch (e) { location.href = to }\n    }\n    const signOut = async () => {\n      try { const auth = d2.auth(d2.param('auth')); if (auth && typeof auth.logout === 'function') await auth.logout() } catch (e) {}\n      try { localStorage.removeItem(sessionKey) } catch (e) {}\n      try { localStorage.removeItem(`${STORE}.me`) } catch (e) {}\n      nav('/login')\n    }\n    // Every launch starts on the title (TFSF_Menu_DEV): its tap grants sound, motion and the microphone,\n    // then it comes back here.\n    if (!rt.titleDone) { nav('/title'); return }\n\n    const pilot = readKey(`${STORE}.nick`) || (readKey(`${STORE}.me`).split('@')[0] || '').toUpperCase()\n    const pilotEl = $('[data-pilot]'); if (pilotEl) pilotEl.textContent = pilot || 'GUEST'\n\n    // ---- font + boot reveal -------------------------------------------------------------------\n    try {\n      // (fonts.check() is true when no MGS face exists at all, so look for the face itself)\n      if (!Array.from(document.fonts).some((f) => f.family.replace(/[\"']/g, '') === 'MGS')) {\n        const ff = new FontFace('MGS', `url(${ASSET(FONT_REF)})`)\n        document.fonts.add(ff)\n        ff.load().catch(() => {})\n      }\n    } catch (e) {}\n    requestAnimationFrame(() => requestAnimationFrame(() => mm.classList.remove('boot')))\n\n    // ---- page takeover (phones): edge to edge, no browser zoom, scroll, selection or callouts -----\n    // As TFSF_Menu_DEV does on its screens: the host is fixed over the whole viewport (so it follows\n    // a rotation), the page cannot scroll or bounce, and a drag or a long press does nothing.\n    const inBuilder = !/\\.data2\\.app$/.test(location.hostname)\n    if (!inBuilder) {\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n      setMeta('apple-mobile-web-app-capable', 'yes')\n      setMeta('mobile-web-app-capable', 'yes')\n      setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n      setMeta('theme-color', '#03080c')\n      const styleOf = (el, css) => {\n        const prev = el.getAttribute('style')\n        el.style.cssText += css\n        undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n      }\n      styleOf(document.documentElement, ';background:#03080c;height:100%;overflow:hidden;overscroll-behavior:none;')\n      styleOf(document.body, ';background:#03080c;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n      const host = root.host\n      if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#03080c;')\n    }\n    const onDoc = (type, fn) => on(document, type, fn, { passive: false })\n    for (const g of ['gesturestart', 'gesturechange', 'gestureend']) onDoc(g, (e) => e.preventDefault())\n    onDoc('touchmove', (e) => { // no drag-to-scroll, no rubber band (a slider keeps its drag)\n      const path = e.composedPath ? e.composedPath() : []\n      if (!path.some((n) => n && (n.tagName === 'INPUT' || (n.classList && n.classList.contains('scroll'))))) e.preventDefault()\n    })\n    on(root, 'touchstart', (e) => { if (e.touches.length > 1) e.preventDefault() }, { passive: false })\n    on(root, 'selectstart', (e) => e.preventDefault())\n    on(root, 'contextmenu', (e) => e.preventDefault())\n\n    // ---- layout: the two stages scale with the window; upright phones get the portrait layout ----\n    const stageL = $('.stage.left')\n    let portrait = false\n    let stageBox = { left: 0, top: 0, s: 1 } // the left stage on screen, for the planet in the background\n    const fit = () => {\n      const w = mm.clientWidth || innerWidth, h = mm.clientHeight || innerHeight\n      portrait = h > w * 1.05\n      mm.classList.toggle('portrait', portrait)\n      const s = portrait ? Math.min(w / 640, h * 0.56 / 670) : Math.min(w / W0, h / H0) // portrait: the menu keeps to the top half\n      mm.style.setProperty('--s', s.toFixed(4))\n      mm.style.setProperty('--ext', `${Math.max(0, w / s - W0).toFixed(1)}px`) // stage px past 1366\n      const r = stageL.getBoundingClientRect(), m = mm.getBoundingClientRect()\n      stageBox = { left: r.left - m.left, top: r.top - m.top, s }\n      // the title's middle (its line box is centred on the capitals), for the corner buttons\n      const tr = $('.title').getBoundingClientRect()\n      mm.style.setProperty('--ty', `${Math.max(32, (tr.top + tr.bottom) / 2 - m.top).toFixed(1)}px`)\n      return s\n    }\n    let scale = fit()\n    let shipKick = 0       // a little shake when the selection moves\n    // The engine glow, as the game sizes it (Player unk_194 easing to unk_190): 1 cruising, 4 at a\n    // boost's kick, 2 while boosting. A pick boosts; moving the selection gives it a small kick.\n    let glow = 1, glowTarget = 1, glowHold = 0\n    let boost = 0          // 0..1, for the readouts\n\n    // ---- screens: one column of slabs each; keys, pad and pointer drive the one on screen ----------\n    let cur = ''                // 'home' | 'profile' | 'settings'\n    let enterAt = -10           // when the current screen came in (the glass follows its slabs' fade)\n    const cols = {}             // screen -> its buttons\n    for (const key of Object.keys(SCREENS)) cols[key] = qa(`.menuwrap[data-scr=\"${key}\"] [data-item]`)\n    const defOf = (key, i) => SCREENS[key].items[i]\n    // Nothing is selected until the pilot points at an item or uses the keys/pad (then the first one).\n    let sel = -1\n    const list = () => cols[cur] || []\n    const select = (i) => {\n      const L = list(); if (!L.length) return\n      const next = (i + L.length) % L.length\n      if (next === sel) return\n      sel = next\n      L.forEach((b, k) => b.classList.toggle('on', k === sel))\n      shipKick = 1\n      glowTarget = Math.max(glowTarget, 1.6); glowHold = 0.3\n    }\n    const deselect = () => { sel = -1; list().forEach((b) => b.classList.remove('on')) }\n    const step = (d) => {\n      const L = list(); if (!L.length) return\n      let i = sel < 0 ? (d > 0 ? -1 : 0) : sel\n      for (let k = 0; k < L.length; k++) { i = (i + d + L.length) % L.length; if (L[i].offsetParent) break }\n      select(i)\n    }\n    const activate = (i) => {\n      if (i < 0) return select(0)\n      const it = defOf(cur, i), b = list()[i]\n      if (!it || !b) return\n      b.classList.remove('go'); void b.offsetWidth; b.classList.add('go')\n      if (it.act === 'nick') return openDlg('nick')\n      if (it.act === 'password') return openDlg('password')\n      if (it.act === 'create') return openDlg('create')\n      if (it.act === 'ready') return toggleReady()\n      if (it.act === 'pilot') return room.mine ? openDlg('pilot') : null\n      if (it.act === 'mission' && isArena()) return openBattle('stage')\n      if (it.act === 'mission') return canPickMission() ? openDlg('mission') : toast('THE HOST PICKS THE MISSION')\n      if (it.act === 'controls') return openDlg('controls')\n      if (it.act === 'leave') { glow = 4; glowTarget = 2; glowHold = 9; return go('mp', true) }\n      glow = 4; glowTarget = 2; glowHold = 9 // the boost: kick, then held until the screen changes\n      const own = it.to && SCREENS[screenOf(it.to)].path === it.to // one of this component's screens\n      if (own) return go(screenOf(it.to), true)\n      // anywhere else: the ship flies off and the screen fades, then the app moves on\n      leave(() => {\n        if (it.to) nav(it.to)\n        else if (it.act === 'disconnect') signOut()\n        else if (it.act === 'starfox64') location.href = `https://${location.hostname.replace(/^[^.]+/, 'datafox2')}/`\n      })\n    }\n    let tapAt = -1e9 // the last touch pick (its click, which follows, is not a second one)\n    for (const key of Object.keys(cols)) {\n      cols[key].forEach((b, i) => {\n        // mouse: hover selects, a click picks. Touch: the tap picks at once, on the finger's lift (iOS\n        // treats a first tap whose hover changes the page as a hover only and never sends its click)\n        on(b, 'pointerenter', (e) => { if (cur === key && e.pointerType === 'mouse') select(i) })\n        on(b, 'pointerleave', (e) => { if (cur === key && e.pointerType === 'mouse' && sel === i) deselect() })\n        on(b, 'pointerdown', (e) => { if (cur === key && e.pointerType !== 'mouse') select(i) })\n        on(b, 'pointerup', (e) => {\n          if (cur !== key || e.pointerType === 'mouse') return\n          tapAt = performance.now(); activate(i)\n        })\n        on(b, 'focus', () => { if (cur === key && performance.now() - tapAt > 800) select(i) })\n        on(b, 'click', () => { if (cur !== key || performance.now() - tapAt < 800) return; select(i); activate(i) })\n      })\n    }\n    const back = () => { if (cur === 'room') return go('mp', true); if (cur !== 'home') go('home', true) }\n    on(window, 'keydown', (e) => {\n      if (!$('.sndpop').hidden || dlgOpen()) return\n      if (e.target && /^(INPUT|TEXTAREA)$/.test(e.target.tagName || '')) return\n      if (e.key === 'ArrowDown' || e.key === 's') { step(1); e.preventDefault() }\n      else if (e.key === 'ArrowUp' || e.key === 'w') { step(-1); e.preventDefault() }\n      else if (e.key === 'Enter' || e.key === ' ') { activate(sel); e.preventDefault() }\n      else if (e.key === 'Escape' || e.key === 'Backspace') { back(); e.preventDefault() }\n    })\n    let padPrev = 0\n    const pollPad = () => {\n      const pads = navigator.getGamepads ? navigator.getGamepads() : []\n      const p = pads && Array.from(pads).find(Boolean)\n      if (!p) return\n      const up = p.buttons[12]?.pressed || p.axes[1] < -0.6, down = p.buttons[13]?.pressed || p.axes[1] > 0.6\n      const a = p.buttons[0]?.pressed || p.buttons[9]?.pressed, bk = p.buttons[1]?.pressed\n      const bits = (up ? 1 : 0) | (down ? 2 : 0) | (a ? 4 : 0) | (bk ? 8 : 0)\n      const press = bits & ~padPrev\n      padPrev = bits\n      if (dlgOpen()) { if (press & 8) closeDlg(); return }\n      if (press & 1) step(-1)\n      if (press & 2) step(1)\n      if (press & 4) activate(sel)\n      if (press & 8) back()\n    }\n\n    // ---- moving between screens ----------------------------------------------------------------\n    // out: the ship flies off along its nose and the screen fades (OUT_S); then the next screen is put\n    // up and fades in while its ship flies in to its place (IN_S). Between this component's own screens\n    // the address changes with history.pushState only: the Router is not involved (nothing remounts).\n    const OUT_S = 0.5, IN_S = 0.9\n    const smooth = (a, b, x) => { const k = Math.max(0, Math.min(1, (x - a) / (b - a))); return k * k * (3 - 2 * k) }\n    let tr = null               // { phase: 'out' | 'in', t0, to, push, then }\n    let shipScreen = ''         // the screen whose pose the ship holds (changes when the screen is swapped)\n    const nowS = () => (performance.now() - t0) / 1000\n    const go = (key, push) => {\n      if (tr && tr.phase === 'out') { tr.to = key; tr.push = push; return }\n      if (key === cur && !tr) return\n      tr = { phase: 'out', t0: nowS(), to: key, push }\n      mm.classList.add('leaving')\n      deselect()\n    }\n    const leave = (then) => { if (tr && tr.phase === 'out') { tr.then = then; return } tr = { phase: 'out', t0: nowS(), then }; mm.classList.add('leaving'); deselect() }\n    const stepTransition = (t) => {\n      if (tr.phase === 'out') {\n        const { to, push, then } = tr\n        if (then) { tr = null; if (alive) then(); return }\n        if (push) { const path = pathOf(to); if (location.pathname !== path) { history.pushState(null, '', path); lastPath = location.pathname } }\n        show(to)\n        mm.classList.remove('leaving')\n        tr = { phase: 'in', t0: t }\n        glow = 3; glowTarget = 1.6; glowHold = 0.5\n      } else tr = null\n    }\n    // the screen the URL asks for (the Router keeps this artboard mounted across these routes)\n    const show = (key) => {\n      if (key === cur) return\n      const first = !cur\n      deselect(); closeDlg()\n      if (cur === 'room') exitRoom() // leaving the room's screen leaves the lobby (not when departing)\n      cur = key\n      shipScreen = key\n      qa('.scr').forEach((el) => el.classList.toggle('on', el.dataset.scr.split(' ').includes(key)))\n      mm.classList.toggle('sub', key !== 'home')\n      $('[data-title]').textContent = SCREENS[key].title\n      glowTarget = 1; glowHold = 0\n      if (!first) {\n        // the column comes in as at boot: hidden, then each slab slides in (staggered)\n        const wrap = $(`.menuwrap[data-scr=\"${key}\"]`)\n        wrap.classList.add('enter'); void wrap.offsetWidth\n        requestAnimationFrame(() => wrap.classList.remove('enter'))\n      }\n      enterAt = (performance.now() - t0) / 1000\n      if (key === 'solo') soloReady = false\n      hg.slots = []; hg.at = -10; setAlert('')\n      if (key !== 'solo' && key !== 'room') $('[data-title]').classList.remove('long')\n      // the screen's own content after this frame (this one swaps the screens and starts the slabs in;\n      // the new screen is still transparent, so a frame later is unseen)\n      if (!first) setTimeout(() => { if (cur === key) fillScreen(key) }, 0)\n      else fillScreen(key)\n      if (key !== 'mp') stopMp()\n    }\n    const fillScreen = (key) => {\n      if (key === 'profile') paintProfile()\n      if (key === 'settings') paintSettings()\n      if (key === 'mp') startMp()\n      if (key === 'room') enterRoomFromPath()\n      if (key === 'solo' || key === 'room') { paintHangar(); requestAnimationFrame(() => { measureHangar(); paintHangar() }) }\n    }\n    const route = () => { const key = screenOf(location.pathname); if (!cur) show(key); else if (key !== cur || tr) go(key, false) }\n    let lastPath = location.pathname\n    const pathWatch = setInterval(() => { if (location.pathname !== lastPath) { lastPath = location.pathname; route() } }, 200)\n    undo.push(() => clearInterval(pathWatch))\n    on(window, 'popstate', () => setTimeout(() => { lastPath = location.pathname; route() }, 0))\n\n    // ---- toast ----------------------------------------------------------------------------------\n    let toastTimer = 0\n    const toast = (msg) => {\n      const t = $('.toast'); t.textContent = msg; t.classList.add('show')\n      clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 1800)\n    }\n\n    // ---- dialogs: nickname, password ------------------------------------------------------------\n    const dlgs = { nick: $('[data-dlg=\"nick\"]'), password: $('[data-dlg=\"password\"]'), create: $('[data-dlg=\"create\"]'),\n      mission: $('[data-dlg=\"mission\"]'), pilot: $('[data-dlg=\"pilot\"]'), controls: $('[data-dlg=\"controls\"]'),\n      battle: $('[data-dlg=\"battle\"]'), swap: $('[data-dlg=\"swap\"]') }\n    // a dialog fades in over the dimmed menu and out again (hidden once its fade is over)\n    const dlgOpen = () => Object.values(dlgs).some((d) => d.classList.contains('show'))\n    const closeDlg = () => {\n      for (const d of Object.values(dlgs)) {\n        if (!d.classList.contains('show')) continue\n        d.classList.remove('show')\n        if (d.contains(root.activeElement)) root.activeElement.blur()\n        clearTimeout(d._hide); d._hide = setTimeout(() => { d.hidden = true; const f = d.querySelector('form'); if (f) f.reset(); const er = d.querySelector('.dlg-err'); if (er) er.textContent = '' }, 260)\n      }\n      mm.classList.remove('dlgon')\n    }\n    const openDlg = (k) => {\n      closeDlg()\n      const d = dlgs[k]; clearTimeout(d._hide); d.hidden = false; mm.classList.add('dlgon')\n      void d.offsetWidth; d.classList.add('show')\n      if (k === 'nick') d.querySelector('input').value = readKey(NICK_KEY)\n      if (k === 'create') { d.querySelector('input').value = `${myName()}'s lobby`; pickMode('adventure') }\n      if (k === 'mission') fillMission()\n      if (k === 'pilot') fillPilot()\n      if (k === 'controls') fillControls(controlsId())\n      if (k === 'battle') fillBattle()\n      setTimeout(() => { try { d.querySelector('input').focus() } catch (e) {} }, 60)\n    }\n    for (const d of Object.values(dlgs)) {\n      on(d, 'click', (e) => { if (performance.now() - tapAt < 800) return // the click that follows the tap that opened it\n        if (e.target === d || (e.target.closest && e.target.closest('[data-dlg-close]'))) closeDlg() })\n      on(d, 'keydown', (e) => { if (e.key === 'Escape') { closeDlg(); e.stopPropagation() } })\n    }\n    // (the pickers hold no field to keep the focus: Escape closes any dialog from anywhere)\n    on(window, 'keydown', (e) => { if (e.key === 'Escape' && dlgOpen()) closeDlg() })\n    const NICK_KEY = `${STORE}.nick`\n    on(dlgs.nick.querySelector('form'), 'submit', (e) => {\n      e.preventDefault()\n      const nick = String(dlgs.nick.querySelector('input').value || '').replace(/\\s+/g, ' ').trim().slice(0, 16)\n      try { if (nick) localStorage.setItem(NICK_KEY, nick); else localStorage.removeItem(NICK_KEY) } catch (err) {}\n      closeDlg()\n      paintPilot(); paintProfile()\n      toast(nick ? `NICKNAME: ${nick.toUpperCase()}` : 'NICKNAME CLEARED')\n    })\n    // The AuthenticationStrategy's own changePassword (as TFSF_Menu_DEV): it reads the current password\n    // from cursor.current.previous, the new one from .credentials and its confirmation from .registration;\n    // the server checks the current one before saving. It resolves to an Error instead of throwing.\n    const pwForm = dlgs.password.querySelector('form')\n    on(pwForm, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(pwForm)\n      const current = String(fd.get('current') || ''), next = String(fd.get('next') || ''), confirm = String(fd.get('confirm') || '')\n      const err = pwForm.querySelector('.dlg-err'), btn = pwForm.querySelector('.go')\n      err.textContent = !current ? 'Enter your current password.' : !next ? 'Enter a new password.'\n        : next !== confirm ? 'The new passwords do not match.' : next === current ? 'The new password is the same as the current one.' : ''\n      if (err.textContent) return\n      btn.disabled = true\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        const raw = auth && auth.raw\n        if (!raw || typeof raw.changePassword !== 'function') throw new Error('Login is not configured')\n        const key = (raw.passwordField && raw.passwordField.key) || 'password'\n        const c = raw.cursor.current\n        c.previous = { [key]: current }\n        c.credentials = { ...(c.credentials || {}), [key]: next }\n        c.registration = { ...(c.registration || {}), [key]: confirm }\n        const res = await raw.changePassword()\n        if (res instanceof Error || (res && res.name === 'Data2Error')) throw res\n        closeDlg()\n        toast('PASSWORD CHANGED')\n      } catch (ex) {\n        try { const c = d2.auth(d2.param('auth')).raw.cursor.current; c.previous = {}; c.credentials = {}; c.registration = {} } catch (e2) {}\n        const m = String((ex && ex.message) || '')\n        err.textContent = /could not change/i.test(m) ? 'Could not change the password. Check your current password.'\n          : /no authenticated/i.test(m) ? 'You are signed out. Sign in again first.' : 'Could not change the password. Try again.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    // ---- the pilot's record (TFSF Runs) ------------------------------------------------------------\n    // TFSF_Player_DEV queues every report that ends something (`${STORE}.runQueue`); written here, to the\n    // TFSF Runs DataSet, whenever this component mounts. The profile adds up the pilot's rows. The\n    // DataSet arrives through TFSF_Runs_DEV, a small block beside this one that holds it as its `runs`\n    // parameter and lends it on the runtime (rt.runsApi: create, query).\n    const RUNQ_KEY = `${STORE}.runQueue`\n    const readQueue = () => { try { return JSON.parse(readKey(RUNQ_KEY) || '[]') || [] } catch (e) { return [] } }\n    const runsApi = async () => {\n      for (let i = 0; i < 40 && !rt.runsApi && alive; i++) await new Promise((r) => setTimeout(r, 100))\n      return rt.runsApi || null\n    }\n    const rowsOf = (r) => (Array.isArray(r) ? r : (r && r.data) || [])\n    let flushing = null\n    const flushRuns = () => flushing || (flushing = (async () => {\n      const ds = await runsApi()\n      if (!ds) return\n      for (;;) {\n        const q = readQueue()\n        if (!q.length) break\n        try { await ds.create(q[0]) } catch (e) { break } // offline / signed out: try again next time\n        const rest = readQueue(); rest.shift()\n        try { localStorage.setItem(RUNQ_KEY, JSON.stringify(rest)) } catch (e) { break }\n      }\n    })().finally(() => { flushing = null }))\n    let runs = null // the pilot's rows, once loaded\n    const loadRuns = async () => {\n      const me = readKey(`${STORE}.me`).trim().toLowerCase()\n      if (!me) { runs = []; return }\n      try { await flushRuns() } catch (e) {}\n      const ds = await runsApi()\n      let rows = []\n      try { if (ds) rows = rowsOf(await ds.query({ email: me, $limit: 1000, $sort: { createdAt: -1 } })) } catch (e) {}\n      // anything still queued (not written yet) counts too\n      runs = rows.concat(readQueue().filter((r) => r.email === me))\n      if (cur === 'profile') paintProfile()\n    }\n    const when = (r) => Number(r.at) || Date.parse(r.createdAt || '') || 0\n    const dateOf = (ms) => { if (!ms) return '—'; const d = new Date(ms); return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}` }\n    const statsOf = (all) => {\n      const st = { runs: 0, cleared: 0, enemies: 0, best: 0, bestAt: '', medals: 0, livesLost: 0, gameOvers: 0, endings: 0, coop: 0, arena: 0, wins: 0, kills: 0 }\n      const ids = new Set()\n      const byRun = new Map()\n      for (const r of all) {\n        if (r.mode !== 'arena' && r.runId) ids.add(r.runId)\n        if (r.event === 'level-end') {\n          st.cleared++\n          const h = Number(r.hits) || 0\n          st.enemies += h\n          if (h > st.best) { st.best = h; st.bestAt = levelName(r.level) }\n          if (Number(r.medal) > 0) st.medals++\n          if (r.mode === 'coop') st.coop++\n        } else if (r.event === 'game-over') st.gameOvers++\n        else if (r.event === 'ending') st.endings++\n        else if (r.event === 'arena-end') { st.arena++; if (r.won === true) st.wins++; st.kills += Number(r.kills) || 0 }\n        if (r.runId && Number.isFinite(Number(r.lives)) && r.mode !== 'arena') { if (!byRun.has(r.runId)) byRun.set(r.runId, []); byRun.get(r.runId).push(r) }\n      }\n      st.runs = ids.size\n      // lives lost: the drops in \"lives left\" from one report of a run to the next (a 1-up can hide one)\n      for (const list of byRun.values()) {\n        list.sort((a, b) => when(a) - when(b))\n        for (let i = 1; i < list.length; i++) st.livesLost += Math.max(0, Number(list[i - 1].lives) - Number(list[i].lives))\n      }\n      return st\n    }\n    const tile = (k, v, extra, cls, i) => `<div class=\"tile ${cls || ''}\" style=\"--i:${i}\"><div class=\"k\">${esc(k)}</div><div class=\"v\">${esc(v)}${extra ? `<small>${esc(extra)}</small>` : ''}</div></div>`\n    const paintPilot = () => {\n      const pilot = readKey(NICK_KEY) || (readKey(`${STORE}.me`).split('@')[0] || '').toUpperCase()\n      const el = $('[data-pilot]'); if (el) el.textContent = pilot || 'GUEST'\n      return pilot\n    }\n    const paintProfile = () => {\n      const me = readKey(`${STORE}.me`).trim().toLowerCase()\n      $('[data-pf-call]').textContent = (readKey(NICK_KEY) || me.split('@')[0] || 'GUEST').toUpperCase()\n      $('[data-pf-mail]').textContent = me ? me.toUpperCase() : 'NOT SIGNED IN'\n      if (!runs) { $('[data-pf-grid]').innerHTML = ''; $('[data-pf-recent]').innerHTML = '<div class=\"none\">LOADING…</div>'; return }\n      const st = statsOf(runs)\n      const times = runs.map(when).filter(Boolean).sort((a, b) => a - b)\n      $('[data-pf-first]').textContent = dateOf(times[0])\n      $('[data-pf-last]').textContent = dateOf(times[times.length - 1])\n      $('[data-pf-grid]').innerHTML = [\n        tile('RUNS', st.runs), tile('LEVELS CLEARED', st.cleared), tile('ENEMIES DOWN', st.enemies), tile('BEST LEVEL', st.best, st.best ? st.bestAt.toUpperCase() : ''),\n        tile('MEDALS', st.medals), tile('LIVES LOST', st.livesLost), tile('GAME OVERS', st.gameOvers), tile('ENDINGS', st.endings),\n        tile('CO-OP LEVELS', st.coop), tile('ARENA MATCHES', st.arena, '', 'arena'), tile('ARENA WINS', st.wins, st.arena ? `${Math.round(st.wins / st.arena * 100)}%` : '', 'arena'), tile('ARENA KILLS', st.kills, '', 'arena'),\n      ].map((t, i) => t.replace('--i:undefined', `--i:${i}`)).join('')\n      const recent = runs.slice().sort((a, b) => when(b) - when(a)).slice(0, portrait ? 6 : 3)\n      $('[data-pf-recent]').innerHTML = recent.length ? recent.map((r) => {\n        const mode = (r.mode || 'solo').toUpperCase()\n        let what = levelName(r.level).toUpperCase(), res = '', cls = ''\n        if (r.event === 'level-end') { res = `CLEARED · ${Number(r.hits) || 0} HITS${Number(r.medal) > 0 ? ' · MEDAL' : ''}`; cls = 'good' }\n        else if (r.event === 'game-over') { res = 'GAME OVER'; cls = 'bad' }\n        else if (r.event === 'ending') { what = 'VENOM'; res = 'THE END'; cls = 'good' }\n        else if (r.event === 'arena-end') { what = 'ARENA'; res = `${r.won === true ? 'WON' : r.won === false ? 'LOST' : 'DRAW'} · ${Number(r.kills) || 0} KILLS`; cls = r.won ? 'good' : r.won === false ? 'bad' : '' }\n        return `<div class=\"r ${cls}\"><span class=\"d\">${dateOf(when(r))}</span><span class=\"m\">${esc(mode)}</span><span>${esc(what)}</span><span class=\"x\">${esc(res)}</span></div>`\n      }).join('') : '<div class=\"none\">NO FLIGHTS RECORDED YET · YOUR NEXT FLIGHT STARTS THE COUNT</div>'\n    }\n\n    // ---- /multiplayer: the open lobbies, and a new one ---------------------------------------------\n    // The tables are TFSF_Menu_DEV's (lobbies, lobbyPlayers), lent by the bridge block as rt.lobbyApi.\n    // A lobby counts as open while one of its pilots has a fresh heartbeat (the server's clock, as the\n    // lobby screen measures it); the lobby itself (/multiplayer/lobbies/:_id) is still TFSF_Menu_DEV's.\n    const LOBBY_LIVE_MS = 60000\n    const MODES = { adventure: { name: 'ADVENTURE', max: 4 }, arena: { name: 'ARENA', max: 8 } }\n    const VS_STAGES = ['Corneria', 'Katina', 'Sector Z']\n    const VS_MATCHES = ['First to 3', 'First to 5', 'First to 10']\n    const DEFAULT_CAMPAIGN = \"Andross' Attack\"\n    const listOf = (r) => (Array.isArray(r) ? r : (r && r.data) || [])\n    const myEmail = () => {\n      let email = readKey(`${STORE}.me`)\n      if (!email) { try { const u = d2.auth(d2.param('auth'))?.user; email = (u && (u.email || (u.credentials && u.credentials.email))) || '' } catch (e) {} }\n      if (!email) {\n        try {\n          const part = readKey(sessionKey).split('.')[1]\n          const claims = part ? JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) : {}\n          email = claims.email || (claims.credentials && claims.credentials.email) || ''\n        } catch (e) {}\n      }\n      if (!email) { // as TFSF_Menu_DEV: a stable guest id for this browser\n        email = readKey(`${STORE}.guest`) || `guest-${Math.random().toString(36).slice(2, 8)}`\n        try { localStorage.setItem(`${STORE}.guest`, email) } catch (e) {}\n      }\n      return email\n    }\n    const me = myEmail\n    const myName = () => readKey(NICK_KEY).trim() || myEmail().split('@')[0] || 'Pilot'\n    const serverNow = () => Date.now() + (Number(readKey(`${STORE}.clockSkew`)) || 0)\n    const stampOf = (p) => Date.parse((p && (p.updatedAt || p.createdAt)) || '') || serverNow()\n    const newCode = () => Array.from({ length: 4 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ'[Math.floor(Math.random() * 23)]).join('')\n    const mpList = $('[data-mp-list]'), mpCount = $('[data-mp-count]')\n    const mp = { timer: 0, html: '', busy: false, mode: 'adventure' }\n    const mpSay = (msg) => { const html = `<div class=\"none\">${msg}</div>`; if (mp.html !== html) { mp.html = html; mpList.innerHTML = html }; mpCount.textContent = '' }\n    const paintMp = async () => {\n      const api = rt.lobbyApi\n      if (!api) { mpSay('LOBBY LINK OFFLINE · RETRYING…'); return }\n      try {\n        const lobbies = listOf(await api.lobbies.query({ status: 'open', $sort: { createdAt: -1 }, $limit: 40 }))\n        const ids = lobbies.map((l) => String(l._id))\n        const now = serverNow()\n        const pilots = ids.length ? listOf(await api.players.query({ lobby: { $in: ids }, $limit: 200 })) : []\n        const count = {}\n        pilots.filter((p) => stampOf(p) > now - LOBBY_LIVE_MS).forEach((p) => { count[p.lobby] = (count[p.lobby] || 0) + 1 })\n        const open = lobbies.filter((l) => count[String(l._id)])\n        if (cur !== 'mp') return\n        if (!open.length) { mpSay('NO OPEN LOBBIES · CREATE ONE AND INVITE YOUR WINGMEN'); return }\n        mpCount.textContent = String(open.length).padStart(2, '0')\n        const html = open.map((l, i) => {\n          const id = String(l._id), n = count[id], max = Number(l.maxPlayers) || (MODES[l.mode] || MODES.adventure).max\n          const mode = l.mode === 'arena' ? 'arena' : 'adventure', full = n >= max\n          const where = mode === 'arena' ? `${VS_STAGES[l.vsStage] || VS_STAGES[0]} · ${VS_MATCHES[l.vsMatch] || VS_MATCHES[0]}` : l.campaign || DEFAULT_CAMPAIGN\n          const pips = Array.from({ length: max }, (_, k) => `<i class=\"${k < n ? 'on' : ''}\"></i>`).join('')\n          // its crew, from the snapshot the host keeps on the lobby row\n          const crew = (Array.isArray(l.players) && l.players.length ? l.players : [{ name: l.hostName || String(l.host || '').split('@')[0], pilot: 'fox' }])\n            .slice(0, max).map((c) => `<span class=\"${c.ready ? 'rdy' : ''}\">${esc(pilotName(c.pilot).toUpperCase())} · ${esc(String(c.name || 'PILOT').toUpperCase())}</span>`).join('')\n          return `<button class=\"lrow ${mode} ${full ? 'full' : ''}\" data-join=\"${esc(id)}\" ${full ? 'disabled' : ''} style=\"--i:${i}\">\n            <span class=\"lmain\"><span class=\"ltop\"><b class=\"lmode\">${MODES[mode].name}</b><span>${esc(l.code || '')}</span><span>${esc(String(where).toUpperCase())}</span></span>\n              <span class=\"lname\">${esc(l.name || `${l.hostName || String(l.host || '').split('@')[0] || 'Pilot'}'s lobby`)}</span>\n              <span class=\"lcrew\">${crew}</span></span>\n            <span class=\"lseats\"><span class=\"lcount\">${n}<small>/${max}</small></span><span class=\"lpips\">${pips}</span></span>\n            <span class=\"ljoin\">${full ? 'FULL' : 'JOIN ▶'}</span>\n          </button>`\n        }).join('')\n        if (html !== mp.html) { mp.html = html; mpList.innerHTML = html }\n      } catch (e) {\n        if (cur === 'mp') mpSay('COULD NOT REACH THE LOBBIES · RETRYING…')\n      }\n    }\n    const startMp = () => {\n      mp.html = ''; mpCount.textContent = ''\n      mpList.innerHTML = '<div class=\"none\">SCANNING FOR LOBBIES…</div>'\n      mpList.classList.add('fresh'); setTimeout(() => mpList.classList.remove('fresh'), 1600) // the rows slide in once, not on every refresh\n      clearInterval(mp.timer); mp.timer = setInterval(paintMp, 3000)\n      paintMp(); setTimeout(() => { if (cur === 'mp' && !mp.html.includes('lrow')) paintMp() }, 700) // the bridge may mount after us\n    }\n    const stopMp = () => clearInterval(mp.timer)\n    undo.push(stopMp)\n    const joinLobby = (id) => { glow = 4; glowTarget = 2; glowHold = 9; room.nextId = id; go('room', true) }\n    on(mpList, 'click', (e) => { const b = e.target.closest && e.target.closest('[data-join]'); if (b && !b.disabled && cur === 'mp' && !tr) joinLobby(b.dataset.join) })\n    const pickMode = (m) => { mp.mode = MODES[m] ? m : 'adventure'; dlgs.create.querySelectorAll('[data-mode]').forEach((b) => b.classList.toggle('on', b.dataset.mode === mp.mode)) }\n    on(dlgs.create, 'click', (e) => { const b = e.target.closest && e.target.closest('[data-mode]'); if (b) pickMode(b.dataset.mode) })\n    on(dlgs.create.querySelector('form'), 'submit', async (e) => {\n      e.preventDefault()\n      const form = dlgs.create.querySelector('form'), err = form.querySelector('.dlg-err'), btn = form.querySelector('.go')\n      const api = rt.lobbyApi\n      if (!api) { err.textContent = 'LOBBY LINK OFFLINE · TRY AGAIN IN A MOMENT'; return }\n      if (mp.busy) return\n      mp.busy = true; btn.disabled = true; err.textContent = ''\n      try {\n        const email = myEmail(), pilot = myName(), now = Date.now(), mode = mp.mode\n        const name = String(form.querySelector('input').value || '').replace(/\\s+/g, ' ').trim().slice(0, 24) || `${pilot}'s lobby`\n        const lobby = await api.lobbies.create({\n          code: newCode(), host: email, hostName: pilot, name, mode, maxPlayers: MODES[mode].max,\n          campaign: mode === 'arena' ? VS_STAGES[0] : DEFAULT_CAMPAIGN, vsStage: 0, vsMatch: 0,\n          status: 'open', players: [{ email, name: pilot }],\n        })\n        if (!lobby || !lobby._id) throw new Error('no lobby id')\n        await api.players.create({ lobby: String(lobby._id), email, name: pilot, pilot: 'fox', ready: false, seen: now, joinedAt: now,\n          ...(mode === 'arena' ? { team: 'blue', seat: 0 } : {}) })\n        closeDlg()\n        joinLobby(String(lobby._id))\n      } catch (e2) {\n        err.textContent = 'COULD NOT CREATE THE LOBBY'\n        console.warn('[mainmenu] create lobby', e2)\n      } finally {\n        mp.busy = false; btn.disabled = false\n      }\n    })\n\n    // ---- /solo and /multiplayer/lobbies/:_id: the hangar ------------------------------------------------\n    // One layout for both: four slots across, a ship over a hex platform each (the scene draws them; the\n    // platform's front edges are the top of the slot's card), the column for this pilot's setup, and,\n    // once everyone is ready, the alert: DEPART for the host, AWAITING DEPART for the others.\n    // Solo: the player (Fox) and three CPU wingmen, always ready. A lobby: its pilots, run as\n    // TFSF_Menu_DEV ran them (the room logic below is its own, moved here: rows, heartbeat, host\n    // hand-over, pilot clashes, the WebRTC links). Arena lobbies are still TFSF_Menu_DEV's screen, on\n    // /multiplayer/arena/:_id.\n    const SKEW_KEY = `${STORE}.clockSkew`\n    const learnSkew = (rec, sentAt) => {\n      const at = Date.parse((rec && rec.updatedAt) || '')\n      if (!at) return\n      try { localStorage.setItem(SKEW_KEY, String(Math.round(at - (sentAt + Date.now()) / 2))) } catch (e) {}\n    }\n    const live = (p, now = serverNow()) => stampOf(p) > now - LIVE_MS\n    const joinedOf = (p) => Date.parse((p && p.createdAt) || '') || Number.MAX_SAFE_INTEGER\n    const byJoin = (a, b) => joinedOf(a) - joinedOf(b) || String(a._id).localeCompare(String(b._id))\n    const nameOf = (email) => String(email || '').split('@')[0] || 'Pilot'\n    const lobbiesDs = () => rt.lobbyApi.lobbies\n    const playersDs = () => rt.lobbyApi.players\n    const signalsDs = () => rt.lobbyApi.signals\n    const find = async (ds, q) => listOf(await ds.query(q))\n    const patch = (ds, rec) => ds.patch(rec)\n    // Heartbeat: touch our own row; the server stamps it (updatedAt), which is what the others read.\n    const touch = async (mine, fields = {}) => {\n      const sentAt = Date.now()\n      const rec = await patch(playersDs(), { _id: mine._id, seen: sentAt, ...fields })\n      learnSkew(rec, sentAt)\n      return rec\n    }\n    // The lobby this browser is seated in, so reopening the app brings the pilot back to it.\n    const ROOM_KEY = `${STORE}.room`\n    const seat = (id) => { try { if (id) localStorage.setItem(ROOM_KEY, id); else localStorage.removeItem(ROOM_KEY) } catch (e) {} }\n    const waitApi = async () => { for (let i = 0; i < 60 && !rt.lobbyApi; i++) await new Promise((r) => setTimeout(r, 100)); return rt.lobbyApi }\n    const CONTROLS_KEY = `${STORE}.controls`\n    const controlsId = () => (CONTROLS.some((c) => c.id === readKey(CONTROLS_KEY)) ? readKey(CONTROLS_KEY) : 'motion')\n    const controlsLabel = () => CONTROLS.find((c) => c.id === controlsId()).label\n    const STAGE_KEY = `${STORE}.soloStage`\n    const soloStage = () => { const v = Number(readKey(STAGE_KEY)); return STAGES.some((st) => st.id === v) && readKey(STAGE_KEY) !== '' ? v : -1 }\n\n    // ---- the tap into the game: sound, motion, fullscreen (inside the gesture), then /play ----------\n    const micLive = () => !!(rt.micPipe && rt.micPipe.stream.getAudioTracks().some((t) => t.readyState === 'live'))\n    const unlockAudio = () => {\n      try { if (navigator.audioSession) navigator.audioSession.type = micLive() ? 'play-and-record' : 'playback' } catch (e) {}\n      try {\n        const ctx = (rt.engine && rt.engine.SDL2 && rt.engine.SDL2.audioContext) || rt.audio\n        if (ctx && ctx.state !== 'closed') { ctx.resume(); return }\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx) return\n        let ac\n        try { ac = new Ctx({ sampleRate: 32000 }) } catch (e) { ac = new Ctx() } // the game mixes at 32 kHz\n        ac.resume()\n        const src = ac.createBufferSource()\n        src.buffer = ac.createBuffer(1, 1, 22050)\n        src.connect(ac.destination)\n        src.start(0)\n        rt.audio = ac\n      } catch (e) {}\n    }\n    const askMotion = () => {\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') { try { Ev.requestPermission().catch(() => {}) } catch (e) {} }\n      }\n    }\n    const goFullscreen = () => {\n      const el = document.documentElement\n      try { if (el.requestFullscreen && !document.fullscreenElement) el.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}) } catch (e) {}\n      try { screen.orientation?.lock?.('landscape')?.catch?.(() => {}) } catch (e) {}\n      try { navigator.wakeLock?.request?.('screen')?.catch?.(() => {}) } catch (e) {}\n    }\n    // The gesture's part now; the ship flies off, then /play.\n    const play = (query, tapped = true) => {\n      unlockAudio(); askMotion(); goFullscreen()\n      // A multiplayer run (rt.mp, set at the lobby's departure) stays multiplayer for every later stage.\n      if (rt.mp && !/(^|&)net=1/.test(query)) query += '&net=1'\n      rt.tappedAt = tapped ? Date.now() : 0 // a tap here: the player need not ask for one\n      glow = 4; glowTarget = 2; glowHold = 9\n      leave(() => nav(`/play?${query}`))\n    }\n\n    // ---- solo ---------------------------------------------------------------------------------------\n    // Ready lives only while /solo is open: every visit starts with the player not ready.\n    let soloReady = false\n\n    // ---- the lobby room -------------------------------------------------------------------------------\n    const room = { id: '', nextId: '', lobby: null, pilots: [], mine: null, timer: 0, sigTimer: 0, paintTimer: 0, joining: false, gone: '', departing: false, back: null }\n    const pathOf = (key) => (key === 'room' ? `/multiplayer/lobbies/${room.nextId || room.id}` : SCREENS[key].path)\n    const amHost = () => !!(room.lobby && room.lobby.host === me())\n    const livePilots = () => { const now = serverNow(); return room.pilots.filter((p) => live(p, now)).sort(byJoin) }\n    const seatOf = (p) => ({ team: p.team === 'red' ? 'red' : 'blue', seat: Number.isFinite(Number(p.seat)) ? Number(p.seat) : 0 })\n    const isArena = () => !!(room.lobby && room.lobby.mode === 'arena')\n    const maxOf = (L) => Number(L && L.maxPlayers) || (L && L.mode === 'arena' ? ARENA_MAX : MAX_PLAYERS)\n    const vsStageOf = (L) => Math.min(VS_STAGES.length - 1, Math.max(0, Number(L && L.vsStage) || 0))\n    const vsMatchOf = (L) => Math.min(VS_MATCHES.length - 1, Math.max(0, Number(L && L.vsMatch) || 0))\n    // A free seat: the side asked for first (else the smaller team), its lowest seat.\n    const freeSeat = (exceptEmail, prefer) => {\n      const pilots = livePilots().filter((p) => p.email !== exceptEmail)\n      const taken = new Set(pilots.map((p) => { const st = seatOf(p); return `${st.team}:${st.seat}` }))\n      const count = (t) => pilots.filter((p) => seatOf(p).team === t).length\n      const order = prefer ? [prefer, prefer === 'blue' ? 'red' : 'blue'] : (count('red') < count('blue') ? ['red', 'blue'] : ['blue', 'red'])\n      for (const team of order) for (let n = 0; n < 4; n++) if (!taken.has(`${team}:${n}`)) return { team, seat: n }\n      return null\n    }\n\n    // Lobby socket: one room per lobby. Every write is followed by a ping; on a ping the others re-read\n    // the tables (the rows are the truth, the ping only says \"look now\"). Polling works without it.\n    const sockRoom = (id) => `tfsf-lobby-${id}`\n    const sock = () => rt.lobbySock || null\n    const announce = (kind) => {\n      const s = sock()\n      if (!s || !room.id) return\n      try { Promise.resolve(s.emit({ lobby: room.id, kind, from: me() }, sockRoom(room.id))).catch(() => {}) } catch (e) {}\n    }\n    let soonTimer = 0\n    const refreshSoon = () => { clearTimeout(soonTimer); soonTimer = setTimeout(() => { beat(); signal() }, 120) }\n    let sockOff = null\n    const listenSock = () => {\n      if (sockOff || !sock()) return\n      try {\n        const off = sock().onMessage((ev) => {\n          let msg = ev && ev.data\n          try { if (typeof msg === 'string') msg = JSON.parse(msg) } catch (e) { return }\n          if (!msg || cur !== 'room' || msg.lobby !== room.id || msg.from === me()) return\n          refreshSoon()\n        })\n        sockOff = typeof off === 'function' ? off : () => {}\n      } catch (e) {}\n    }\n    undo.push(() => { clearTimeout(soonTimer); try { sockOff && sockOff() } catch (e) {} })\n    const joinSock = (id) => { const s = sock(); if (s) { try { Promise.resolve(s.subscribe(sockRoom(id))).catch(() => {}) } catch (e) {} } }\n    const leaveSock = (id) => { const s = sock(); if (s && id) { try { Promise.resolve(s.unsubscribe(sockRoom(id))).catch(() => {}) } catch (e) {} } }\n\n    // Pilots: each is taken by at most one player; on a clash the later joiner moves to a free one.\n    const takenPilots = (exceptEmail) => livePilots().filter((p) => p.email !== exceptEmail).map((p) => p.pilot).filter(Boolean)\n    const freePilot = (exceptEmail) => { const taken = takenPilots(exceptEmail); return (PILOTS.find((p) => !taken.includes(p.id)) || PILOTS[0]).id }\n    const fixPilotClash = async () => {\n      const mine = room.mine\n      if (!mine) return\n      const earlier = livePilots().filter((p) => p.email !== mine.email && byJoin(p, mine) < 0)\n      if (!mine.pilot || earlier.some((p) => p.pilot === mine.pilot)) {\n        const taken = earlier.map((p) => p.pilot)\n        const pick = (PILOTS.find((p) => !taken.includes(p.id)) || PILOTS[0]).id\n        if (pick !== mine.pilot) {\n          mine.pilot = pick\n          await touch(mine, { pilot: pick })\n          announce('pilot')\n        }\n      }\n    }\n\n    // Two pilots on one seat (both moved at once): the later joiner moves on.\n    const fixSeatClash = async () => {\n      const mine = room.mine\n      if (!mine || !isArena()) return\n      const st = seatOf(mine)\n      const clash = livePilots().some((p) => p.email !== mine.email && byJoin(p, mine) < 0 && seatOf(p).team === st.team && seatOf(p).seat === st.seat)\n      if (mine.team == null || clash) {\n        const free = freeSeat(mine.email, mine.team)\n        if (free) { Object.assign(mine, free); await touch(mine, free); announce('seat') }\n      }\n    }\n    // Swaps: the asker writes swapTo (and the seat it wants); the other says yes (swapDone: takes the\n    // asker's seat) or no (swapNo); the asker, seeing yes, moves into the seat the other left.\n    const requestsForMe = () => {\n      const now = serverNow()\n      return livePilots().filter((p) => p.swapTo === me() && now - (Number(p.swapAt) || 0) < SWAP_MS && stampOf(p) > now - SWAP_MS)\n    }\n    const followSwap = async () => {\n      const mine = room.mine\n      if (!mine || !isArena() || !mine.swapTo) return\n      const other = livePilots().find((p) => p.email === mine.swapTo)\n      const expired = serverNow() - (Number(mine.swapAt) || 0) > SWAP_MS\n      if (other && other.swapDone === mine.email) {\n        const fields = { team: mine.swapTeam === 'red' ? 'red' : 'blue', seat: Number(mine.swapSeat) || 0, swapTo: '' }\n        Object.assign(mine, fields)\n        await touch(mine, fields)\n        announce('swap')\n      } else if (!other || other.swapNo === mine.email || expired) {\n        if (other && other.swapNo === mine.email) toast(`${String(other.name || nameOf(other.email)).toUpperCase()} SAID NO`)\n        Object.assign(mine, { swapTo: '' })\n        await touch(mine, { swapTo: '' })\n      }\n    }\n    const tapSeat = async (key) => {\n      const mine = room.mine\n      if (!mine || !isArena() || room.gone) return\n      const [team, n] = key.split(':')\n      const at = Number(n)\n      const p = livePilots().find((x) => { const st = seatOf(x); return st.team === team && st.seat === at })\n      if (!p) {\n        Object.assign(mine, { team, seat: at })\n        paintHangar()\n        try { await touch(mine, { team, seat: at }); announce('seat') } catch (err) { toast('COULD NOT MOVE') }\n        return\n      }\n      if (p.email === mine.email) return\n      if (mine.swapTo === p.email) return toast('ALREADY ASKED')\n      const st = seatOf(p)\n      const ask = { swapTo: p.email, swapAt: serverNow(), swapTeam: st.team, swapSeat: st.seat }\n      Object.assign(mine, ask)\n      paintHangar()\n      toast(`ASKED ${String(p.name || nameOf(p.email)).toUpperCase()} TO SWAP`)\n      try { await touch(mine, ask); announce('swap') } catch (err) { toast('COULD NOT ASK') }\n    }\n    const answerSwap = async (yes) => {\n      const mine = room.mine\n      const from = dlgs.swap.dataset.from\n      const req = livePilots().find((p) => p.email === from)\n      dlgs.swap.dataset.from = ''\n      closeDlg()\n      if (!mine || !req) return\n      swapSeen.add(`${from}:${req.swapAt}`)\n      if (yes) {\n        const theirs = seatOf(req)\n        const fields = { team: theirs.team, seat: theirs.seat, swapDone: from, swapNo: '' }\n        Object.assign(mine, fields)\n        try { await touch(mine, fields); announce('swap') } catch (err) {}\n      } else {\n        try { await touch(mine, { swapNo: from, swapDone: '' }); announce('swap') } catch (err) {}\n      }\n      paintHangar()\n    }\n    const swapSeen = new Set() // requests answered (or dismissed) here\n    on(dlgs.swap, 'click', (e) => { const b = e.target.closest && e.target.closest('[data-sw]'); if (b) answerSwap(b.dataset.sw === 'yes') })\n\n    // Links (WebRTC to the host).\n    const net = rt.net || (rt.net = makeNet())\n    const sid = () => Math.random().toString(36).slice(2, 10)\n    // Guest: dial the host (one offer row per attempt).\n    const dialHost = async () => {\n      const host = net.host\n      const s = sid()\n      const peer = net.newPeer(host, s)\n      peer.dialing = true\n      net.wire(peer, peer.pc.createDataChannel('u', { ordered: false, maxRetransmits: 0 }), false)\n      net.wire(peer, peer.pc.createDataChannel('r', { ordered: true }), true)\n      await peer.pc.setLocalDescription(await peer.pc.createOffer())\n      await net.gathered(peer.pc)\n      if (net.peers.get(host) !== peer) return\n      for (const old of await find(signalsDs(), { lobby: net.lobby, from: net.me, $limit: 20 })) { try { await signalsDs().remove(old) } catch (e) {} }\n      await signalsDs().create({ lobby: net.lobby, from: net.me, to: host, kind: 'offer', sid: s, sdp: peer.pc.localDescription.sdp, at: Date.now() })\n      announce('signal')\n    }\n    // One signaling pass: the host answers new offers; a guest picks up its answer, or redials.\n    const signal = async () => {\n      if (!net.lobby || !net.me || !rt.lobbyApi) return\n      try {\n        if (net.isHost()) {\n          const offers = await find(signalsDs(), { lobby: net.lobby, to: net.me, kind: 'offer', $limit: 20 })\n          for (const o of offers) {\n            const key = `${o.from}:${o.sid}`\n            if (net.handled.has(key)) continue\n            net.handled.add(key)\n            const peer = net.newPeer(o.from, o.sid)\n            await peer.pc.setRemoteDescription({ type: 'offer', sdp: o.sdp })\n            await peer.pc.setLocalDescription(await peer.pc.createAnswer())\n            await net.gathered(peer.pc)\n            for (const old of await find(signalsDs(), { lobby: net.lobby, from: net.me, to: o.from, $limit: 20 })) { try { await signalsDs().remove(old) } catch (e) {} }\n            await signalsDs().create({ lobby: net.lobby, from: net.me, to: o.from, kind: 'answer', sid: o.sid, sdp: peer.pc.localDescription.sdp, at: Date.now() })\n            announce('signal')\n          }\n          // Guests that left: drop their links.\n          const here = new Set(livePilots().map((p) => p.email))\n          for (const email of [...net.peers.keys()]) if (!here.has(email)) net.closePeer(email)\n        } else if (net.host) {\n          const peer = net.peers.get(net.host)\n          const dead = !peer || peer.state === 'failed' || peer.state === 'closed' ||\n            (peer.state === 'linking' && Date.now() - peer.startedAt > LINK_TIMEOUT_MS)\n          if (dead) return dialHost()\n          if (peer.state === 'linking' && peer.dialing && !peer.pc.remoteDescription) {\n            const [ans] = await find(signalsDs(), { lobby: net.lobby, to: net.me, kind: 'answer', sid: peer.sid, $limit: 1 })\n            if (ans && net.peers.get(net.host) === peer && !peer.pc.remoteDescription) await peer.pc.setRemoteDescription({ type: 'answer', sdp: ans.sdp })\n          }\n        }\n      } catch (e) {\n        console.warn('[mainmenu] signaling', e)\n      }\n    }\n    // Point the runtime at this lobby / host (a host change makes guests redial).\n    const syncNet = () => {\n      const L = room.lobby\n      if (!L || !room.mine) return\n      if (net.lobby !== room.id || net.me !== me()) { net.closeAll(); net.handled.clear(); net.lobby = room.id; net.me = me() }\n      if (net.host !== L.host) { net.closeAll(); net.host = L.host }\n      clearInterval(room.sigTimer)\n      room.sigTimer = setInterval(signal, SIG_MS)\n    }\n    const linkOf = (email) => {\n      if (email === me()) {\n        if (net.isHost()) return { state: 'host' }\n        const p = net.peers.get(net.host)\n        return p ? { state: p.state, rtt: p.rtt } : { state: 'linking' }\n      }\n      if (email === net.host) return { state: 'host' }\n      if (net.isHost()) { const p = net.peers.get(email); return p ? { state: p.state, rtt: p.rtt } : { state: 'linking' } }\n      return net.links[email] || { state: 'linking' }\n    }\n    const linkLabel = (l) => (l.state === 'host' ? 'HOST LINK' : l.state === 'open' ? `LINKED · ${l.rtt || '…'} MS` : l.state === 'failed' ? 'NO ROUTE · RETRYING' : 'LINKING…')\n    const allLinked = () => livePilots().every((p) => { const l = linkOf(p.email); return l.state === 'host' || l.state === 'open' })\n    // Depart from the host: everyone gets it over the link (and from the lobby row as a fallback).\n    undo.push(net.on('depart', (msg) => { if (cur === 'room' && !net.isHost()) launch(msg.start) }))\n    const launch = (start) => {\n      if (room.departing) return\n      room.departing = true\n      seat('')\n      leaveRoomTimers()\n      rt.mp = { lobby: room.id, start, me: me(), host: net.host, pilots: Object.fromEntries(livePilots().map((p) => [p.email, p.pilot || 'fox'])), names: Object.fromEntries(livePilots().map((p) => [p.email, p.name || nameOf(p.email)])),\n        teams: (start && start.teams) || Object.fromEntries(livePilots().map((p) => [p.email, seatOf(p).team])), seats: Object.fromEntries(livePilots().map((p) => [p.email, seatOf(p).seat])) }\n      // Guests are launched by the host's message, not by a tap of theirs: they still tapped Ready.\n      const level = start && start.cmd === 'level' ? `&level=${Number(start.level) || 0}` : ''\n      const arena = start && start.cmd === 'arena' ? `&stage=${Number(start.stage) || 0}&match=${Number(start.match) || 0}&minutes=${Number(start.minutes) || ARENA_MINUTES}&rounds=${Number(start.rounds) || VS_ROUNDS[Number(start.match) || 0] || 3}` : ''\n      play(`cmd=${encodeURIComponent((start && start.cmd) || 'new')}${level}${arena}&net=1`, net.isHost())\n    }\n\n    // The host picks the lobby's stage; everyone sees it on the lobby row.\n    const roomStage = () => { const v = room.lobby && room.lobby.stage; return Number.isFinite(Number(v)) && v !== null && v !== undefined ? Number(v) : -1 }\n    const pickRoomStage = async (id) => {\n      if (!amHost() || !room.lobby) return\n      const st = stageOf(id)\n      room.lobby = { ...room.lobby, stage: st.id, campaign: st.name }\n      paintHangar()\n      try { await patch(lobbiesDs(), { _id: room.id, stage: st.id, campaign: st.name }); announce('stage') } catch (e) { toast('COULD NOT CHANGE THE MISSION') }\n    }\n\n    // Arena: the host's map and match type, on the lobby row.\n    const pickBattle = async (fields) => {\n      if (!amHost() || !room.lobby || !isArena()) return\n      if (fields.vsStage != null) fields.campaign = VS_STAGES[fields.vsStage]\n      room.lobby = { ...room.lobby, ...fields }\n      paintHangar(); fillBattle()\n      try { await patch(lobbiesDs(), { _id: room.id, ...fields }); announce('stage') } catch (e) { toast('COULD NOT CHANGE THE BATTLE') }\n    }\n\n    // Join (or re-join) this lobby: one lobbyPlayers row per pilot.\n    const joinRoom = async () => {\n      const myEmail = me()\n      const lobby = (await find(lobbiesDs(), { _id: room.id }))[0]\n      if (!lobby) { room.gone = 'THIS LOBBY DOES NOT EXIST ANYMORE'; return false }\n      room.lobby = lobby\n      const pilots = await find(playersDs(), { lobby: room.id, $limit: 50 })\n      room.pilots = pilots\n      let mine = pilots.find((p) => p.email === myEmail)\n      const now = Date.now()\n      // Back from an Arena battle of this lobby (the player sends its pilots back here).\n      const back = room.back\n      if (!mine) {\n        if (lobby.status !== 'open' && !back) { room.gone = 'THIS LOBBY HAS ALREADY DEPARTED'; return false }\n        if (pilots.filter((p) => live(p)).length >= maxOf(lobby)) { room.gone = 'THIS LOBBY IS FULL'; return false }\n        const was = back && rt.mp && rt.mp.teams && rt.mp.teams[myEmail] ? { team: rt.mp.teams[myEmail], seat: Number(rt.mp.seats && rt.mp.seats[myEmail]) || 0 } : null\n        const wasFree = was && !pilots.some((p) => live(p) && p.email !== myEmail && seatOf(p).team === was.team && seatOf(p).seat === was.seat)\n        const seatFields = lobby.mode === 'arena' ? ((wasFree && was) || freeSeat(myEmail, was && was.team) || { team: 'blue', seat: 0 }) : {}\n        const wasPilot = back && rt.mp && rt.mp.pilots ? rt.mp.pilots[myEmail] : ''\n        const pilot = wasPilot && !takenPilots(myEmail).includes(wasPilot) ? wasPilot : freePilot(myEmail)\n        mine = await playersDs().create({ lobby: room.id, email: myEmail, name: myName(), pilot, ready: false, seen: now, joinedAt: now, ...seatFields })\n        if (!mine || !mine._id) throw new Error('no player id')\n        learnSkew(mine, now)\n        room.pilots.push(mine)\n        // Two pilots can take the last seat at once: the later joiner gives it back.\n        const after = (await find(playersDs(), { lobby: room.id, $limit: 50 })).filter((p) => live(p)).sort(byJoin)\n        if (after.findIndex((p) => p.email === myEmail) >= maxOf(lobby)) {\n          try { await playersDs().remove(mine) } catch (e) {}\n          room.gone = 'THIS LOBBY IS FULL'\n          return false\n        }\n      } else {\n        Object.assign(mine, await touch(mine, back ? { name: myName(), ready: false } : { name: myName() }) || {})\n        if (back) mine.ready = false\n      }\n      if (back && lobby.status !== 'open') {\n        // The battle is over: the lobby takes pilots (and a new departure) again.\n        try { await patch(lobbiesDs(), { _id: room.id, status: 'open' }); room.lobby = { ...lobby, status: 'open' } } catch (e) {}\n      }\n      room.mine = mine\n      seat(room.id)\n      // The pilot was picked before this phone knew the server's clock (from the row just written),\n      // so the others' rows may have looked stale: check it again now.\n      try { await fixPilotClash() } catch (e) {}\n      try { await fixSeatClash() } catch (e) {}\n      announce('join')\n      return true\n    }\n\n    // Every BEAT_MS: heartbeat, refresh, and (as host) housekeeping.\n    let beat = async () => {\n      if (cur !== 'room' || !room.mine) return\n      try {\n        const sentAt = Date.now()\n        const touched = await touch(room.mine)\n        const [lobby] = await find(lobbiesDs(), { _id: room.id })\n        if (!lobby) { room.gone = 'THE LOBBY WAS CLOSED'; seat(''); return paintHangar() }\n        room.lobby = lobby\n        // A departure (not the one this pilot just came back from) takes everyone along.\n        const old = room.back && lobby.start && Number(lobby.start.at) <= room.back.at\n        if (lobby.status === 'departed' && !amHost() && !old) return launch(lobby.start)\n        room.pilots = await find(playersDs(), { lobby: room.id, $limit: 50 })\n        const myRow = room.pilots.find((p) => String(p._id) === String(room.mine._id))\n        if (!myRow) { room.gone = 'YOU WERE AWAY TOO LONG AND LEFT THIS LOBBY'; seat(''); return paintHangar() }\n        room.mine = myRow\n        // No record back from the save: our row, as just read, carries the server's stamp of it.\n        if (!(touched && touched.updatedAt)) learnSkew(myRow, sentAt)\n        const now = serverNow()\n        await fixPilotClash()\n        try { await fixSeatClash(); await followSwap() } catch (e) {}\n        const pilots = livePilots()\n        // The host is gone: the earliest-joined live pilot takes over.\n        if (!pilots.some((p) => p.email === lobby.host) && pilots[0] && pilots[0].email === me()) {\n          await patch(lobbiesDs(), { _id: room.id, host: me(), hostName: myName() })\n          room.lobby = { ...lobby, host: me(), hostName: myName() }\n          announce('host')\n        }\n        if (net.host !== room.lobby.host) syncNet()\n        if (amHost()) {\n          // Forget pilots long gone, and keep the lobby's players snapshot current.\n          for (const p of room.pilots) if (stampOf(p) < now - PRUNE_MS) { try { await playersDs().remove(p) } catch (e) {} }\n          const snap = pilots.map((p) => ({ email: p.email, name: p.name, pilot: p.pilot || '', ready: !!p.ready, ...(isArena() ? { team: seatOf(p).team } : {}) }))\n          if (JSON.stringify(snap) !== JSON.stringify(room.lobby.players || [])) {\n            await patch(lobbiesDs(), { _id: room.id, players: snap })\n            room.lobby.players = snap\n          }\n        }\n      } catch (e) {\n        console.warn('[mainmenu] lobby refresh', e)\n      }\n      paintHangar()\n    }\n    // beat() can be asked for by a socket ping while one is running: run one at a time.\n    const beatOnce = beat\n    let beating = null\n    beat = () => (beating || (beating = beatOnce().finally(() => { beating = null })))\n\n    const leaveRoomTimers = () => { clearInterval(room.timer); clearInterval(room.sigTimer); clearInterval(room.paintTimer) }\n    const enterRoom = async (id) => {\n      leaveRoomTimers()\n      const back = rt.arenaBack && rt.arenaBack.lobby === id ? rt.arenaBack : null\n      rt.arenaBack = null\n      Object.assign(room, { id, nextId: '', lobby: null, pilots: [], mine: null, gone: '', joining: true, departing: false, back })\n      paintHangar()\n      try {\n        if (!(await waitApi())) throw new Error('no lobby link')\n        if (cur !== 'room' || room.id !== id) return\n        listenSock()\n        const ok = await joinRoom()\n        room.joining = false\n        if (cur !== 'room' || room.id !== id) return\n        paintHangar()\n        if (ok) {\n          joinSock(id)\n          beat()\n          room.timer = setInterval(() => beat(), BEAT_MS)\n          syncNet()\n          signal()\n          // Link status changes faster than the lobby beat: repaint every second.\n          room.paintTimer = setInterval(paintHangar, 1000)\n        }\n      } catch (e) {\n        room.joining = false\n        room.gone = 'COULD NOT JOIN THIS LOBBY'\n        console.warn('[mainmenu] join lobby', e)\n        paintHangar()\n      }\n    }\n    // Leaving: drop my row; a leaving host hands the lobby on, the last pilot out closes it.\n    const leaveRoom = async () => {\n      leaveRoomTimers()\n      const mine = room.mine\n      const lobby = room.lobby\n      const id = room.id\n      room.mine = null\n      seat('')\n      if (!mine) return\n      net.closeAll()\n      net.lobby = ''\n      leaveSock(id)\n      try {\n        for (const s of await find(signalsDs(), { lobby: id, from: me(), $limit: 20 })) { try { await signalsDs().remove(s) } catch (e) {} }\n        await playersDs().remove(mine)\n        if (sock()) { try { Promise.resolve(sock().emit({ lobby: id, kind: 'leave', from: me() }, sockRoom(id))).catch(() => {}) } catch (e) {} }\n        if (lobby && lobby.host === me()) {\n          const rest = (await find(playersDs(), { lobby: id, $limit: 50 })).filter((p) => live(p)).sort(byJoin)\n          if (rest[0]) await patch(lobbiesDs(), { _id: id, host: rest[0].email, hostName: rest[0].name || nameOf(rest[0].email) })\n          else {\n            for (const p of await find(playersDs(), { lobby: id, $limit: 50 })) { try { await playersDs().remove(p) } catch (e) {} }\n            for (const s of await find(signalsDs(), { lobby: id, $limit: 50 })) { try { await signalsDs().remove(s) } catch (e) {} }\n            await lobbiesDs().remove(lobby)\n          }\n        }\n      } catch (e) {\n        console.warn('[mainmenu] leave lobby', e)\n      }\n    }\n    // Leaving the room's screen leaves the lobby (unless it is departing for /play).\n    const exitRoom = () => { if (room.mine && !room.departing) leaveRoom(); else leaveRoomTimers(); room.id = ''; room.gone = '' }\n    // Closing or reloading the tab is not leaving: the seat is kept for LIVE_MS.\n    let unloading = false\n    on(window, 'pagehide', () => { unloading = true })\n    // In the background (home screen, another app, the screen locked) the menu goes quiet: its music and\n    // anything else on the shared audio context. Back in front it plays on (or at the next tap, if iOS\n    // will not resume it without one).\n    let bgQuiet = false\n    const quiet = (hidden) => {\n      const ac = rt.audio\n      if (!ac || ac.state === 'closed') return\n      if (hidden) { if (ac.state === 'running') { bgQuiet = true; ac.suspend().catch(() => {}) } }\n      else if (bgQuiet) { bgQuiet = false; ac.resume().catch(() => {}) }\n    }\n    on(document, 'visibilitychange', () => quiet(document.hidden))\n    on(window, 'pagehide', () => quiet(true))\n    on(window, 'pageshow', () => quiet(document.hidden))\n    on(window, 'blur', () => { if (document.hidden) quiet(true) })\n    undo.push(() => { if (room.mine && !room.departing && !unloading) leaveRoom(); else leaveRoomTimers() })\n    const enterRoomFromPath = () => {\n      const m = location.pathname.match(/^\\/multiplayer\\/lobbies\\/([^/]+)/)\n      if (m) enterRoom(decodeURIComponent(m[1]))\n    }\n    const setReady = async (ready) => {\n      const mine = room.mine\n      if (!mine) return\n      mine.ready = ready\n      const row = room.pilots.find((p) => String(p._id) === String(mine._id))\n      if (row) row.ready = ready\n      paintHangar()\n      try { await touch(mine, { ready }); announce('ready') } catch (e) { toast('COULD NOT UPDATE YOUR STATUS') }\n    }\n    const pickPilot = async (pick) => {\n      const mine = room.mine\n      if (!mine || takenPilots(mine.email).includes(pick)) return paintHangar()\n      mine.pilot = pick\n      const row = room.pilots.find((p) => String(p._id) === String(mine._id))\n      if (row) row.pilot = pick\n      paintHangar()\n      try { await touch(mine, { pilot: pick }); announce('pilot') } catch (err) { toast('COULD NOT CHANGE PILOT') }\n    }\n\n    // ---- the column's actions ---------------------------------------------------------------------\n    const hangarOn = () => cur === 'solo' || cur === 'room'\n    const toggleReady = () => {\n      if (cur === 'solo') { soloReady = !soloReady; return paintHangar() }\n      if (cur === 'room' && room.mine) setReady(!room.mine.ready)\n    }\n    const canPickMission = () => cur === 'solo' || (cur === 'room' && amHost() && !room.gone)\n    const cycleControls = () => {\n      const next = CONTROLS[(CONTROLS.findIndex((c) => c.id === controlsId()) + 1) % CONTROLS.length]\n      try { localStorage.setItem(CONTROLS_KEY, next.id) } catch (err) {}\n      toast(`CONTROLS: ${next.label.toUpperCase()}`)\n      paintHangar()\n    }\n    const depart = async () => {\n      if (cur === 'solo') {\n        if (!soloReady || tr) return\n        rt.mp = null\n        const st = stageCmd(soloStage())\n        return play(st.cmd === 'level' ? `cmd=level&level=${st.level}` : 'cmd=new&expert=0')\n      }\n      unlockAudio(); askMotion(); goFullscreen() // inside the tap, before any await\n      if (cur !== 'room' || !amHost() || room.departing) return\n      const pilots = livePilots()\n      if (!pilots.length || !pilots.every((p) => p.ready) || !allLinked()) return\n      if (isArena() && (pilots.length < 2 || pilots.length > ARENA_MAX)) return\n      const L = room.lobby || {}\n      const start = isArena()\n        ? { cmd: 'arena', stage: vsStageOf(L), match: vsMatchOf(L), rounds: VS_ROUNDS[vsMatchOf(L)], minutes: Number(rt.arenaMinutes) || ARENA_MINUTES, at: Date.now(),\n          teams: Object.fromEntries(pilots.map((p) => [p.email, seatOf(p).team])) }\n        : { ...stageCmd(roomStage()), at: Date.now() }\n      net.send({ t: 'depart', start }, { reliable: true })\n      try { await patch(lobbiesDs(), { _id: room.id, status: 'departed', start }) } catch (e) {}\n      announce('depart')\n      launch(start)\n    }\n    on($('[data-al-go]'), 'click', () => { if ($('[data-alert]').classList.contains('go')) depart() })\n\n    // ---- pickers ----------------------------------------------------------------------------------\n    const pickMission = $('[data-pick-mission]'), pickPilotEl = $('[data-pick-pilot]')\n    const MISSION_SECTIONS = [['CAMPAIGN', ['Full campaign']], ['PLANETS', ['Planet']], ['SPACE', ['Space']], ['ALL-RANGE', ['All-range']],\n      ['VEHICLES', ['Landmaster', 'Blue Marine']], ['FINAL', ['Boss']]]\n    const fillMission = () => {\n      const now = cur === 'room' ? roomStage() : soloStage()\n      let k = 0\n      pickMission.classList.add('missions')\n      pickMission.innerHTML = MISSION_SECTIONS.map(([title, kinds]) => {\n        const list = STAGES.filter((st) => kinds.includes(st.kind))\n        if (!list.length) return ''\n        return `<div class=\"pick-sec\">${title}</div>` + list.map((st) => `<button type=\"button\" class=\"pick art ${st.id < 0 ? 'wide' : ''} ${st.id === now ? 'on' : ''}\" data-stage=\"${st.id}\" style=\"--k:${k++}\">\n          <canvas data-art=\"${st.id}\"></canvas><span class=\"pk\">${esc(st.name.toUpperCase())}<small>${esc(st.kind.toUpperCase())}</small></span></button>`).join('')\n      }).join('')\n      worldsStart()\n    }\n\n    // ---- the worlds: each mission's planet (or nebula, station, sun) on a small canvas, spinning ----\n    // As the game's map draws them: a planet is its surface texture scrolled across a disc (that is\n    // its rotation) under a shadow; the campaign has the Star Fox logo, turning, with some depth.\n    const WORLD_ART = { 0: ['planet', 0], 14: ['planet', 1], 16: ['planet', 2], 11: ['planet', 3], 12: ['planet', 4, 'rings'], 8: ['planet', 5],\n      13: ['planet', 6], 6: ['planet', 7, 'cloud'], 19: ['planet', 7, 'cloud', 'red'], 9: ['planet', 7, 'cloud', 'boss'], 7: ['sun'],\n      1: ['meteo'], 2: ['nebula', 0], 5: ['nebula', 1], 18: ['nebula', 2], 3: ['area6'], 17: ['bolse'], [-1]: ['logo'] }\n    const PLANET_GLOW = ['#6fb6ff', '#9be7ff', '#8fd0ff', '#ffbf7a', '#e8e0c8', '#d6e86f', '#7fb6ff', '#ff8c5a']\n    const worlds = { atlas: null, loading: null, logo: null, raf: 0, t0: 0 }\n    const dlKey = async () => {\n      if (rt.dlKey) return rt.dlKey\n      const kr = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (!kr.ok) throw new Error(`keys ${kr.status}`)\n      const body = await kr.json(); const rows = Array.isArray(body) ? body : (body && body.data) || []\n      return rows[0] && rows[0].key\n    }\n    const worldsLoad = () => worlds.loading || (worlds.loading = (async () => {\n      const key = await dlKey()\n      const r = await fetch(`${filesBase}/file/${MAP_ATLAS_REF}?t=${encodeURIComponent(key)}`)\n      if (!r.ok) throw new Error(`atlas ${r.status}`)\n      worlds.atlas = await createImageBitmap(await r.blob())\n    })().catch((e) => { worlds.loading = null; console.warn('[mainmenu] map atlas', e && e.message) }))\n    const worldsStart = () => {\n      worldsLoad()\n      if (!worlds.logo) { const im = new Image(); im.src = ASSET(LOGO_REF); worlds.logo = im }\n      worlds.t0 = performance.now()\n      if (!worlds.raf) worlds.raf = requestAnimationFrame(worldsFrame)\n    }\n    undo.push(() => cancelAnimationFrame(worlds.raf))\n    const ease = (x) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)\n    const worldsFrame = (now) => {\n      if (!dlgs.mission.classList.contains('show') || !alive) { worlds.raf = 0; return }\n      worlds.raf = requestAnimationFrame(worldsFrame)\n      const t = (now - worlds.t0) / 1000\n      const dpr = Math.min(2, window.devicePixelRatio || 1)\n      for (const cv of pickMission.querySelectorAll('canvas[data-art]')) {\n        const w = Math.round(cv.clientWidth * dpr), h = Math.round(cv.clientHeight * dpr)\n        if (!w || !h) continue\n        if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h }\n        const cx = cv.getContext('2d')\n        cx.setTransform(1, 0, 0, 1, 0, 0); cx.clearRect(0, 0, w, h)\n        const id = Number(cv.dataset.art), art = WORLD_ART[id] || ['planet', 0]\n        const k = Number(cv.closest('.pick').style.getPropertyValue('--k')) || 0\n        const tin = ease((t - k * 0.028 - 0.06) / 0.7) // its arrival: grows in, spinning down to its pace\n        drawWorld(cx, w, h, art, t, tin)\n      }\n    }\n    const drawWorld = (cx, w, h, art, t, tin) => {\n      const A = worlds.atlas, kind = art[0]\n      const R = Math.min(w, h) * 0.38 * (0.35 + 0.65 * tin), mx = w / 2, my = h / 2\n      const spin = t * 10 + (1 - tin) * 60 // texels per second (fast while arriving)\n      cx.globalAlpha = tin\n      if (kind === 'logo') {\n        const im = worlds.logo\n        if (!im || !im.complete || !im.naturalWidth) return\n        if (!worlds.logoDark) { // its silhouette, dark: the layers of its depth (no canvas filters: older iOS has none)\n          const c = document.createElement('canvas'); c.width = im.naturalWidth; c.height = im.naturalHeight\n          const x = c.getContext('2d'); x.drawImage(im, 0, 0); x.globalCompositeOperation = 'source-in'; x.fillStyle = '#06142c'; x.fillRect(0, 0, c.width, c.height)\n          worlds.logoDark = c\n        }\n        const ang = Math.sin(t * 0.9) * 0.55 + (1 - tin) * 3.1\n        const sx = Math.cos(ang), lw = w * 0.92, lh = lw * im.naturalHeight / im.naturalWidth\n        cx.save(); cx.translate(mx, my)\n        for (let d = 6; d >= 1; d--) { // the depth: darker copies behind, offset the way it faces\n          cx.save(); cx.translate(-Math.sin(ang) * d * 1.6, d * 0.8); cx.scale(sx, 1); cx.globalAlpha = tin * 0.6\n          cx.drawImage(worlds.logoDark, -lw / 2, -lh / 2, lw, lh); cx.restore()\n        }\n        cx.scale(sx, 1); cx.globalAlpha = tin; cx.drawImage(im, -lw / 2, -lh / 2, lw, lh)\n        cx.restore(); return\n      }\n      if (!A) { // until the textures arrive: a dim disc\n        cx.beginPath(); cx.arc(mx, my, R, 0, Math.PI * 2); cx.fillStyle = '#0e2a4a'; cx.fill(); return\n      }\n      if (kind === 'planet') {\n        const i = art[1], glow = art[3] ? '#ff4a3a' : PLANET_GLOW[i]\n        // the atmosphere\n        const g0 = cx.createRadialGradient(mx, my, R * 0.9, mx, my, R * 1.35)\n        g0.addColorStop(0, glow + 'aa'); g0.addColorStop(1, glow + '00')\n        cx.fillStyle = g0; cx.beginPath(); cx.arc(mx, my, R * 1.35, 0, Math.PI * 2); cx.fill()\n        if (art[2] === 'rings') worldRing(cx, mx, my, R, 'back')\n        cx.save(); cx.beginPath(); cx.arc(mx, my, R, 0, Math.PI * 2); cx.clip()\n        // (the surface, then its mirror image, and so on: the texture's edges do not match, a mirror's do;\n        // whole pixels, each copy a pixel over the last)\n        const D = Math.round(R * 2), x0 = Math.round(mx - R), y0 = Math.round(my - R)\n        const strip = (sx, sy, off) => {\n          for (let j = 0; j < 3; j++) {\n            const x = x0 - off + j * D\n            if (j % 2) { cx.save(); cx.translate(x + D, y0); cx.scale(-1, 1); cx.drawImage(A, sx, sy, 96, 96, 0, 0, D + 1, D); cx.restore() }\n            else cx.drawImage(A, sx, sy, 96, 96, x, y0, D + 1, D)\n          }\n        }\n        strip(i * 96, 0, Math.round(((spin % 192) / 96) * D))\n        if (art[2] === 'cloud') { // Venom's clouds, drifting the other way\n          cx.globalAlpha = tin * 0.55\n          strip(8 * 96, 0, Math.round(((192 - ((t * 6) % 192)) / 96) * D))\n          cx.globalAlpha = tin\n        }\n        if (art[3]) { cx.globalCompositeOperation = 'multiply'; cx.fillStyle = art[3] === 'boss' ? '#ff6a5a' : '#ff9a8a'; cx.fillRect(mx - R, my - R, D, D); cx.globalCompositeOperation = 'source-over' }\n        // the sphere: light from the upper left, the night side to the lower right\n        const g = cx.createRadialGradient(mx - R * 0.45, my - R * 0.45, R * 0.1, mx, my, R * 1.05)\n        g.addColorStop(0, 'rgba(255,255,255,0.18)'); g.addColorStop(0.45, 'rgba(0,0,0,0)'); g.addColorStop(0.85, 'rgba(0,4,16,0.55)'); g.addColorStop(1, 'rgba(0,4,16,0.85)')\n        cx.fillStyle = g; cx.fillRect(mx - R, my - R, D, D)\n        cx.restore()\n        if (art[2] === 'rings') worldRing(cx, mx, my, R, 'front')\n        return\n      }\n      if (kind === 'nebula') { // a sector: its nebula, turning slowly, glowing\n        const i = art[1]\n        cx.save(); cx.translate(mx, my); cx.globalCompositeOperation = 'lighter'\n        for (const [s, a, r] of [[2.7, 0.55, 1], [2.1, 0.8, -1.6]]) {\n          cx.save(); cx.rotate(t * 0.25 * r + (1 - tin) * 4); const S = R * s\n          cx.globalAlpha = tin * a; cx.drawImage(A, i * 32, 96, 32, 32, -S / 2, -S / 2, S, S); cx.restore()\n        }\n        cx.restore(); return\n      }\n      if (kind === 'meteo') { // the asteroid field: a big rock tumbling, smaller ones going round\n        const rock = (x, y, s, a) => { cx.save(); cx.translate(x, y); cx.rotate(a); cx.drawImage(A, 3 * 32, 96, 32, 32, -s / 2, -s / 2, s, s); cx.restore() }\n        rock(mx, my, R * 1.7, t * 0.6 + (1 - tin) * 5)\n        for (let j = 0; j < 4; j++) { const a = t * 0.5 + j * 1.57; rock(mx + Math.cos(a) * R * 1.1, my + Math.sin(a) * R * 0.45, R * (0.35 + j * 0.06), -t * 1.3 + j) }\n        return\n      }\n      if (kind === 'sun') { // Solar: the star's corona, turning, over a burning disc\n        const g = cx.createRadialGradient(mx, my, 0, mx, my, R * 1.4)\n        g.addColorStop(0, '#fff6c8'); g.addColorStop(0.35, '#ffcf4a'); g.addColorStop(0.62, '#ff7a1a'); g.addColorStop(1, 'rgba(255,60,0,0)')\n        cx.fillStyle = g; cx.beginPath(); cx.arc(mx, my, R * 1.4, 0, Math.PI * 2); cx.fill()\n        cx.save(); cx.translate(mx, my); cx.globalCompositeOperation = 'lighter'\n        for (const [r, a] of [[1, 0.7], [-0.6, 0.45]]) { cx.save(); cx.rotate(t * 0.3 * r); const S = R * 2.9; cx.globalAlpha = tin * a; cx.drawImage(A, 0, 128, 128, 128, -S / 2, -S / 2, S, S); cx.restore() }\n        cx.restore(); return\n      }\n      if (kind === 'area6' || kind === 'bolse') { // the battle fleet's flagship / the station: its hull, turning\n        const tiles = kind === 'area6' ? [[4 * 32, 96, 32]] : [[5 * 32, 96, 32], [6 * 32, 96, 32], [7 * 32, 96, 32], [272, 96, 16]]\n        // (turning, but never edge on: a slow rock and wobble)\n        const ang = t * 0.5 + (1 - tin) * 6\n        cx.save(); cx.translate(mx, my); cx.rotate(0.35 + Math.sin(ang) * 0.25); cx.scale(0.72 + 0.28 * Math.cos(ang * 1.3), 1)\n        const S = R * (kind === 'area6' ? 1.9 : 1.05)\n        if (kind === 'area6') cx.drawImage(A, tiles[0][0], tiles[0][1], 32, 32, -S / 2, -S / 2, S, S)\n        else tiles.forEach(([x, y, n], j) => cx.drawImage(A, x, y, n, n, (j % 2 ? 0 : -S), (j < 2 ? -S : 0), S, S))\n        cx.restore()\n        const g = cx.createRadialGradient(mx, my, R * 0.2, mx, my, R * 1.4)\n        g.addColorStop(0, 'rgba(120,200,255,0)'); g.addColorStop(1, 'rgba(120,200,255,0.18)')\n        cx.fillStyle = g; cx.beginPath(); cx.arc(mx, my, R * 1.4, 0, Math.PI * 2); cx.fill()\n      }\n    }\n    // Titania's rings: the half behind the planet, then the half in front\n    const worldRing = (cx, mx, my, R, half) => {\n      cx.save(); cx.translate(mx, my); cx.rotate(-0.35)\n      cx.strokeStyle = 'rgba(235,220,180,0.75)'; cx.lineWidth = R * 0.12\n      cx.beginPath(); cx.ellipse(0, 0, R * 1.55, R * 0.42, 0, half === 'back' ? Math.PI : 0, half === 'back' ? Math.PI * 2 : Math.PI); cx.stroke()\n      cx.strokeStyle = 'rgba(235,220,180,0.35)'; cx.lineWidth = R * 0.05\n      cx.beginPath(); cx.ellipse(0, 0, R * 1.78, R * 0.5, 0, half === 'back' ? Math.PI : 0, half === 'back' ? Math.PI * 2 : Math.PI); cx.stroke()\n      cx.restore()\n    }\n    // The pilot picker: in two sections (the McCloud team, Star Wolf), each pilot with their comms\n    // portrait (the game's own, talking while picked) and their ship turning beside it: Arwing or Wolfen.\n    const fillPilot = () => {\n      const mine = room.mine\n      const taken = mine ? takenPilots(mine.email) : []\n      let k = 0\n      const card = (pl) => {\n        const t = taken.includes(pl.id), on = mine && mine.pilot === pl.id, wf = WOLF_CREW.includes(pl.id)\n        return `<button type=\"button\" class=\"pick pil ${on ? 'on' : ''} ${wf ? 'wolf' : ''}\" data-pilot=\"${pl.id}\" ${t ? 'disabled' : ''} style=\"--k:${k++}\">\n          <canvas data-face=\"${PILOTS.indexOf(pl)}\"></canvas><span class=\"pk\">${esc(pl.name.toUpperCase())}<small>${t ? 'TAKEN' : on ? 'YOURS' : wf ? 'STAR WOLF' : 'STAR FOX'}</small></span><i class=\"pk-ship\" data-ship=\"${wf ? 'wolf' : 'arwing'}\"></i></button>`\n      }\n      pickPilotEl.innerHTML = [['STAR FOX', PILOTS.filter((p) => !WOLF_CREW.includes(p.id))], ['STAR WOLF', PILOTS.filter((p) => WOLF_CREW.includes(p.id))]]\n        .map(([title, list]) => `<div class=\"pick-sec\">${title}</div>` + list.map(card).join('')).join('')\n      pilotsStart()\n    }\n    const pk = { faces: null, loading: null, raf: 0, t0: 0, gl: null, prog: null, glow: null, quad: null, models: new Map(), talk: 0, talkAt: 0 }\n    const facesLoad = () => pk.loading || (pk.loading = (async () => {\n      const key = await dlKey()\n      const r = await fetch(`${filesBase}/file/${FACES_REF}?t=${encodeURIComponent(key)}`)\n      if (!r.ok) throw new Error(`faces ${r.status}`)\n      pk.faces = await createImageBitmap(await r.blob())\n    })().catch((e) => { pk.loading = null; console.warn('[mainmenu] portraits', e && e.message) }))\n    const pilotsStart = () => {\n      facesLoad()\n      pk.t0 = performance.now()\n      if (!pk.raf) pk.raf = requestAnimationFrame(pilotsFrame)\n    }\n    undo.push(() => { cancelAnimationFrame(pk.raf); try { const x = pk.gl && pk.gl.getExtension('WEBGL_lose_context'); if (x) x.loseContext() } catch (e) {} })\n    // the picker's own WebGL context (the scene's canvas is under the dialog): the same shaders, the\n    // models' data uploaded again from what the scene parsed\n    const pkGL = () => {\n      if (pk.gl !== null) return pk.gl\n      pk.gl = false\n      try {\n        const g = $('.pk-gl').getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true })\n        if (!g) return false\n        pk.prog = compile(...SHIP_SRC, g); pk.glow = compile(...GLOW_SRC, g)\n        pk.quad = g.createVertexArray(); g.bindVertexArray(pk.quad)\n        const qb = g.createBuffer(); g.bindBuffer(g.ARRAY_BUFFER, qb)\n        g.bufferData(g.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]), g.STATIC_DRAW)\n        const l = g.getAttribLocation(pk.glow.p, 'aC'); g.enableVertexAttribArray(l); g.vertexAttribPointer(l, 2, g.FLOAT, false, 0, 0)\n        g.bindVertexArray(null)\n        pk.gl = g\n      } catch (e) { console.warn('[mainmenu] pilot ships', e && e.message) }\n      return pk.gl\n    }\n    const pkModel = (g, m) => {\n      if (!m || !m.ready || !m.raw || !m.raw.px) return null\n      let o = pk.models.get(m)\n      if (o) return o\n      const { out, nv, layers, px } = m.raw, F = 11\n      o = { count: nv, glow: m.glow, vao: g.createVertexArray() }\n      g.bindVertexArray(o.vao)\n      const vb = g.createBuffer(); g.bindBuffer(g.ARRAY_BUFFER, vb); g.bufferData(g.ARRAY_BUFFER, out, g.STATIC_DRAW)\n      const at = (name, n, off) => { const l = g.getAttribLocation(pk.prog.p, name); if (l < 0) return; g.enableVertexAttribArray(l); g.vertexAttribPointer(l, n, g.FLOAT, false, F * 4, off) }\n      at('aP', 3, 0); at('aN', 3, 12); at('aUV', 2, 24); at('aL', 3, 32)\n      g.bindVertexArray(null)\n      o.tex = g.createTexture(); g.bindTexture(g.TEXTURE_2D_ARRAY, o.tex)\n      g.texImage3D(g.TEXTURE_2D_ARRAY, 0, g.RGBA8, 64, 64, layers, 0, g.RGBA, g.UNSIGNED_BYTE, px)\n      g.texParameteri(g.TEXTURE_2D_ARRAY, g.TEXTURE_MIN_FILTER, g.LINEAR); g.texParameteri(g.TEXTURE_2D_ARRAY, g.TEXTURE_MAG_FILTER, g.LINEAR)\n      g.texParameteri(g.TEXTURE_2D_ARRAY, g.TEXTURE_WRAP_S, g.REPEAT); g.texParameteri(g.TEXTURE_2D_ARRAY, g.TEXTURE_WRAP_T, g.REPEAT)\n      pk.models.set(m, o)\n      return o\n    }\n    const pilotsFrame = (now) => {\n      if (!dlgs.pilot.classList.contains('show') || !alive) { pk.raf = 0; return }\n      pk.raf = requestAnimationFrame(pilotsFrame)\n      const t = (now - pk.t0) / 1000\n      const dpr = Math.min(2, window.devicePixelRatio || 1)\n      // the picked pilot talks: the game's two frames, the mouth flapping at its pace\n      if (now >= pk.talkAt) { pk.talk = pk.talk ? 0 : 1; pk.talkAt = now + (pk.talk ? 90 + Math.random() * 90 : 70 + Math.random() * 160) }\n      for (const cv of pickPilotEl.querySelectorAll('canvas[data-face]')) {\n        const w = Math.round(cv.clientWidth * dpr)\n        if (!w || !pk.faces) continue\n        if (cv.width !== w) { cv.width = w; cv.height = w }\n        const on = cv.closest('.pick').classList.contains('on'), i = Number(cv.dataset.face)\n        const f = on ? pk.talk : 0\n        if (cv._f === f && cv._w === w) continue\n        cv._f = f; cv._w = w\n        const cx = cv.getContext('2d'); cx.imageSmoothingEnabled = false\n        cx.clearRect(0, 0, w, w); cx.drawImage(pk.faces, i * 44 + 1, f * 44 + 1, 42, 42, 0, 0, w, w) // (inside the game's 1px frame)\n      }\n      // the ships: one canvas over the list, each drawn in its card's slot\n      const g = pkGL()\n      if (!g) return\n      const cv = g.canvas, cr = cv.getBoundingClientRect()\n      const W = Math.round(cv.clientWidth * dpr), H = Math.round(cv.clientHeight * dpr)\n      if (!W || !H) return\n      if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H }\n      const k = W / Math.max(1, cr.width) // (the box scales as it opens: map through its rect)\n      g.disable(g.SCISSOR_TEST); g.viewport(0, 0, W, H); g.clearColor(0, 0, 0, 0); g.clear(g.COLOR_BUFFER_BIT | g.DEPTH_BUFFER_BIT)\n      const arw = pkModel(g, ship), wlf = pkModel(g, wolf)\n      if (!arw) return\n      g.enable(g.SCISSOR_TEST)\n      for (const el of pickPilotEl.querySelectorAll('[data-ship]')) {\n        const r = el.getBoundingClientRect()\n        if (r.bottom < cr.top || r.top > cr.bottom || !r.width) continue\n        const x = Math.round((r.left - cr.left) * k), y = Math.round((cr.bottom - r.bottom) * k), w = Math.round(r.width * k), h = Math.round(r.height * k)\n        g.viewport(x, y, w, h); g.scissor(x, y, w, h); g.clear(g.DEPTH_BUFFER_BIT)\n        const card = el.closest('.pick'), i = Number(card.style.getPropertyValue('--k')) || 0\n        const isWolf = el.dataset.ship === 'wolf', m = isWolf ? (wlf || arw) : arw\n        const tint = isWolf && !wlf ? [1.3, 0.52, 0.46] : [1, 1, 1]\n        const on = card.classList.contains('on'), off = card.disabled\n        const tin = ease((t - i * 0.045 - 0.12) / 0.7) // its arrival: grows in, spinning down to its pace\n        const yaw = t * (on ? 1.1 : 0.55) + i * 0.8 + (1 - tin) * 5\n        const bob = Math.sin(t * 1.6 + i) * 0.06\n        const M = mul(trans(0, bob - 0.02, -3.3), mul(rotX(0.42), mul(rotY(yaw), mul(rotZ(Math.sin(t * 1.1 + i) * 0.12), scl(0.35 + 0.65 * tin)))))\n        const P = persp(0.62, w / h, 0.1, 50), a = (off ? 0.35 : 1) * Math.min(1, tin * 1.5)\n        g.enable(g.DEPTH_TEST); g.depthMask(true); g.disable(g.BLEND)\n        g.useProgram(pk.prog.p)\n        g.uniformMatrix4fv(pk.prog.u.uVP, false, P); g.uniformMatrix4fv(pk.prog.u.uM, false, M)\n        g.activeTexture(g.TEXTURE1); g.bindTexture(g.TEXTURE_2D_ARRAY, m.tex); g.uniform1i(pk.prog.u.uTex, 1)\n        g.uniform1f(pk.prog.u.uFade, a); g.uniform3f(pk.prog.u.uEye, 0, 0, 0); g.uniform3f(pk.prog.u.uTint, tint[0], tint[1], tint[2])\n        g.bindVertexArray(m.vao)\n        g.uniform1f(pk.prog.u.uAlphaPass, 0); g.drawArrays(g.TRIANGLES, 0, m.count)\n        g.enable(g.BLEND); g.blendFuncSeparate(g.SRC_ALPHA, g.ONE_MINUS_SRC_ALPHA, g.ONE, g.ONE_MINUS_SRC_ALPHA); g.depthMask(false)\n        const gw = m.glow, c = xf(M, gw.c), s = 0.35 + 0.65 * tin, flick = Math.floor(t * 30) % 2 ? 0.9 : 1\n        g.useProgram(pk.glow.p)\n        g.uniformMatrix4fv(pk.glow.u.uP, false, P); g.uniform3f(pk.glow.u.uCenter, c[0], c[1], c[2])\n        g.uniform2f(pk.glow.u.uHalf, gw.hw * s * flick, gw.hh * s * flick); g.uniform1f(pk.glow.u.uRoll, 0)\n        g.uniform1f(pk.glow.u.uLayer, gw.layer); g.uniform1f(pk.glow.u.uFade, a); g.uniform1i(pk.glow.u.uTex, 1)\n        g.bindVertexArray(pk.quad); g.drawArrays(g.TRIANGLES, 0, 6)\n        g.useProgram(pk.prog.p); g.bindVertexArray(m.vao)\n        g.uniform1f(pk.prog.u.uAlphaPass, 1); g.drawArrays(g.TRIANGLES, 0, m.count)\n        g.depthMask(true)\n      }\n      g.disable(g.SCISSOR_TEST); g.bindVertexArray(null)\n    }\n    const pickVsStage = $('[data-pick-vsstage]'), pickVsMatch = $('[data-pick-vsmatch]')\n    const fillBattle = () => {\n      const L = room.lobby, host = amHost()\n      pickVsStage.innerHTML = VS_STAGES.map((n, i) => `<button type=\"button\" class=\"pick ${i === vsStageOf(L) ? 'on' : ''}\" data-vsstage=\"${i}\" ${host ? '' : 'disabled'}>${esc(n.toUpperCase())}<small>VERSUS MAP</small></button>`).join('')\n      pickVsMatch.innerHTML = VS_MATCHES.map((n, i) => `<button type=\"button\" class=\"pick ${i === vsMatchOf(L) ? 'on' : ''}\" data-vsmatch=\"${i}\" ${host ? '' : 'disabled'}>${esc(n.toUpperCase())}<small>${VS_ROUNDS[i]} ROUND POINTS WIN</small></button>`).join('')\n    }\n    on(pickVsStage, 'click', (e) => { const b = e.target.closest && e.target.closest('[data-vsstage]'); if (b && !b.disabled) { closeDlg(); pickBattle({ vsStage: Number(b.dataset.vsstage) }) } })\n    on(pickVsMatch, 'click', (e) => { const b = e.target.closest && e.target.closest('[data-vsmatch]'); if (b && !b.disabled) { closeDlg(); pickBattle({ vsMatch: Number(b.dataset.vsmatch) }) } })\n    // the host's two buttons (top right): the stage, the rules (how many round points win)\n    const openBattle = (part) => {\n      if (!amHost()) return toast('THE HOST PICKS THE BATTLE')\n      dlgs.battle.dataset.part = part\n      setText($('[data-battle-title]'), part === 'stage' ? 'STAGE' : 'RULES')\n      openDlg('battle')\n    }\n    on($('.hg-opts'), 'click', (e) => { const b = e.target.closest && e.target.closest('[data-opt]'); if (b && cur === 'room' && !tr) openBattle(b.dataset.opt) })\n    on(pickMission, 'click', (e) => {\n      const b = e.target.closest && e.target.closest('[data-stage]')\n      if (!b) return\n      const id = Number(b.dataset.stage)\n      closeDlg()\n      if (cur === 'solo') { try { localStorage.setItem(STAGE_KEY, String(id)) } catch (err) {} paintHangar() }\n      else if (cur === 'room') pickRoomStage(id)\n    })\n    on(pickPilotEl, 'click', (e) => {\n      const b = e.target.closest && e.target.closest('[data-pilot]')\n      if (!b || b.disabled) return\n      closeDlg()\n      pickPilot(b.dataset.pilot)\n    })\n\n    // ---- the controls window: the three schemes, each with a drawing and what every input does ----------\n    // Picking a tab picks the scheme (`${STORE}.controls`, read by the player); the drawing is the game's\n    // own on-screen layout (TFSF_Player_DEV's buttons and chips) or the keyboard and mouse.\n    const CTL = {\n      motion: { name: 'MOTION', note: 'TILT THE PHONE TO FLY · NEEDS A GYROSCOPE', rows: [\n        ['TILT ◀ ▶', 'BANK AND TURN · ROLL THE PHONE LIKE A WHEEL'], ['TIP ▲ ▼', 'DIVE AND CLIMB · TIP ITS TOP EDGE AWAY OR TOWARD YOU'],\n        ['TURN ⟲', 'TURN · SWING THE PHONE LIKE A TRAY'], ['A', 'FIRE LASERS · HOLD TO CHARGE A LOCK-ON'], ['B', 'SMART BOMB'],\n        ['Z · R', 'TILT THE WINGS · DOUBLE TAP FOR A BARREL ROLL'], ['BOOST', 'BOOST'], ['BRAKE', 'BRAKE'],\n        ['SWIPE ▲', 'SOMERSAULT · ON THE RIGHT HALF'], ['SWIPE ▼', 'U-TURN · IN ALL-RANGE MODE'], ['CENTER', 'THE WAY YOU HOLD IT NOW IS STRAIGHT AHEAD'],\n        ['TILT', 'SENSITIVITY · LOW, MED, HIGH'], ['GYRO', 'OFF: A TOUCH STICK STEERS INSTEAD'], ['START', 'PAUSE'],\n      ] },\n      touch: { name: 'TOUCH', note: 'A STICK UNDER YOUR LEFT THUMB · NO TILTING', rows: [\n        ['DRAG ◀ ▶', 'BANK AND TURN · THE STICK APPEARS WHERE YOUR THUMB LANDS'], ['DRAG ▲ ▼', 'CLIMB AND DIVE'],\n        ['A', 'FIRE LASERS · HOLD TO CHARGE A LOCK-ON'], ['B', 'SMART BOMB'], ['Z · R', 'TILT THE WINGS · DOUBLE TAP FOR A BARREL ROLL'],\n        ['BOOST', 'BOOST'], ['BRAKE', 'BRAKE'], ['SWIPE ▲', 'SOMERSAULT · ON THE RIGHT HALF'], ['SWIPE ▼', 'U-TURN · IN ALL-RANGE MODE'],\n        ['HIDE', 'HIDE THE BUTTONS'], ['START', 'PAUSE'],\n      ] },\n      mouse: { name: 'KEYBOARD & MOUSE', note: 'FOR A COMPUTER', rows: [\n        ['W A S D', 'STEER · W CLIMBS, S DIVES · OR THE ARROW KEYS'], ['LEFT CLICK', 'FIRE LASERS · HOLD TO CHARGE A LOCK-ON'], ['RIGHT CLICK', 'SMART BOMB'],\n        ['SPACE', 'BOOST'], ['SHIFT', 'BRAKE'], ['Q · E', 'BANK LEFT, RIGHT · DOUBLE TAP FOR A BARREL ROLL'], ['X', 'SOMERSAULT'],\n        ['C', 'U-TURN · IN ALL-RANGE MODE'], ['ENTER', 'START · PAUSE'], ['V', 'HOLD TO TALK ON THE RADIO'], ['ESC', 'THE GAME\\'S MENU'],\n      ] },\n    }\n    // the phone, as the player lays out its buttons (landscape), and what each scheme adds to it\n    const svgPhone = (over) => `<svg viewBox=\"0 0 520 300\" class=\"cl-svg\" aria-hidden=\"true\">\n      <defs><marker id=\"clar\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0,0 L10,5 L0,10 z\" fill=\"#00dbff\"/></marker></defs>\n      <rect x=\"60\" y=\"50\" width=\"400\" height=\"200\" rx=\"30\" class=\"sv-frame\"/><rect x=\"78\" y=\"62\" width=\"364\" height=\"176\" rx=\"12\" class=\"sv-scr\"/>\n      ${over.under || ''}\n      ${['GYRO', 'TILT', 'CENTER', 'HIDE', 'MENU'].map((c, i) => `<rect x=\"${262 + i * 35}\" y=\"70\" width=\"31\" height=\"12\" rx=\"6\" class=\"sv-btn ${over.chips && over.chips.includes(c) ? 'hi' : ''}\"/><text x=\"${277.5 + i * 35}\" y=\"78.6\" class=\"sv-t s\">${c}</text>`).join('')}\n      <rect x=\"94\" y=\"116\" width=\"44\" height=\"18\" rx=\"9\" class=\"sv-btn\"/><text x=\"116\" y=\"128.5\" class=\"sv-t\">Z</text>\n      <circle cx=\"112\" cy=\"162\" r=\"18\" class=\"sv-btn\"/><text x=\"112\" y=\"165\" class=\"sv-t s\">BOOST</text>\n      <circle cx=\"112\" cy=\"205\" r=\"18\" class=\"sv-btn\"/><text x=\"112\" y=\"208\" class=\"sv-t s\">BRAKE</text>\n      <rect x=\"382\" y=\"140\" width=\"44\" height=\"18\" rx=\"9\" class=\"sv-btn\"/><text x=\"404\" y=\"152.5\" class=\"sv-t\">R</text>\n      <circle cx=\"372\" cy=\"206\" r=\"15\" class=\"sv-btn\"/><text x=\"372\" y=\"210\" class=\"sv-t\">B</text>\n      <circle cx=\"414\" cy=\"194\" r=\"21\" class=\"sv-btn hi\"/><text x=\"414\" y=\"198.5\" class=\"sv-t\">A</text>\n      <rect x=\"232\" y=\"214\" width=\"56\" height=\"16\" rx=\"8\" class=\"sv-btn\"/><text x=\"260\" y=\"225\" class=\"sv-t s\">START</text>\n      ${over.over || ''}\n    </svg>`\n    const ART = {\n      // motion: the three axes the phone turns on, each seen from where its turn reads best (a dot: the pivot)\n      motion: () => {\n        const ph = (x, y, w, h, r, rot, cls) => `<rect x=\"${x - w / 2}\" y=\"${y - h / 2}\" width=\"${w}\" height=\"${h}\" rx=\"${r}\" class=\"${cls}\" transform=\"rotate(${rot} ${x} ${y})\"/>`\n        const you = (x, y) => `<circle cx=\"${x}\" cy=\"${y}\" r=\"7\" class=\"sv-you\"/><circle cx=\"${x}\" cy=\"${y}\" r=\"2.5\" class=\"sv-knob\"/><text x=\"${x}\" y=\"${y + 22}\" class=\"sv-ct s\">YOU</text>`\n        const pivot = (x, y) => `<circle cx=\"${x}\" cy=\"${y}\" r=\"4\" class=\"sv-knob\"/>`\n        const panel = (x, n, axis, view) => `<text x=\"${x}\" y=\"20\" class=\"sv-h\">${n} · ${axis}</text><text x=\"${x}\" y=\"36\" class=\"sv-ct s\">${view}</text>`\n        const res = (x, a, b) => `<text x=\"${x}\" y=\"276\" class=\"sv-ct\">${a}</text><text x=\"${x}\" y=\"294\" class=\"sv-ct\">${b}</text>`\n        return `<svg viewBox=\"0 0 600 310\" class=\"cl-svg\" aria-hidden=\"true\">\n          <defs><marker id=\"clar\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0,0 L10,5 L0,10 z\" fill=\"#00dbff\"/></marker></defs>\n          <path d=\"M200,10 L200,300 M400,10 L400,300\" class=\"sv-sep\"/>\n          ${panel(100, 1, 'ROLL', 'FACING YOU')}\n          ${ph(100, 140, 120, 66, 11, -24, 'sv-ghost')}${ph(100, 140, 120, 66, 11, 24, 'sv-ghost')}\n          ${ph(100, 140, 120, 66, 11, 0, 'sv-frame')}<rect x=\"50\" y=\"116\" width=\"100\" height=\"48\" rx=\"5\" class=\"sv-scr\"/>${pivot(100, 140)}\n          <path d=\"M44,83 A80,80 0 0 1 156,83\" class=\"sv-a\" marker-start=\"url(#clar)\" marker-end=\"url(#clar)\"/>\n          <text x=\"40\" y=\"64\" class=\"sv-ct s\">◀ LEFT</text><text x=\"160\" y=\"64\" class=\"sv-ct s\">RIGHT ▶</text>\n          <text x=\"100\" y=\"222\" class=\"sv-ct s\">TURN IT LIKE A</text><text x=\"100\" y=\"236\" class=\"sv-ct s\">STEERING WHEEL</text>\n          ${res(100, 'THE SHIP BANKS', 'AND TURNS THAT WAY')}\n          ${panel(300, 2, 'PITCH', 'FROM THE SIDE')}\n          ${you(236, 140)}\n          ${ph(305, 140, 12, 100, 4, -26, 'sv-ghost')}${ph(305, 140, 12, 100, 4, 26, 'sv-ghost')}\n          ${ph(305, 140, 12, 100, 4, 0, 'sv-frame')}${pivot(305, 140)}\n          <path d=\"M276,82 A64,64 0 0 1 334,82\" class=\"sv-a\" marker-start=\"url(#clar)\" marker-end=\"url(#clar)\"/>\n          <text x=\"268\" y=\"64\" class=\"sv-ct s\">TOWARD</text><text x=\"344\" y=\"64\" class=\"sv-ct s\">AWAY</text>\n          <text x=\"300\" y=\"222\" class=\"sv-ct s\">TIP THE TOP EDGE</text><text x=\"300\" y=\"236\" class=\"sv-ct s\">TOWARD OR AWAY</text>\n          ${res(300, 'TOWARD: CLIMB ▲', 'AWAY: DIVE ▼')}\n          ${panel(500, 3, 'YAW', 'FROM ABOVE')}\n          ${ph(500, 128, 120, 12, 4, -24, 'sv-ghost')}${ph(500, 128, 120, 12, 4, 24, 'sv-ghost')}\n          ${ph(500, 128, 120, 12, 4, 0, 'sv-frame')}${pivot(500, 128)}\n          <path d=\"M444,88 A72,72 0 0 1 556,88\" class=\"sv-a\" marker-start=\"url(#clar)\" marker-end=\"url(#clar)\"/>\n          <text x=\"440\" y=\"64\" class=\"sv-ct s\">◀ LEFT</text><text x=\"560\" y=\"64\" class=\"sv-ct s\">RIGHT ▶</text>\n          ${you(500, 178)}\n          <text x=\"500\" y=\"222\" class=\"sv-ct s\">SWING IT FLAT,</text><text x=\"500\" y=\"236\" class=\"sv-ct s\">LIKE A TRAY</text>\n          ${res(500, 'THE SHIP TURNS', 'THAT WAY')}\n        </svg>`\n      },\n      touch: () => svgPhone({ chips: ['HIDE'], under: `\n        <rect x=\"150\" y=\"96\" width=\"104\" height=\"112\" rx=\"10\" class=\"sv-zone\"/><text x=\"202\" y=\"110\" class=\"sv-ct s\">LEFT HALF</text>\n        <rect x=\"282\" y=\"88\" width=\"80\" height=\"96\" rx=\"10\" class=\"sv-zone\"/><text x=\"322\" y=\"101\" class=\"sv-ct s\">RIGHT HALF</text>`, over: `\n        <circle cx=\"202\" cy=\"156\" r=\"28\" class=\"sv-stick\"/><circle cx=\"215\" cy=\"143\" r=\"10\" class=\"sv-knob\"/>\n        <path d=\"M202,156 L213,145\" class=\"sv-a\" marker-end=\"url(#clar)\"/><text x=\"202\" y=\"200\" class=\"sv-ct s\">DRAG: STEER</text>\n        <path d=\"M306,170 L306,122\" class=\"sv-a\" marker-end=\"url(#clar)\"/><text x=\"306\" y=\"115\" class=\"sv-ct s\">▲ LOOP</text>\n        <path d=\"M338,122 L338,166\" class=\"sv-a\" marker-end=\"url(#clar)\"/><text x=\"338\" y=\"178\" class=\"sv-ct s\">▼ U-TURN</text>` }),\n      mouse: () => {\n        const key = (x, y, w, t, g) => `<rect x=\"${x}\" y=\"${y}\" width=\"${w}\" height=\"30\" rx=\"6\" class=\"sv-key ${g || ''}\"/><text x=\"${x + w / 2}\" y=\"${y + 19}\" class=\"sv-t\">${t}</text>`\n        return `<svg viewBox=\"0 0 520 300\" class=\"cl-svg\" aria-hidden=\"true\">\n          ${key(20, 30, 44, 'ESC', 'sys')}\n          ${key(64, 84, 32, 'Q', 'bank')}${key(100, 84, 32, 'W', 'steer')}${key(136, 84, 32, 'E', 'bank')}\n          ${key(72, 118, 32, 'A', 'steer')}${key(108, 118, 32, 'S', 'steer')}${key(144, 118, 32, 'D', 'steer')}\n          ${key(16, 152, 70, 'SHIFT', 'speed')}${key(90, 152, 32, 'X', 'move')}${key(126, 152, 32, 'C', 'move')}${key(162, 152, 32, 'V', 'sys')}\n          ${key(90, 186, 150, 'SPACE', 'speed')}\n          ${key(250, 84, 62, 'ENTER', 'sys')}\n          ${key(286, 152, 32, '▲', 'steer')}${key(250, 186, 32, '◀', 'steer')}${key(286, 186, 32, '▼', 'steer')}${key(322, 186, 32, '▶', 'steer')}\n          <rect x=\"400\" y=\"60\" width=\"84\" height=\"132\" rx=\"42\" class=\"sv-key\"/><path d=\"M442,60 L442,112 M400,112 L484,112\" class=\"sv-line\"/>\n          <path d=\"M442,60 A42,42 0 0 0 400,102 L400,112 L442,112 Z\" class=\"sv-key hi\"/><path d=\"M442,60 A42,42 0 0 1 484,102 L484,112 L442,112 Z\" class=\"sv-key bomb\"/>\n          <text x=\"420\" y=\"40\" class=\"sv-ct\">LASER</text><text x=\"470\" y=\"40\" class=\"sv-ct\">BOMB</text>\n          <path d=\"M420,46 L421,74\" class=\"sv-a\" marker-end=\"url(#clar)\"/><path d=\"M470,46 L465,74\" class=\"sv-a\" marker-end=\"url(#clar)\"/>\n          <g class=\"sv-legend\"><rect x=\"16\" y=\"244\" width=\"10\" height=\"10\" class=\"sv-key steer\"/><text x=\"32\" y=\"253\">STEER</text>\n            <rect x=\"96\" y=\"244\" width=\"10\" height=\"10\" class=\"sv-key speed\"/><text x=\"112\" y=\"253\">SPEED</text>\n            <rect x=\"176\" y=\"244\" width=\"10\" height=\"10\" class=\"sv-key bank\"/><text x=\"192\" y=\"253\">BANK · ROLL</text>\n            <rect x=\"290\" y=\"244\" width=\"10\" height=\"10\" class=\"sv-key move\"/><text x=\"306\" y=\"253\">MANEUVERS</text>\n            <rect x=\"410\" y=\"244\" width=\"10\" height=\"10\" class=\"sv-key sys\"/><text x=\"426\" y=\"253\">GAME · RADIO</text></g>\n          <defs><marker id=\"clar\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0,0 L10,5 L0,10 z\" fill=\"#00dbff\"/></marker></defs>\n        </svg>`\n      },\n    }\n    let ctlShown = ''\n    const fillControls = (scheme) => {\n      const sc = CTL[scheme] ? scheme : controlsId()\n      ctlShown = sc\n      const now = controlsId()\n      $('[data-cl-tabs]').innerHTML = Object.entries(CTL).map(([id, c]) => `<button type=\"button\" class=\"pick ${id === sc ? 'on' : ''}\" data-scheme=\"${id}\">${c.name}<small>${id === now ? '● IN USE' : 'TAP TO USE'}</small></button>`).join('')\n      $('[data-cl-art]').innerHTML = ART[sc]()\n      $('[data-cl-list]').innerHTML = CTL[sc].rows.map(([k, v]) => `<div class=\"cl-row\"><span class=\"cl-k\">${esc(k).split(' · ').map((x) => `<kbd>${x}</kbd>`).join('')}</span><span class=\"cl-v\">${esc(v)}</span></div>`).join('')\n      setText($('[data-cl-note]'), CTL[sc].note)\n    }\n    on(dlgs.controls, 'click', (e) => {\n      const b = e.target.closest && e.target.closest('[data-scheme]')\n      if (!b) return\n      const id = b.dataset.scheme\n      if (id !== controlsId()) {\n        try { localStorage.setItem(CONTROLS_KEY, id) } catch (err) {}\n        paintHangar()\n      }\n      fillControls(id)\n    })\n\n    // ---- painting -------------------------------------------------------------------------------------\n    const hg = { slots: [], world: [], at: -10, alert: '' }\n    const slotEls = qa('.hg-slots .slotc'), seatEls = qa('.hg-arena .slotc')\n    const hangarEls = () => (cur === 'room' && isArena() ? seatEls : slotEls)\n    on($('.hg-arena'), 'click', (e) => { const c = e.target.closest && e.target.closest('[data-seat]'); if (c && cur === 'room' && !tr) tapSeat(c.dataset.seat) })\n    const setText = (el, v) => { if (el.textContent !== v) el.textContent = v }\n    const ALERTS = {\n      go: { word: 'DEPART', sep: '▶▶', k1: '発進準備完了', k2: '全機発進', c1: 'LAUNCH SEQUENCE', c2: 'ALL WINGS READY' },\n      wait: { word: 'AWAITING DEPART', sep: '▶▶', k1: '発進待機', k2: '発進待機', c1: 'STAND BY', c2: 'HOST HAS THE STICK' },\n      link: { word: 'LINKING PILOTS', sep: '▶▶', k1: '通信接続中', k2: '発進待機', c1: 'COMMS', c2: 'HOLD POSITION' },\n    }\n    const setAlert = (mode) => {\n      if (hg.alert === mode) return\n      hg.alert = mode\n      const el = $('[data-alert]')\n      el.classList.remove('on', 'go', 'wait')\n      if (!mode) return\n      const a = ALERTS[mode]\n      setText($('[data-al-kj1]'), a.k1); setText($('[data-al-kj2]'), a.k2); setText($('[data-al-c1]'), a.c1); setText($('[data-al-c2]'), a.c2)\n      void el.offsetWidth\n      el.classList.add('on', mode === 'go' ? 'go' : 'wait')\n      runWords()\n    }\n    // N blocks (\"WORD ▶▶\") one block apart, each crossing the band right to left and coming round again\n    // off screen; N blocks span the band plus one, so the line never shows a gap.\n    const runWords = () => {\n      const mode = hg.alert, track = $('[data-al-track]')\n      for (const it of track.children) for (const an of it.getAnimations ? it.getAnimations() : []) an.cancel()\n      if (!mode) { track.innerHTML = ''; return }\n      const a = ALERTS[mode]\n      const block = `<i class=\"al-it\"><span>${a.word}</span><em>${a.sep}</em></i>`\n      track.innerHTML = block\n      const W = track.offsetWidth, iw = track.firstElementChild.offsetWidth\n      if (!W || !iw) return\n      const n = Math.ceil((W + iw) / iw), D = n * iw\n      track.innerHTML = block.repeat(n)\n      const T = D / (mode === 'go' ? 230 : 150) * 1000 // ms for one round, at a steady speed in stage px/s\n      Array.from(track.children).forEach((it, k) => {\n        if (!it.animate) return\n        it.animate([{ transform: `translate3d(${W}px,0,0)` }, { transform: `translate3d(${W - D}px,0,0)` }],\n          { duration: T, iterations: Infinity, easing: 'linear', delay: -(k * T) / n })\n      })\n    }\n    const item = (scr, id) => $(`.menuwrap[data-scr=\"${scr}\"] [data-item=\"${id}\"]`)\n    const paintHangar = () => {\n      if (!hangarOn()) return\n      const solo = cur === 'solo'\n      let slots, alert = '', mission, kind, title\n      const ctl = controlsLabel().toUpperCase()\n      if (solo) {\n        slots = TEAM.map((m, i) => ({ pilot: m.pilot, name: m.cpu ? 'COMPUTER' : myName().toUpperCase(), tag: m.cpu ? `CPU ${i}` : 'P1 · YOU', you: !m.cpu,\n          ready: m.cpu || soloReady, state: m.cpu || soloReady ? 'ready' : 'idle', note: m.cpu ? 'WINGMAN · ALWAYS READY' : `CONTROLS · ${ctl}` }))\n        if (soloReady) alert = 'go'\n        const st = stageOf(soloStage())\n        mission = st.name.toUpperCase(); kind = st.kind.toUpperCase(); title = SCREENS.solo.title\n        const rd = item('solo', 'sready')\n        setText(rd.querySelector('.lbl'), soloReady ? 'Unready' : 'Ready'); rd.classList.toggle('armed', soloReady)\n        setText(item('solo', 'smission').querySelector('.code'), st.id < 0 ? 'FULL' : 'STAGE')\n        setText(item('solo', 'scontrols').querySelector('.code'), ctl.split(' ')[0])\n      } else if (isArena()) {\n        const L = room.lobby\n        const pilots = room.gone || room.joining ? [] : livePilots()\n        const myEmail = me()\n        const bySeat = {}\n        for (const p of pilots) { const st = seatOf(p); bySeat[`${st.team}:${st.seat}`] = p }\n        const asked = room.mine && room.mine.swapTo\n        slots = []\n        for (const tm of TEAMS) for (let n = 0; n < 4; n++) {\n          const p = bySeat[`${tm.id}:${n}`]\n          const tag = `${tm.id.toUpperCase()} ${n + 1}`\n          if (!p) { slots.push({ open: true, state: 'open', team: tm.id, tag, pilot: '', name: room.gone || !room.mine ? '' : 'TAP TO TAKE THIS SEAT', note: '', title: 'OPEN' }); continue }\n          const mine = p.email === myEmail\n          const awayFor = mine ? 0 : serverNow() - stampOf(p)\n          const away = awayFor > AWAY_MS\n          const link = away ? null : linkOf(p.email)\n          const isAsked = !!(asked && asked === p.email)\n          slots.push({ pilot: p.pilot || 'fox', team: tm.id, flip: tm.id === 'red', name: (p.name || nameOf(p.email)).toUpperCase(), tag: `${tag}${mine ? ' · YOU' : ''}`, you: mine,\n            host: !!(L && p.email === L.host), ready: !!p.ready, state: away ? 'away' : p.ready ? 'ready' : 'idle', asked: isAsked, swap: !mine && !!room.mine && !isAsked && !away,\n            note: away ? `DISCONNECTED · OUT IN ${Math.max(0, Math.ceil((LIVE_MS - awayFor) / 1000))} S` : isAsked ? 'ASKED TO SWAP…' : mine && !net.isHost() ? linkLabel(link) : mine ? `CONTROLS · ${ctl}` : linkLabel(link) })\n        }\n        for (const tm of TEAMS) setText($(`[data-ar-count=\"${tm.id}\"]`), `${pilots.filter((p) => seatOf(p).team === tm.id).length} / 4`)\n        const allReady = pilots.length > 1 && pilots.every((p) => p.ready)\n        if (allReady && pilots.length <= ARENA_MAX) alert = !allLinked() ? 'link' : amHost() ? 'go' : 'wait'\n        mission = room.gone || VS_STAGES[vsStageOf(L)].toUpperCase()\n        kind = room.gone ? '' : `${VS_MATCHES[vsMatchOf(L)].toUpperCase()}${pilots.length < 2 ? ' · NEEDS 2 PILOTS' : amHost() ? '' : ' · HOST\\'S PICK'}`\n        title = L ? String(L.name || `LOBBY ${L.code || ''}`).toUpperCase() : room.gone ? 'LOBBY' : 'JOINING…'\n        const mineReady = !!(room.mine && room.mine.ready)\n        const rd = item('room', 'rready')\n        setText(rd.querySelector('.lbl'), mineReady ? 'Unready' : 'Ready'); rd.classList.toggle('armed', mineReady)\n        rd.classList.toggle('dim', !room.mine)\n        item('room', 'rpilot').classList.toggle('dim', !room.mine)\n        setText(item('room', 'rpilot').querySelector('.code'), room.mine ? pilotName(room.mine.pilot).toUpperCase() : 'PLT')\n        item('room', 'rmission').classList.add('gone')\n        setText($('[data-opt-stage]'), VS_STAGES[vsStageOf(L)].toUpperCase())\n        setText($('[data-opt-rules]'), VS_MATCHES[vsMatchOf(L)].toUpperCase())\n        setText(item('room', 'rcontrols').querySelector('.code'), ctl.split(' ')[0])\n        // a swap asked of this pilot: the prompt (once per request)\n        const req = room.mine && !room.gone ? requestsForMe().find((r) => !swapSeen.has(`${r.email}:${r.swapAt}`)) : null\n        if (req && dlgs.swap.dataset.from !== req.email && !dlgOpen()) {\n          const st = seatOf(req)\n          dlgs.swap.dataset.from = req.email\n          $('[data-sw-msg]').innerHTML = `<b>${esc(pilotName(req.pilot).toUpperCase())}</b> · ${esc(String(req.name || nameOf(req.email)).toUpperCase())}<br>WANTS YOUR SEAT. YOU WOULD TAKE THEIRS: <b>${st.team.toUpperCase()} ${st.seat + 1}</b>`\n          openDlg('swap')\n        } else if (!req && dlgs.swap.dataset.from && dlgs.swap.classList.contains('show')) { dlgs.swap.dataset.from = ''; closeDlg() }\n      } else {\n        const L = room.lobby\n        const pilots = room.gone || room.joining ? [] : livePilots()\n        const myEmail = me()\n        slots = [0, 1, 2, 3].map((i) => {\n          const p = pilots[i]\n          if (!p) return { open: true, state: 'open', tag: `P${i + 1}`, pilot: '', name: '', note: '', title: room.joining && i === 0 ? 'JOINING…' : room.gone ? '—' : 'OPEN' }\n          const mine = p.email === myEmail\n          const awayFor = mine ? 0 : serverNow() - stampOf(p)\n          const away = awayFor > AWAY_MS\n          const link = away ? null : linkOf(p.email)\n          return { pilot: p.pilot || 'fox', name: (p.name || nameOf(p.email)).toUpperCase(), tag: `P${i + 1}${mine ? ' · YOU' : ''}`, you: mine,\n            host: !!(L && p.email === L.host), ready: !!p.ready, state: away ? 'away' : p.ready ? 'ready' : 'idle',\n            note: away ? `DISCONNECTED · OUT IN ${Math.max(0, Math.ceil((LIVE_MS - awayFor) / 1000))} S` : mine && !net.isHost() ? linkLabel(link) : mine ? `CONTROLS · ${ctl}` : linkLabel(link) }\n        })\n        const allReady = pilots.length > 0 && pilots.every((p) => p.ready)\n        if (allReady) alert = !allLinked() ? 'link' : amHost() ? 'go' : 'wait'\n        const st = stageOf(roomStage())\n        mission = room.gone || (L && L.campaign ? String(L.campaign).toUpperCase() : st.name.toUpperCase())\n        kind = room.gone ? '' : `${st.kind.toUpperCase()}${amHost() ? '' : ' · HOST\\'S PICK'}`\n        title = L ? String(L.name || `LOBBY ${L.code || ''}`).toUpperCase() : room.gone ? 'LOBBY' : 'JOINING…'\n        const mineReady = !!(room.mine && room.mine.ready)\n        const rd = item('room', 'rready')\n        setText(rd.querySelector('.lbl'), mineReady ? 'Unready' : 'Ready'); rd.classList.toggle('armed', mineReady)\n        rd.classList.toggle('dim', !room.mine)\n        item('room', 'rpilot').classList.toggle('dim', !room.mine)\n        setText(item('room', 'rpilot').querySelector('.code'), room.mine ? pilotName(room.mine.pilot).toUpperCase() : 'PLT')\n        item('room', 'rmission').classList.toggle('dim', !canPickMission())\n        item('room', 'rmission').classList.remove('gone')\n        setText(item('room', 'rmission').querySelector('.code'), amHost() ? 'HOST' : 'LOCKED')\n        setText(item('room', 'rcontrols').querySelector('.code'), ctl.split(' ')[0])\n      }\n      const arenaNow = !solo && isArena()\n      const hgEl = $('.hangar')\n      if (hgEl.classList.contains('arena') !== arenaNow) { hgEl.classList.toggle('arena', arenaNow); hg.at = -10 }\n      hgEl.classList.toggle('host', arenaNow && amHost() && !room.gone)\n      hg.slots = slots\n      const t = $('[data-title]')\n      setText(t, title); t.classList.toggle('long', title.length > 13)\n      const mEl = $('.hg-mission')\n      mEl.classList.toggle('gone', !!(!solo && room.gone))\n      setText($('[data-hg-k]'), !solo && room.gone ? 'LOBBY' : arenaNow ? 'STAGE' : 'MISSION')\n      setText($('[data-hg-mission]'), mission); setText($('[data-hg-kind]'), kind)\n      const els = arenaNow ? seatEls : slotEls\n      slots.forEach((s, i) => {\n        const el = els[i]\n        if (!el) return\n        for (const c of ['open', 'ready', 'away', 'you']) el.classList.toggle(c, c === 'you' ? !!s.you : c === 'open' ? !!s.open : s.state === c)\n        if (arenaNow) { el.classList.toggle('asked', !!s.asked); el.classList.toggle('swap', !!s.swap); el.classList.toggle('self', !!s.you) }\n        setText(el.querySelector('[data-s-tag]'), s.tag)\n        el.querySelector('[data-s-host]').hidden = !s.host\n        setText(el.querySelector('[data-s-pilot]'), s.open ? s.title : pilotName(s.pilot).toUpperCase())\n        setText(el.querySelector('[data-s-name]'), s.open ? (arenaNow ? s.name : room.gone && !solo ? '' : 'WAITING FOR A PILOT') : s.name)\n        setText(el.querySelector('[data-s-link]'), s.note || '')\n        setText(el.querySelector('[data-s-state]'), s.open ? 'OPEN SLOT' : s.state === 'away' ? 'DISCONNECTED' : s.asked ? 'ASKED' : s.ready ? 'READY' : 'NOT READY')\n      })\n      setAlert(tr ? '' : alert)\n    }\n\n    // The slots' places in the scene: each card's top centre, at the hangar's depth, is its platform's\n    // centre; its width the platform's. The card's frame takes the platform's front edges (projected).\n    const HEX_TILT = 0.5    // how far the platforms lean toward the viewer (rad)\n    const SHIP_K = 0.95     // a ship's size, per platform radius\n    const hexPt = (A, rad, ang, lift = 0) => {\n      const lx = Math.cos(ang) * rad, lz = Math.sin(ang) * rad\n      return [A.x + lx, A.y - lz * Math.sin(HEX_TILT) + lift, A.z + lz * Math.cos(HEX_TILT)]\n    }\n    const measureHangar = () => {\n      if (!hangarOn()) { hg.world = []; return }\n      const box = mm.getBoundingClientRect()\n      const cssW = box.width, cssH = box.height\n      if (!cssW || !cssH) return\n      const aspect = cssW / cssH\n      const ps = POSES[cur], Z = (portrait ? ps.port : ps.land).z\n      const hH = -Z * Math.tan(0.31), hW = hH * aspect\n      const P = persp(0.62, aspect, 0.1, 300)\n      const s = stageBox.s || 1\n      const f = (v) => v.toFixed(1)\n      hg.world = hangarEls().map((el) => {\n        const rc = el.getBoundingClientRect()\n        if (!rc.width) return null\n        const left = rc.left - box.left, cy = rc.top - box.top\n        const A = { x: ((left + rc.width / 2) / cssW * 2 - 1) * hW, y: (1 - cy / cssH * 2) * hH, z: Z, r: rc.width / cssW * hW * (el.classList.contains('mini') ? 0.8 : 1) }\n        const toCard = (p) => { const c = xf(P, p); return [((c[0] / c[3] * 0.5 + 0.5) * cssW - left) / s, ((0.5 - c[1] / c[3] * 0.5) * cssH - cy) / s] }\n        const w = el.offsetWidth, h = el.offsetHeight\n        const nl = toCard(hexPt(A, A.r, Math.PI * 2 / 3)), nr = toCard(hexPt(A, A.r, Math.PI / 3))\n        const svg = el.querySelector('svg')\n        svg.setAttribute('viewBox', `0 0 ${w} ${h}`)\n        el.querySelector('.sf-fill').setAttribute('d', `M0,0 L${f(nl[0])},${f(nl[1])} L${f(nr[0])},${f(nr[1])} L${w},0 L${w},${h} L0,${h} Z`)\n        el.querySelector('.sf-edge').setAttribute('d', `M${w},0 L${w},${h} L0,${h} L0,0`)\n        el.style.setProperty('--dip', `${f(Math.max(nl[1], nr[1]))}px`)\n        return A\n      })\n    }\n    // the platform: a glow brighter toward the rim, the rim, a turning inner ring, a ring rising off it,\n    // and the projector's light up to the ship; x y z + brightness per vertex, as triangles\n    const hexMesh = (A, t, i, k) => {\n      const out = []\n      const v = (p, a) => { out.push(p[0], p[1], p[2], a) }\n      const r = A.r, S = Math.PI / 3\n      for (let e = 0; e < 6; e++) { v([A.x, A.y, A.z], 0.04 * k); v(hexPt(A, r, e * S), 0.2 * k); v(hexPt(A, r, (e + 1) * S), 0.2 * k) }\n      const ring = (rad, w, rot, lift, a) => {\n        for (let e = 0; e < 6; e++) {\n          const a0 = rot + e * S, a1 = a0 + S\n          const o0 = hexPt(A, rad, a0, lift), o1 = hexPt(A, rad, a1, lift), i0 = hexPt(A, rad - w, a0, lift), i1 = hexPt(A, rad - w, a1, lift)\n          v(o0, a); v(o1, a); v(i1, a); v(o0, a); v(i1, a); v(i0, a)\n        }\n      }\n      ring(r, r * 0.045, 0, 0, 0.95 * k)\n      ring(r * 0.68, r * 0.025, t * 0.25 + i, 0, 0.4 * k)\n      const fr = (t * 0.45 + i * 0.27) % 1\n      ring(r * (0.97 - 0.22 * fr), r * 0.02, 0, fr * r * 0.95, 0.55 * (1 - fr) * k)\n      const h = r * 1.2, x0 = A.x - r * 0.6, x1 = A.x + r * 0.6\n      v([x0, A.y, A.z], 0.13 * k); v([x1, A.y, A.z], 0.13 * k); v([x1, A.y + h, A.z], 0)\n      v([x0, A.y, A.z], 0.13 * k); v([x1, A.y + h, A.z], 0); v([x0, A.y + h, A.z], 0)\n      return new Float32Array(out)\n    }\n    const HEX_COL = { ready: [0.24, 1, 0.54], idle: [0, 0.86, 1], away: [1, 0.36, 0.3], open: [0.5, 0.62, 0.8], blue: [0.3, 0.64, 1], red: [1, 0.36, 0.34] }\n\n    // ---- corner buttons: fullscreen, sound ------------------------------------------------------\n    const fsBtn = $('.corner.fs')\n    const fsOn = () => !!(document.fullscreenElement || document.webkitFullscreenElement)\n    const paintFs = () => fsBtn.classList.toggle('full', fsOn())\n    if (document.fullscreenEnabled || document.webkitFullscreenEnabled) {\n      fsBtn.hidden = false\n      paintFs()\n      on(document, 'fullscreenchange', paintFs)\n      on(document, 'webkitfullscreenchange', paintFs)\n    }\n    on(fsBtn, 'click', async () => {\n      const el = document.documentElement\n      try {\n        if (fsOn()) await (document.exitFullscreen ? document.exitFullscreen() : document.webkitExitFullscreen())\n        else { await (el.requestFullscreen ? el.requestFullscreen({ navigationUI: 'hide' }) : el.webkitRequestFullscreen()); try { await screen.orientation?.lock?.('landscape') } catch (e) {} }\n      } catch (e) {}\n    })\n    // The sound button opens a small window: the Sound switch and the Master level, the same settings\n    // (tfsf-dev.volume) as /settings, applied at once (the game's buses if it is loaded, the music).\n    const sndBtn = $('.corner.snd'), pop = $('.sndpop'), swEl = $('[data-sw]'), mEl = $('[data-master]')\n    const VOL_KEY = `${STORE}.volume`\n    const readVol = () => { try { return JSON.parse(readKey(VOL_KEY) || '{}') || {} } catch (e) { return {} } }\n    const paintVol = () => {\n      const v = readVol(), m = Number.isFinite(Number(v.master)) ? Math.max(0, Math.min(200, Math.round(Number(v.master)))) : 100\n      sndBtn.classList.toggle('muted', v.muted === true)\n      pop.classList.toggle('muted', v.muted === true)\n      swEl.classList.toggle('on', v.muted !== true); swEl.setAttribute('aria-checked', String(v.muted !== true))\n      mEl.value = String(m); mEl.style.setProperty('--fill', `${m / 2}%`); mEl.title = `${m}%`\n    }\n    const writeVol = (patch) => {\n      const v = Object.assign(readVol(), patch)\n      try { localStorage.setItem(VOL_KEY, JSON.stringify(v)) } catch (e) {}\n      try { rt.applyVolume && rt.applyVolume() } catch (e) {}\n      try { const g = rt.menuMusic && rt.menuMusic.gain; if (g) g.gain.setTargetAtTime(musicLevel(), g.context.currentTime, 0.05) } catch (e) {}\n      paintVol()\n      if (cur === 'settings') paintSettings()\n    }\n    const popOpen = (open) => { pop.hidden = !open; sndBtn.classList.toggle('open', open); if (open) paintVol() }\n    paintVol()\n    on(sndBtn, 'click', () => popOpen(pop.hidden))\n    on($('[data-pop-close]'), 'click', () => popOpen(false))\n    on(swEl, 'click', () => writeVol({ muted: !(readVol().muted === true) }))\n    on(mEl, 'input', () => writeVol({ master: Math.max(0, Math.min(200, Number(mEl.value) || 0)) }))\n    on(root, 'pointerdown', (e) => {\n      if (pop.hidden) return\n      const path = e.composedPath ? e.composedPath() : []\n      if (!path.includes(pop) && !path.includes(sndBtn)) popOpen(false)\n    })\n    on(window, 'keydown', (e) => { if (e.key === 'Escape' && !pop.hidden) popOpen(false) })\n\n    // ---- /settings: the Sound switch, the mixer (0-200 %, 100 = as the game mixes it), fullscreen --\n    // The same settings (`${STORE}.volume`) as the sound window and the game's pause window.\n    const stPanel = $('.panel[data-scr=\"settings\"]'), stSound = $('[data-st-sound]'), stFs = $('[data-st-fs]')\n    const fsAble = () => !!(document.fullscreenEnabled || document.webkitFullscreenEnabled)\n    const paintSettings = () => {\n      const v = readVol()\n      stPanel.classList.toggle('muted', v.muted === true)\n      stSound.classList.toggle('on', v.muted !== true); stSound.setAttribute('aria-checked', String(v.muted !== true))\n      for (const m of MIXER) {\n        const lv = Number.isFinite(Number(v[m.id])) ? Math.max(0, Math.min(200, Math.round(Number(v[m.id])))) : 100\n        const row = stPanel.querySelector(`[data-mix=\"${m.id}\"]`), input = row.querySelector('input')\n        if (document.activeElement !== input && root.activeElement !== input) input.value = String(lv)\n        input.style.setProperty('--fill', `calc(4px + (100% - 8px) * ${lv / 200})`); row.querySelector('b').textContent = `${lv}%`\n      }\n      stFs.hidden = !fsAble(); stPanel.querySelector('.na').hidden = fsAble()\n      stFs.classList.toggle('on', fsOn()); stFs.setAttribute('aria-checked', String(fsOn()))\n    }\n    on(stSound, 'click', () => writeVol({ muted: !(readVol().muted === true) }))\n    for (const m of MIXER) {\n      const input = stPanel.querySelector(`[data-mix=\"${m.id}\"] input`)\n      on(input, 'input', () => writeVol({ [m.id]: Math.max(0, Math.min(200, Number(input.value) || 0)) }))\n    }\n    on(stFs, 'click', () => fsBtn.click())\n    on(document, 'fullscreenchange', () => { if (cur === 'settings') paintSettings() })\n    on(document, 'webkitfullscreenchange', () => { if (cur === 'settings') paintSettings() })\n\n    // ---- readouts ------------------------------------------------------------------------------\n    const el = (s) => $(s)\n    const rSync = el('[data-sync]'), rVel = el('[data-vel]'), rGd = el('[data-gd]'), rHdg = el('[data-hdg]'), rAlt = el('[data-alt]'), rBoost = el('[data-boost]'), rClock = el('[data-clock]')\n    const reticle = $('.reticle')\n    let retHidden = false\n\n    // ---- WebGL2 scene --------------------------------------------------------------------------\n    const canvas = $('canvas.gl')\n    // (no antialias on the canvas: the scene is drawn multisampled off screen, for the glass to read it)\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, powerPreference: 'high-performance', preserveDrawingBuffer: false })\n    if (!gl) { mm.classList.add('nogl') }\n\n    const pointer = { x: 0, y: 0, tx: 0, ty: 0 }\n    on(mm, 'pointermove', (e) => { const r = mm.getBoundingClientRect(); pointer.tx = (e.clientX - r.left) / r.width * 2 - 1; pointer.ty = (e.clientY - r.top) / r.height * 2 - 1 })\n\n    // math\n    const persp = (fovy, asp, n, f) => { const t = 1 / Math.tan(fovy / 2); return new Float32Array([t / asp, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, 2 * f * n / (n - f), 0]) }\n    const mul = (a, b) => { const o = new Float32Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s } return o }\n    const rotY = (a) => { const c = Math.cos(a), s = Math.sin(a); return new Float32Array([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]) }\n    const rotX = (a) => { const c = Math.cos(a), s = Math.sin(a); return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]) }\n    const rotZ = (a) => { const c = Math.cos(a), s = Math.sin(a); return new Float32Array([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) }\n    const trans = (x, y, z) => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1])\n    const scl = (s) => new Float32Array([s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1])\n    const xf = (m, v) => [m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14], m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15]]\n\n    // the ships' shader and their engine glow's (sources: the scene's context compiles them, the pilot picker's too)\n    const SHIP_SRC = [`#version 300 es\n          in vec3 aP; in vec3 aN; in vec2 aUV; in vec3 aL; uniform mat4 uVP; uniform mat4 uM;\n          out vec3 vN; out vec2 vUV; flat out vec3 vL; out vec3 vW;\n          void main(){ vec4 w = uM*vec4(aP,1.); vW = w.xyz; vN = mat3(uM)*aN; vUV = aUV; vL = aL; gl_Position = uVP*w; }`, `#version 300 es\n          precision highp float; precision highp sampler2DArray;\n          in vec3 vN; in vec2 vUV; flat in vec3 vL; in vec3 vW; out vec4 o;\n          uniform sampler2DArray uTex; uniform float uFade; uniform float uAlphaPass; uniform vec3 uEye; uniform vec3 uTint;\n          // the game's wrap mode per axis (as its sampler had it): 0 repeat, 1 mirror, 2 clamp\n          float wrap(float u, float m){\n            if (m > 1.5) return clamp(u, .5/64., 1.-.5/64.);\n            if (m > .5) { float f = mod(u, 2.); return f > 1. ? 2.-f : f; }\n            return u;\n          }\n          void main(){\n            float vLayer = vL.x, vFlags = vL.y, wm = vL.z;\n            vec2 uv = vec2(wrap(vUV.x, mod(wm, 4.)), wrap(vUV.y, floor(wm/4.)));\n            vec4 t = texture(uTex, vec3(uv, vLayer));\n            vec3 N = normalize(vN); vec3 V = normalize(uEye - vW);\n            if (!gl_FrontFacing) N = -N;\n            vec3 Lk = normalize(vec3(-.5,.8,.6));\n            float dif = max(dot(N,Lk),0.);\n            float rim = pow(1.-max(dot(N,V),0.), 3.);\n            vec3 H = normalize(Lk+V); float spec = pow(max(dot(N,H),0.), 24.);\n            float unlit = mod(floor(vFlags/2.),2.);\n            vec3 lit = t.rgb*(vec3(.22,.28,.42) + vec3(1.,.96,.9)*dif*.72) + vec3(.35,.8,1.)*rim*.5 + spec*.18;\n            vec3 c = mix(lit, t.rgb, unlit);\n            float isA = mod(vFlags,2.);\n            if (uAlphaPass < .5 && isA > .5) discard;\n            if (uAlphaPass > .5 && isA < .5) discard;\n            float a = isA > .5 ? .38 : 1.; // the canopy: clear enough to see the pilot\n            o = vec4(c*uTint*uFade, a*uFade);\n          }`]\n    const GLOW_SRC = [`#version 300 es\n          in vec2 aC; uniform mat4 uP; uniform vec3 uCenter; uniform vec2 uHalf; uniform float uRoll; out vec2 vUV;\n          void main(){\n            float c = cos(uRoll), s = sin(uRoll); vec2 d = vec2(aC.x*uHalf.x, aC.y*uHalf.y);\n            vec3 p = uCenter + vec3(c*d.x - s*d.y, s*d.x + c*d.y, 0.);\n            vUV = vec2(aC.x*.5+.5, .5-aC.y*.5); gl_Position = uP*vec4(p,1.);\n          }`, `#version 300 es\n          precision mediump float; precision mediump sampler2DArray;\n          in vec2 vUV; out vec4 o; uniform sampler2DArray uTex; uniform float uLayer; uniform float uFade;\n          void main(){\n            vec4 t = texture(uTex, vec3(clamp(vUV, .5/64., 1.-.5/64.), uLayer));\n            vec3 c = mix(vec3(0.,0.,1.), vec3(1.), t.rgb);\n            o = vec4(c, t.a*uFade);\n          }`]\n    // (on the scene's context, or another: the pilot picker's ships have their own canvas)\n    const compile = (vs, fs, g = gl) => {\n      const mk = (type, src) => { const s = g.createShader(type); g.shaderSource(s, src); g.compileShader(s); if (!g.getShaderParameter(s, g.COMPILE_STATUS)) throw new Error(g.getShaderInfoLog(s)); return s }\n      const p = g.createProgram(); g.attachShader(p, mk(g.VERTEX_SHADER, vs)); g.attachShader(p, mk(g.FRAGMENT_SHADER, fs)); g.linkProgram(p)\n      if (!g.getProgramParameter(p, g.LINK_STATUS)) throw new Error(g.getProgramInfoLog(p))\n      const u = {}; const n = g.getProgramParameter(p, g.ACTIVE_UNIFORMS)\n      for (let i = 0; i < n; i++) { const info = g.getActiveUniform(p, i); u[info.name.replace(/\\[0\\]$/, '')] = g.getUniformLocation(p, info.name) }\n      return { p, u }\n    }\n\n    let progCopy, progGlass, sc = null, glassOn = false, quadVao01\n    let progTrail, trailVao, trailBuf\n    let progBg, progBlit, progStar, progShip, progGlow, noiseTex, bgFbo = null, bgTex = null, bgW = 0, bgH = 0, starVao, starCount = 0, quadVao\n    const ship = { vao: null, count: 0, tex: null, ready: false, fade: 0, glow: null }\n    const wolf = { vao: null, count: 0, tex: null, ready: false, glow: null } // Star Wolf's crew fly this\n    if (gl) {\n      try {\n        const FS_TRI = `#version 300 es\n          const vec2 P[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));\n          out vec2 vUv; void main(){ vec2 p = P[gl_VertexID]; vUv = p*.5+.5; gl_Position = vec4(p,0.,1.); }`\n        progBg = compile(FS_TRI, `#version 300 es\n          precision highp float; in vec2 vUv; out vec4 o;\n          uniform vec2 uRes; uniform float uT; uniform sampler2D uNoise; uniform vec3 uPlanet; uniform vec2 uPar;\n          float n(vec2 p){ return texture(uNoise, p/256.).r; }\n          float fbm(vec2 p){ float s=0., a=.5; for(int i=0;i<5;i++){ s+=a*n(p); p=p*2.03+vec2(17.3,9.1); a*=.5; } return s; }\n          void main(){\n            vec2 px = vUv*uRes; vec2 q = px/uRes.y;\n            vec3 col = mix(vec3(.012,.03,.05), vec3(.02,.06,.13), smoothstep(0.,1.,vUv.y));\n            // nebula: two drifting fbm layers, cosmic blues/violet with a cyan core\n            vec2 d = q + uPar*.012 + vec2(uT*.004, uT*.0015);\n            float f1 = fbm(d*34.); float f2 = fbm(d*20. + f1*9. + vec2(-uT*.25,0.));\n            float neb = smoothstep(.38,.85,f2) * (.6 + .4*sin(q.x*2.1+1.3));\n            col += neb * mix(vec3(.04,.10,.30), vec3(.22,.07,.38), smoothstep(.35,.8,f1)) * .85;\n            col += pow(max(f2-.5,0.)*2.,3.)*vec3(.15,.55,.95)*.9;\n            float dust = smoothstep(.62,.95,fbm(d*90.+f1*4.)); col += dust*vec3(.06,.08,.12);\n            // soft cyan blobs (the concept's blurred ellipses)\n            vec2 b1 = vec2(.66,.06)*uRes; vec2 b2 = vec2(.83,.79)*uRes;\n            col += vec3(.24,.6,.75)*.22*exp(-dot((px-b1)/(uRes.y*.38),(px-b1)/(uRes.y*.38))*2.);\n            col += vec3(.24,.6,.75)*.18*exp(-dot((px-b2)/vec2(uRes.y*.6,uRes.y*.22),(px-b2)/vec2(uRes.y*.6,uRes.y*.22))*2.);\n            // planet, top-left: dark core, blue limb, rim light and atmosphere glow\n            vec2 pc = uPlanet.xy + uPar*vec2(-8.,6.); float r = uPlanet.z; float dd = length(px-pc);\n            float inside = 1.-smoothstep(r-1.5, r+1.5, dd);\n            float k = clamp(dd/r,0.,1.);\n            vec3 pl = mix(vec3(.012,.03,.047), vec3(.18,.56,1.)*.42, pow(k,3.2));\n            float bands = fbm(vec2(((px.x-pc.x)/r)*3., ((px.y-pc.y)/r)*14.)*20. + vec2(uT*.03,0.));\n            pl += vec3(.05,.12,.25)*bands*.35*(1.-k);\n            vec2 L = normalize(vec2(.7,-.6)); vec2 nrm = (px-pc)/r;\n            pl += vec3(.3,.75,1.)*pow(max(dot(nrm,L),0.),6.)*k*.9;\n            col = mix(col, pl, inside);\n            float rim = exp(-abs(dd-r)/(2.+r*.004)); col += vec3(.18,.5,1.)*rim*.9;\n            col += vec3(.1,.35,.9)*.35*exp(-max(dd-r,0.)/(r*.10))*(1.-inside);\n            col += vec3(.2,.6,1.)*.25*pow(k,8.)*inside;\n            o = vec4(col,1.);\n          }`)\n        progBlit = compile(FS_TRI, `#version 300 es\n          precision mediump float; in vec2 vUv; out vec4 o; uniform sampler2D uBg; uniform vec2 uRes;\n          void main(){ vec3 c = texture(uBg, vUv).rgb; vec2 px = vUv*uRes;\n            c *= .95 + .05*sin(px.y*1.5708); vec2 v = vUv-.5; c *= 1.-dot(v,v)*.85; o = vec4(c,1.); }`)\n        // Far stars: a slab of space well behind the ship (z -40 to -110), streaming past opposite to\n        // the ship's heading (uDir), so it reads as the ship flying. Each star runs a fixed track\n        // through its anchor and comes back to its start (fract of its phase): it fades in at one end\n        // and out at the other, so the loop has no pop, and no star ever comes past the ship.\n        progStar = compile(`#version 300 es\n          in vec4 aS; in vec2 aC; uniform mat4 uVP; uniform float uT; uniform float uLen; uniform vec2 uRes; uniform vec3 uDir;\n          out float vA; out float vX;\n          void main(){\n            float k = fract(aS.z + uT);                 // 0 .. 1 along the track\n            float za = -75. + (fract(sin(dot(aS.xy, vec2(12.9898, 78.233))) * 43758.5453) - .5) * 50.;\n            vec3 p = vec3(aS.xy, za) - uDir * (k - .5) * 30.;\n            vec3 q = p + uDir * (1.5 + aS.w*3.) * uLen; // the streak's tail, where it came from\n            vec4 ca = uVP*vec4(p,1.), cb = uVP*vec4(q,1.);\n            vec2 sa = ca.xy/ca.w, sb = cb.xy/cb.w; vec2 dir = sa-sb; float l = length(dir*uRes);\n            vec2 nn = l > .001 ? normalize(vec2(-dir.y*uRes.x, dir.x*uRes.y)) : vec2(0.,1.);\n            vec4 c = mix(ca, cb, aC.x);\n            c.xy += nn/uRes*(.8+aS.w*1.1)*aC.y*c.w;\n            gl_Position = c; vX = aC.x;\n            vA = (.25 + .6*aS.w) * smoothstep(0., .2, k) * (1. - smoothstep(.8, 1., k));\n          }`, `#version 300 es\n          precision mediump float; in float vA; in float vX; out vec4 o;\n          void main(){ float a = vA*(1.-vX*.85); o = vec4(vec3(.78,.9,1.)*a, a); }`)\n        progShip = compile(...SHIP_SRC)\n        // The engine glow, as the game draws it (Display_DrawEngineGlow: aOrbDL with SETUPDL_67): a\n        // camera-facing quad of the orb texture, colour (PRIM - ENV) * TEXEL0 + ENV with PRIM white and\n        // ENV blue, alpha TEXEL0 * PRIM.\n        progGlow = compile(...GLOW_SRC)\n\n        // Liquid glass under the slabs and the corner buttons: each is a lens over the finished scene.\n        // Its shape is the element's rounded rectangle, wherever its 3D transform puts it (p0 + x u + y v\n        // in canvas pixels, from markers at its corners). Inside, the scene is blurred and brightened as\n        // the CSS blur was; toward the rim it bends outward (the scene just past the edge is drawn\n        // squeezed into it, as a thick glass edge does), with a little colour split and an inner light.\n        progCopy = compile(FS_TRI, `#version 300 es\n          precision mediump float; in vec2 vUv; out vec4 o; uniform sampler2D uSrc;\n          void main(){ o = vec4(texture(uSrc, vUv).rgb, 1.); }`)\n        progGlass = compile(`#version 300 es\n          in vec2 aC; uniform vec2 uP0; uniform vec2 uU; uniform vec2 uV; uniform vec2 uRes; uniform vec2 uSize;\n          out vec2 vL;\n          void main(){ vec2 p = uP0 + aC.x*uU + aC.y*uV; vL = aC*uSize; gl_Position = vec4(p/uRes*2.-1., 0., 1.); }`, `#version 300 es\n          precision highp float;\n          in vec2 vL; out vec4 o;\n          uniform sampler2D uScene; uniform vec2 uRes; uniform vec2 uU; uniform vec2 uV; uniform vec2 uSize; uniform float uR; uniform float uA;\n          float sdf(vec2 l){ vec2 q = abs(l - uSize*.5) - (uSize*.5 - uR); return length(max(q, 0.)) + min(max(q.x, q.y), 0.) - uR; }\n          vec2 toPx(vec2 dl){ return dl.x/uSize.x*uU + dl.y/uSize.y*uV; }   // local px -> canvas px\n          vec3 at(vec2 px){ return texture(uScene, px/uRes).rgb; }\n          void main(){\n            float d = sdf(vL);\n            float a = clamp(.5 - d, 0., 1.) * uA;\n            if (a <= 0.) discard;\n            float e = -d;                                        // depth into the glass, local px\n            vec2 n = normalize(vec2(sdf(vL + vec2(1.,0.)) - sdf(vL - vec2(1.,0.)), sdf(vL + vec2(0.,1.)) - sdf(vL - vec2(0.,1.))) + 1e-5);\n            float band = clamp(1. - e/14., 0., 1.);\n            float bend = band*band*12.;                           // outward, strongest at the rim\n            vec2 c = gl_FragCoord.xy + toPx(n*bend);\n            // blur: 12 taps on two rings (radius 3.5 and 7 local px), plus the centre\n            vec3 col = at(c)*.16; float w = .16;\n            for (int i = 0; i < 12; i++) {\n              float ang = float(i)*.5236 + (i < 6 ? 0. : .2618);\n              float r = i < 6 ? 3.5 : 7.;\n              vec2 dl = vec2(cos(ang), sin(ang))*r;\n              float k = i < 6 ? .09 : .05; col += at(c + toPx(dl))*k; w += k;\n            }\n            col /= w;\n            // colour split at the rim\n            vec2 ca = toPx(n*bend*.35);\n            col.r = mix(col.r, at(c + ca).r, band*.6); col.b = mix(col.b, at(c - ca).b, band*.6);\n            // as the CSS did: saturate 1.6, brighten 1.1\n            float g = dot(col, vec3(.299,.587,.114)); col = mix(vec3(g), col, 1.6)*1.1;\n            col += vec3(.55,.85,1.)*band*band*.16;                 // inner light along the rim\n            o = vec4(col, a);\n          }`)\n\n        // value-noise texture (256², repeat) for the nebula\n        const N = 256, nd = new Uint8Array(N * N)\n        let seed = 1337; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)\n        for (let i = 0; i < N * N; i++) nd[i] = rnd() * 255\n        noiseTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, noiseTex)\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, N, N, 0, gl.RED, gl.UNSIGNED_BYTE, nd)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)\n\n        // stars: instanced streak quads; each a direction (x, y), a phase and a size\n        starCount = Math.round(Math.min(320, 160 + innerWidth * 0.1))\n        const sd = new Float32Array(starCount * 4)\n        for (let i = 0; i < starCount; i++) { sd[i * 4] = (rnd() - .5) * 80; sd[i * 4 + 1] = (rnd() - .5) * 46; sd[i * 4 + 2] = rnd(); sd[i * 4 + 3] = Math.pow(rnd(), 3) }\n        starVao = gl.createVertexArray(); gl.bindVertexArray(starVao)\n        const corner = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, corner)\n        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, -1, 1, -1, 0, 1, 1, -1, 1, 1, 0, 1]), gl.STATIC_DRAW)\n        let loc = gl.getAttribLocation(progStar.p, 'aC'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n        const sb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, sb); gl.bufferData(gl.ARRAY_BUFFER, sd, gl.STATIC_DRAW)\n        loc = gl.getAttribLocation(progStar.p, 'aS'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(loc, 1)\n\n        // the panes' quad (0..1)\n        quadVao01 = gl.createVertexArray(); gl.bindVertexArray(quadVao01)\n        const qb01 = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, qb01)\n        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW)\n        loc = gl.getAttribLocation(progGlass.p, 'aC'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n        // the boost trail (multiplayer): a strip of points the ship's tail has passed, x y z + brightness\n        progTrail = compile(`#version 300 es\n          in vec4 aP; uniform mat4 uVP; out float vA;\n          void main(){ vA = aP.w; gl_Position = uVP*vec4(aP.xyz, 1.); }`, `#version 300 es\n          precision mediump float; in float vA; out vec4 o; uniform vec3 uCol;\n          void main(){ o = vec4(uCol*vA, vA); }`)\n        trailVao = gl.createVertexArray(); gl.bindVertexArray(trailVao)\n        trailBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, trailBuf)\n        loc = gl.getAttribLocation(progTrail.p, 'aP'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, 0, 0)\n\n        // the glow's quad\n        quadVao = gl.createVertexArray(); gl.bindVertexArray(quadVao)\n        const qb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, qb)\n        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]), gl.STATIC_DRAW)\n        loc = gl.getAttribLocation(progGlow.p, 'aC'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n        gl.bindVertexArray(null)\n      } catch (e) {\n        console.warn('[mainmenu] webgl', e)\n        mm.classList.add('nogl')\n      }\n    }\n\n    // ---- the Arwing: mesh + textures from the private drive ----------------------------------------\n    const loadShip = async () => {\n      if (!gl || !progShip) return\n      let key = rt.dlKey\n      if (!key) {\n        const kr = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n        if (!kr.ok) throw new Error(`keys ${kr.status}`)\n        const body = await kr.json(); const rows = Array.isArray(body) ? body : (body && body.data) || []\n        key = rows[0] && rows[0].key\n        if (!key) throw new Error('no key')\n      }\n      // both at once, and both shown on the same frame (a failed Wolfen falls back to a red-tinted Arwing)\n      const [a, w] = await Promise.allSettled([loadModel(ship, MESH_REF, TEX_REF, key), loadModel(wolf, WOLF_MESH_REF, WOLF_TEX_REF, key)])\n      if (a.status === 'rejected') throw a.reason\n      if (!alive || !ship.vao) return\n      if (w.status === 'rejected') console.warn('[mainmenu] wolfen', w.reason && w.reason.message)\n      else if (wolf.vao) {\n        // (the Wolfen is compact: both are 2 units long, so it reads bigger; it flies a size down, its glow too)\n        if (wolf.glow) { wolf.glow.hw *= 0.7; wolf.glow.hh *= 0.7 }\n        wolf.ready = true\n      }\n      ship.ready = true\n    }\n    // A model (AWM2 + its texture strip) into `ship`'s shape: the Arwing, the Wolfen. The caller marks it ready.\n    const loadModel = async (ship, meshRef, texRef, key) => {\n      const [mr, tr] = await Promise.all([\n        fetch(`${filesBase}/file/${meshRef}?t=${encodeURIComponent(key)}`),\n        fetch(`${filesBase}/file/${texRef}?t=${encodeURIComponent(key)}`),\n      ])\n      if (!mr.ok || !tr.ok) throw new Error(`ship files ${mr.status}/${tr.status}`)\n      const buf = await mr.arrayBuffer()\n      const img = await createImageBitmap(await tr.blob())\n      if (!alive) return\n      // AWM2 (export_arwing2.py): nv, layers, the engine glow (centre xyz, half size w h, its layer),\n      // then 28-byte vertices: pos f32x3, normal i8x3, uv f32x2, layer, flags, prim alpha, wrap\n      const dv = new DataView(buf)\n      if (dv.getUint32(0, true) !== 0x324d5741) throw new Error('ship mesh: not AWM2')\n      const nv = dv.getUint32(4, true), layers = dv.getUint32(8, true)\n      const g = (k) => dv.getFloat32(12 + k * 4, true)\n      ship.glow = { c: [g(0), g(1), g(2)], hw: g(3), hh: g(4), layer: dv.getUint32(32, true) }\n      // interleave pos3 nrm3 uv2 + layer flags wrap\n      const S = 28, F = 11, out = new Float32Array(nv * F)\n      for (let i = 0; i < nv; i++) {\n        const o = 36 + i * S, w = i * F\n        out[w] = dv.getFloat32(o, true); out[w + 1] = dv.getFloat32(o + 4, true); out[w + 2] = dv.getFloat32(o + 8, true)\n        out[w + 3] = dv.getInt8(o + 12) / 127; out[w + 4] = dv.getInt8(o + 13) / 127; out[w + 5] = dv.getInt8(o + 14) / 127\n        out[w + 6] = dv.getFloat32(o + 16, true); out[w + 7] = dv.getFloat32(o + 20, true)\n        out[w + 8] = dv.getUint8(o + 24); out[w + 9] = dv.getUint8(o + 25); out[w + 10] = dv.getUint8(o + 27)\n      }\n      ship.raw = { out, nv, layers } // (the pilot picker uploads the same data to its own context)\n      ship.vao = gl.createVertexArray(); gl.bindVertexArray(ship.vao)\n      const vb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vb); gl.bufferData(gl.ARRAY_BUFFER, out, gl.STATIC_DRAW)\n      const at = (name, n, off) => { const l = gl.getAttribLocation(progShip.p, name); if (l < 0) return; gl.enableVertexAttribArray(l); gl.vertexAttribPointer(l, n, gl.FLOAT, false, F * 4, off) }\n      at('aP', 3, 0); at('aN', 3, 12); at('aUV', 2, 24); at('aL', 3, 32)\n      gl.bindVertexArray(null)\n      ship.count = nv\n      // texture array from the stacked strip\n      const cv = document.createElement('canvas'); cv.width = img.width; cv.height = img.height\n      const cx = cv.getContext('2d'); cx.drawImage(img, 0, 0)\n      const px = cx.getImageData(0, 0, img.width, img.height).data\n      ship.tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D_ARRAY, ship.tex)\n      ship.raw.px = new Uint8Array(px.buffer, 0, 64 * 64 * 4 * layers)\n      gl.texImage3D(gl.TEXTURE_2D_ARRAY, 0, gl.RGBA8, 64, 64, layers, 0, gl.RGBA, gl.UNSIGNED_BYTE, ship.raw.px)\n      gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT)\n    }\n    loadShip().catch((e) => {\n      console.warn('[mainmenu] ship', e && e.message)\n      // no download key means no session: say so where the ship would be, and go and sign in\n      if (/keys 40[13]/.test(String(e && e.message))) { mm.classList.add('noship'); if (alive) nav('/login?next=%2F') }\n      else if (/no key/.test(String(e && e.message))) mm.classList.add('noship')\n    })\n\n    // ---- menu music: the Corneria theme, as on TFSF_Menu_DEV's screens -------------------------\n    // Same song, same state (rt.menuMusic): it keeps playing from screen to screen across both\n    // components; the game's loading screen fades it out (rt.menuMusicStop) and it starts again here.\n    const MENU_MUSIC = { ref: 'FBoHETPRIMLxFzfavAwi2', loopStart: 1.0, loopEnd: 84.2079 }\n    const music = rt.menuMusic || (rt.menuMusic = { buf: null, loading: null, src: null, gain: null })\n    const musicLevel = () => {\n      let v = {}\n      try { v = JSON.parse(readKey(`${STORE}.volume`) || '{}') || {} } catch (e) {}\n      if (v.muted === true) return 0\n      const lvl = (x) => (Number.isFinite(Number(x)) ? Math.max(0, Math.min(200, Number(x))) : 100) / 100\n      const ac = rt.audio\n      // Once the player has put its master gain on the context, `destination` is that gain already.\n      return (ac && ac.__tfsfMaster ? 1 : lvl(v.master)) * lvl(v.bgm) * 0.35\n    }\n    const musicLoad = () => music.loading || (music.loading = (async () => {\n      let key = rt.dlKey\n      if (!key) {\n        const kr = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n        if (!kr.ok) throw new Error(`keys: HTTP ${kr.status}`)\n        const body = await kr.json(); const rows = Array.isArray(body) ? body : (body && body.data) || []\n        key = rows[0] && rows[0].key\n        if (!key) throw new Error('keys: none')\n      }\n      const res = await fetch(`${filesBase}/file/${MENU_MUSIC.ref}?t=${encodeURIComponent(key)}`)\n      if (!res.ok) throw new Error(`music: HTTP ${res.status}`)\n      music.buf = await rt.audio.decodeAudioData(await res.arrayBuffer())\n      return music.buf\n    })().catch((e) => { music.loading = null; throw e }))\n    const musicPlay = async () => {\n      const ac = rt.audio\n      if (!ac || ac.state === 'closed' || music.src) return\n      try { await musicLoad() } catch (e) { return }\n      if (music.src || !alive || rt.menuMusicOff || ac !== rt.audio || ac.state === 'closed') return\n      const src = ac.createBufferSource()\n      src.buffer = music.buf\n      src.loop = true\n      src.loopStart = MENU_MUSIC.loopStart\n      src.loopEnd = Math.min(MENU_MUSIC.loopEnd, music.buf.duration)\n      const gain = ac.createGain()\n      gain.gain.setValueAtTime(0, ac.currentTime)\n      gain.gain.linearRampToValueAtTime(musicLevel(), ac.currentTime + 1.5)\n      src.connect(gain)\n      gain.connect(ac.destination)\n      src.start()\n      music.src = src\n      music.gain = gain\n    }\n    const musicStop = (fade) => {\n      const { src, gain } = music\n      music.src = music.gain = null\n      if (!src) return\n      try {\n        const ac = src.context\n        gain.gain.cancelScheduledValues(ac.currentTime)\n        gain.gain.setValueAtTime(gain.gain.value, ac.currentTime)\n        gain.gain.linearRampToValueAtTime(0, ac.currentTime + fade)\n        src.stop(ac.currentTime + fade + 0.05)\n      } catch (e) {}\n    }\n    rt.menuMusicStop = musicStop\n    rt.menuMusicOff = false // back on a menu screen: the music may play again\n    musicPlay()\n    // iOS suspends sound when the page sleeps: a tap wakes it (and starts the music if it never did)\n    on(mm, 'pointerup', () => {\n      try { if (rt.audio && rt.audio.state === 'suspended') rt.audio.resume() } catch (e) {}\n      musicPlay()\n    })\n\n    // ---- frame ---------------------------------------------------------------------------------\n    let W = 0, H = 0, dpr = 1\n    const resize = () => {\n      if (hg.alert) requestAnimationFrame(runWords)\n      dpr = Math.min(window.devicePixelRatio || 1, innerWidth < 900 ? 1.75 : 1.5)\n      const w = mm.clientWidth || innerWidth, h = mm.clientHeight || innerHeight\n      const cw = Math.round(w * dpr), ch = Math.round(h * dpr)\n      if (cw !== W || ch !== H) { W = cw; H = ch; canvas.width = W; canvas.height = H }\n      scale = fit()\n    }\n    resize()\n    const ro = new ResizeObserver(resize); ro.observe(mm); undo.push(() => ro.disconnect())\n    // a rotation can land before the observer sees the new size (iOS): look again once it settles\n    const later = () => { resize(); setTimeout(resize, 120); setTimeout(resize, 400) }\n    on(window, 'resize', later); on(window, 'orientationchange', later)\n    if (window.visualViewport) on(window.visualViewport, 'resize', later)\n\n    const t0 = performance.now()\n    let last = t0, raf = 0, frame = 0, nextRead = 0, starPhase = 0\n    // ---- the scene's off-screen target (multisampled), and its resolved copy the glass samples ----\n    const sceneTarget = () => {\n      if (!progGlass) return null\n      if (sc && sc.w === W && sc.h === H) return sc.ms\n      try {\n        if (!sc) {\n          sc = { ms: gl.createFramebuffer(), fb: gl.createFramebuffer(), color: gl.createRenderbuffer(), depth: gl.createRenderbuffer(), tex: gl.createTexture(), w: 0, h: 0 }\n        }\n        const samples = Math.min(4, gl.getParameter(gl.MAX_SAMPLES) || 0)\n        gl.bindRenderbuffer(gl.RENDERBUFFER, sc.color); gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.RGBA8, W, H)\n        gl.bindRenderbuffer(gl.RENDERBUFFER, sc.depth); gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.DEPTH_COMPONENT24, W, H)\n        gl.bindFramebuffer(gl.FRAMEBUFFER, sc.ms)\n        gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, sc.color)\n        gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, sc.depth)\n        gl.bindTexture(gl.TEXTURE_2D, sc.tex)\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n        gl.bindFramebuffer(gl.FRAMEBUFFER, sc.fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, sc.tex, 0)\n        if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) throw new Error('scene target incomplete')\n        sc.w = W; sc.h = H\n        if (!glassOn) { glassOn = true; mm.classList.add('lg') } // the CSS blur steps aside for the glass\n        return sc.ms\n      } catch (e) {\n        console.warn('[mainmenu] glass off', e && e.message)\n        progGlass = null; sc = null; glassOn = false; mm.classList.remove('lg')\n        return null\n      }\n    }\n    // the glass panes: the slabs, the corner buttons and the sound window, each with markers at its\n    // corners (the 3D transforms move them; their positions give the pane on screen)\n    const scrOf = (el) => { const s = el.closest('[data-scr]'); return s ? s.dataset.scr : '' }\n    const panes = [...qa('.item .box').map((el) => ({ el, r: 13, item: el.parentElement, scr: scrOf(el) })),\n      ...qa('.pane').map((el) => ({ el, r: Number(el.dataset.r) || 16, fades: true, scr: scrOf(el), dlg: el.classList.contains('dlg-box') })),\n      ...qa('.corner').map((el) => ({ el, r: 12, scr: '' })), { el: pop, r: 16, scr: '' }]\n    for (const pn of panes) {\n      pn.m = ['left:0;top:0', 'left:100%;top:0', 'left:0;top:100%'].map((css) => { const i = document.createElement('i'); i.className = 'lgm'; i.style.cssText = css; pn.el.appendChild(i); return i })\n    }\n    const stageEl = $('.stage.left')\n    const drawGlass = (t) => {\n      const box = mm.getBoundingClientRect(), k = W / Math.max(1, box.width)\n      gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)\n      gl.useProgram(progGlass.p)\n      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, sc.tex); gl.uniform1i(progGlass.u.uScene, 0)\n      gl.uniform2f(progGlass.u.uRes, W, H)\n      gl.bindVertexArray(quadVao01)\n      const early = t < 3 || t - enterAt < 1.6 // slabs fade in (boot, a new screen): follow their opacity until they are in\n      // leaving: this screen's glass fades with it (as its CSS opacity does, .28 s)\n      const scrA = tr && tr.phase === 'out' ? 1 - smooth(0, 0.28, t - tr.t0) : 1\n      const stageA = Number(getComputedStyle(stageEl).opacity) // the menu dims (and its glass with it) under a dialog\n      for (const pn of panes) {\n        if (pn.scr && pn.scr !== cur) continue // another screen's: not on screen, not even measured\n        if (pn.dlg && pn.el.parentElement.hidden) continue\n        const w = pn.el.offsetWidth, h = pn.el.offsetHeight\n        if (!w || !h) continue\n        let a = pn.scr ? scrA : 1\n        if (pn.el === pop) a = pop.hidden ? 0 : Number(getComputedStyle(pop).opacity) || 0\n        else if ((pn.item && early) || pn.fades) a *= Number(getComputedStyle(pn.fades ? pn.el : pn.item).opacity) || 0\n        if (pn.dlg) a *= Number(getComputedStyle(pn.el.parentElement).opacity) || 0\n        else if (pn.scr) a *= stageA\n        if (a <= 0.01) continue\n        const pt = pn.m.map((i) => { const r = i.getBoundingClientRect(); return [(r.left - box.left) * k, H - (r.top - box.top) * k] })\n        gl.uniform2f(progGlass.u.uP0, pt[0][0], pt[0][1])\n        gl.uniform2f(progGlass.u.uU, pt[1][0] - pt[0][0], pt[1][1] - pt[0][1])\n        gl.uniform2f(progGlass.u.uV, pt[2][0] - pt[0][0], pt[2][1] - pt[0][1])\n        gl.uniform2f(progGlass.u.uSize, w, h); gl.uniform1f(progGlass.u.uR, pn.r); gl.uniform1f(progGlass.u.uA, a)\n        gl.drawArrays(gl.TRIANGLES, 0, 6)\n      }\n      gl.bindVertexArray(null)\n    }\n\n    const trail = [], TRAIL_V = 7, TRAIL_LIFE = 1.8 // world units / s it drifts back; seconds it lasts\n    const tick = (now) => {\n      raf = requestAnimationFrame(tick)\n      if (document.hidden) return\n      const dt = Math.min(0.05, (now - last) / 1000); last = now\n      const t = (now - t0) / 1000\n      frame++\n      pollPad()\n      pointer.x += (pointer.tx - pointer.x) * Math.min(1, dt * 3)\n      pointer.y += (pointer.ty - pointer.y) * Math.min(1, dt * 3)\n      // the glow eases as the game's does (Math_SmoothStepToF(.., 0.5, 5.0) per 30 Hz frame)\n      if (glowHold > 0) glowHold -= dt; else glowTarget = 1\n      glow += Math.max(-5, Math.min(5, (glowTarget - glow) * (1 - Math.pow(0.5, dt * 30))))\n      boost = Math.max(0, Math.min(1, (glow - 1) / 3))\n      shipKick *= Math.exp(-dt * 4)\n      starPhase += dt * (0.06 + boost * 0.16)\n\n      // camera: still, a touch of parallax with the pointer\n      const aspect = W / H\n      const P = persp(0.62, aspect, 0.1, 300)\n      // (none in the hangar: the platforms must sit exactly on their cards)\n      const V = (POSES[shipScreen] || POSES.home).hangar ? rotX(0) : mul(rotX(pointer.y * 0.02), rotY(pointer.x * 0.035))\n      const VP = { P, V, VP: mul(P, V) }\n      // the ship: the screen's pose (POSES), banking, bobbing and weaving gently; between screens it\n      // flies off along its nose and the next screen's pose flies in from behind\n      const ps = POSES[shipScreen] || POSES.home, spot = portrait ? ps.port : ps.land\n      const z = spot.z\n      const halfH = -z * Math.tan(0.31), halfW = halfH * aspect\n      const wob = ps.wob\n      const bob = (Math.sin(t * 1.3) * 0.05 + Math.sin(t * 0.47) * 0.04) * wob\n      const pos = [spot.at[0] * halfW + Math.sin(t * 0.4) * 0.08 * wob, spot.at[1] * halfH + bob, z]\n      const yaw = ps.yaw + Math.sin(t * 0.33) * 0.06 * wob\n      const pitch = ps.pitch + Math.sin(t * 0.6) * 0.03 * wob\n      const roll = ps.roll + (Math.sin(t * 0.9) * 0.12 + Math.sin(t * 0.37) * 0.08) * wob + shipKick * 0.25 * Math.sin(t * 20)\n      const size = portrait ? 1.35 * Math.min(1, aspect * 1.15) : 1.35 // portrait: narrower view, smaller ship\n      const heading = [Math.sin(yaw), -Math.sin(pitch) * Math.cos(yaw), Math.cos(pitch) * Math.cos(yaw)]\n      let shipAlpha = 1, trPhase = '', trK = 0\n      if (tr) {\n        const k = Math.min(1, (t - tr.t0) / (tr.phase === 'out' ? OUT_S : IN_S))\n        trPhase = tr.phase; trK = k\n        if (tr.phase === 'out') {\n          // away along the nose, faster and faster, fading\n          const d = 9 * k * k\n          pos[0] += heading[0] * d; pos[1] += heading[1] * d; pos[2] += heading[2] * d * 0.5 // (half toward the camera: it never reaches it)\n          shipAlpha = 1 - smooth(0.25, 1, k)\n        } else {\n          // in from behind its place, slowing to a stop\n          const d = 7 * (1 - k) * (1 - k) * (1 - k)\n          pos[0] -= heading[0] * d; pos[1] -= heading[1] * d; pos[2] -= heading[2] * d\n          shipAlpha = smooth(0, 0.45, k)\n        }\n        if (k >= 1) stepTransition(t)\n      }\n      let M = mul(trans(...pos), mul(rotX(pitch), mul(rotY(yaw), mul(rotZ(roll), mul(scl(size), trans(-ps.pivot[0], -ps.pivot[1], -ps.pivot[2]))))))\n      // the boost trail: where the tail has been, drifting back along the heading as the ship flies on\n      if (ps.trail && ship.ready && ship.glow) {\n        const tp = xf(M, ship.glow.c), dv = TRAIL_V * dt\n        for (const q of trail) { q.x -= heading[0] * dv; q.y -= heading[1] * dv; q.z -= heading[2] * dv; q.age += dt }\n        trail.push({ x: tp[0], y: tp[1], z: tp[2], age: 0 })\n        while (trail.length && (trail[0].age > TRAIL_LIFE || trail.length > 240)) trail.shift()\n      } else if (trail.length) trail.length = 0\n      // the hangar: a ship per occupied slot, hovering over its platform (they fly off / in together)\n      if (ps.hangar && (t - hg.at > 1 || hg.W !== W || hg.H !== H)) { hg.at = t; hg.W = W; hg.H = H; measureHangar() }\n      const hexA = trPhase === 'out' ? 1 - smooth(0, 0.5, trK) : trPhase === 'in' ? smooth(0, 0.6, trK) : 1\n      const hgShips = [], hangarNow = !!ps.hangar // (`ps` means another thing in the drawing below)\n      if (hangarNow) {\n        hg.world.forEach((A, i) => {\n          const sl = hg.slots[i]\n          if (!A || !sl || sl.open) return\n          const yw = (sl.flip ? -ps.yaw : ps.yaw) + Math.sin(t * 0.33 + i * 1.3) * 0.06, pt = ps.pitch + Math.sin(t * 0.6 + i) * 0.03\n          const rl = (Math.sin(t * 0.9 + i * 2.1) * 0.1 + Math.sin(t * 0.37 + i) * 0.06) * ps.wob\n          const hd = [Math.sin(yw), -Math.sin(pt) * Math.cos(yw), Math.cos(pt) * Math.cos(yw)]\n          const p = [A.x, A.y + A.r * 0.5 + Math.sin(t * 1.3 + i * 1.7) * A.r * 0.05, A.z]\n          if (trPhase === 'out') { const d = 9 * trK * trK; p[0] += hd[0] * d; p[1] += hd[1] * d; p[2] += hd[2] * d * 0.5 }\n          else if (trPhase === 'in') { const d = 7 * (1 - trK) ** 3; p[0] -= hd[0] * d; p[1] -= hd[1] * d; p[2] -= hd[2] * d }\n          // Star Wolf's crew fly the Wolfen (a red-tinted Arwing if it failed to load)\n          const wolfen = WOLF_CREW.includes(sl.pilot)\n          const sz = A.r * SHIP_K * (wolfen && wolf.ready ? 0.8 : 1)\n          hgShips.push({ M: mul(trans(...p), mul(rotX(pt), mul(rotY(yw), mul(rotZ(rl), scl(sz))))), sz, rl,\n            // (an arena lobby: each ship in its team's colour, as the game paints them)\n            model: wolfen && wolf.ready ? wolf : ship, tint: sl.team ? TEAM_TINT[sl.team] : wolfen && !wolf.ready ? [1, 0.5, 0.45] : [1, 1, 1] })\n        })\n      }\n      const dbg = window.__mmShip // test aid: { yaw, pitch, z } pins the ship in front of the camera\n      if (dbg) M = mul(trans(0, 0, dbg.z || -3.2), mul(rotX(dbg.tilt || 0), mul(rotY(dbg.yaw || 0), mul(rotX(dbg.pitch || 0), mul(rotZ(dbg.roll || 0), scl(1.35))))))\n\n      if (gl && progBg) {\n        const bw = Math.max(1, W >> 1), bh = Math.max(1, H >> 1)\n        if (bw !== bgW || bh !== bgH) {\n          bgW = bw; bgH = bh\n          if (!bgTex) { bgTex = gl.createTexture(); bgFbo = gl.createFramebuffer() }\n          gl.bindTexture(gl.TEXTURE_2D, bgTex)\n          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, bw, bh, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)\n          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n          gl.bindFramebuffer(gl.FRAMEBUFFER, bgFbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, bgTex, 0)\n        }\n        gl.bindFramebuffer(gl.FRAMEBUFFER, bgFbo)\n        gl.viewport(0, 0, bw, bh)\n        gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND)\n        gl.useProgram(progBg.p)\n        gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, noiseTex); gl.uniform1i(progBg.u.uNoise, 0)\n        gl.uniform2f(progBg.u.uRes, bw, bh); gl.uniform1f(progBg.u.uT, t)\n        // the planet sits where the concept has it on the left stage (circle 620 at -153, -293)\n        const k = bw / (W / dpr), ps = stageBox.s * k\n        gl.uniform3f(progBg.u.uPlanet, stageBox.left * k + (-153 + 310) * ps, bh - (stageBox.top * k + (-293 + 310) * ps), 310 * ps)\n        gl.uniform2f(progBg.u.uPar, pointer.x, pointer.y)\n        gl.drawArrays(gl.TRIANGLES, 0, 3)\n        gl.bindFramebuffer(gl.FRAMEBUFFER, sceneTarget())\n        gl.viewport(0, 0, W, H)\n        gl.useProgram(progBlit.p)\n        gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, bgTex); gl.uniform1i(progBlit.u.uBg, 0)\n        gl.uniform2f(progBlit.u.uRes, W, H)\n        gl.drawArrays(gl.TRIANGLES, 0, 3)\n\n        // far stars (all of them behind the ship)\n        gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE)\n        gl.useProgram(progStar.p)\n        gl.uniformMatrix4fv(progStar.u.uVP, false, VP.VP)\n        gl.uniform1f(progStar.u.uT, starPhase); gl.uniform1f(progStar.u.uLen, 0.6 + boost * 2); gl.uniform2f(progStar.u.uRes, W, H)\n        gl.uniform3f(progStar.u.uDir, heading[0], heading[1], heading[2])\n        gl.bindVertexArray(starVao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, starCount)\n\n        // the trail, under the ship: a wide faint glow and a thin bright core, both fading with age\n        if (trail.length > 1 && progTrail) {\n          const n = trail.length, wpp = 2 * -z * Math.tan(0.31) / Math.max(1, H / dpr) // world units per CSS px at the ship's depth\n          const data = new Float32Array(n * 2 * 4 * 2)\n          let o = 0\n          for (const [hw, amp] of [[3.2, 0.22], [0.9, 0.95]]) {\n            for (const q of trail) {\n              const f = 1 - q.age / TRAIL_LIFE, w = hw * wpp * (0.35 + 0.65 * f), a = amp * f * f * ship.fade\n              data[o++] = q.x; data[o++] = q.y + w; data[o++] = q.z; data[o++] = a\n              data[o++] = q.x; data[o++] = q.y - w; data[o++] = q.z; data[o++] = a\n            }\n          }\n          gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE)\n          gl.useProgram(progTrail.p)\n          gl.uniformMatrix4fv(progTrail.u.uVP, false, VP.VP); gl.uniform3f(progTrail.u.uCol, 0.32, 0.72, 1)\n          gl.bindVertexArray(trailVao); gl.bindBuffer(gl.ARRAY_BUFFER, trailBuf); gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW)\n          gl.drawArrays(gl.TRIANGLE_STRIP, 0, n * 2); gl.drawArrays(gl.TRIANGLE_STRIP, n * 2, n * 2)\n        }\n\n        // the hangar's platforms, under the ships (their front edges are the cards' tops)\n        if (hangarNow && progTrail && hg.world.length) {\n          gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE)\n          gl.useProgram(progTrail.p); gl.uniformMatrix4fv(progTrail.u.uVP, false, VP.VP)\n          gl.bindVertexArray(trailVao); gl.bindBuffer(gl.ARRAY_BUFFER, trailBuf)\n          hg.world.forEach((A, i) => {\n            if (!A) return\n            const sl = hg.slots[i] || { state: 'open' }\n            const col = (sl.team && (sl.state === 'idle' || sl.state === 'open') ? HEX_COL[sl.team].map((c) => (sl.state === 'open' ? c * 0.7 : c)) : HEX_COL[sl.state]) || HEX_COL.open\n            const k = hexA * (sl.state === 'open' ? 0.4 : sl.state === 'ready' ? 0.88 + 0.12 * Math.sin(t * 4 + i) : 1)\n            const data = hexMesh(A, t, i, k)\n            gl.uniform3f(progTrail.u.uCol, col[0], col[1], col[2])\n            gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW)\n            gl.drawArrays(gl.TRIANGLES, 0, data.length / 4)\n          })\n        }\n\n        // ship(s), then the engine glow, then the canopy\n        if (ship.ready) {\n          ship.fade = Math.min(1, ship.fade + dt * 1.2)\n          gl.clear(gl.DEPTH_BUFFER_BIT)\n          const drawShip = (M, sz, rl, a, tint, ship) => {\n            gl.enable(gl.DEPTH_TEST); gl.depthMask(true)\n            gl.disable(gl.BLEND)\n            gl.useProgram(progShip.p)\n            gl.uniformMatrix4fv(progShip.u.uVP, false, VP.VP); gl.uniformMatrix4fv(progShip.u.uM, false, M)\n            gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D_ARRAY, ship.tex); gl.uniform1i(progShip.u.uTex, 1)\n            gl.uniform1f(progShip.u.uFade, a); gl.uniform3f(progShip.u.uEye, 0, 0, 0); gl.uniform3f(progShip.u.uTint, tint[0], tint[1], tint[2])\n            gl.bindVertexArray(ship.vao)\n            gl.uniform1f(progShip.u.uAlphaPass, 0); gl.drawArrays(gl.TRIANGLES, 0, ship.count)\n            gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false)\n            // the glow at the tail: the game scales it by its boost size and flickers it (x0.9 every other\n            // 30 Hz frame); it faces the camera, so from this angle the hull hides part of it\n            const gw = ship.glow, c = xf(mul(V, M), gw.c)\n            const flick = Math.floor(t * 30) % 2 ? 0.9 : 1\n            gl.useProgram(progGlow.p)\n            gl.uniformMatrix4fv(progGlow.u.uP, false, P)\n            gl.uniform3f(progGlow.u.uCenter, c[0], c[1], c[2])\n            gl.uniform2f(progGlow.u.uHalf, gw.hw * sz * glow * flick, gw.hh * sz * glow * flick)\n            gl.uniform1f(progGlow.u.uRoll, rl); gl.uniform1f(progGlow.u.uLayer, gw.layer); gl.uniform1f(progGlow.u.uFade, a)\n            gl.uniform1i(progGlow.u.uTex, 1)\n            gl.bindVertexArray(quadVao); gl.drawArrays(gl.TRIANGLES, 0, 6)\n            gl.useProgram(progShip.p); gl.bindVertexArray(ship.vao)\n            gl.uniform1f(progShip.u.uAlphaPass, 1); gl.drawArrays(gl.TRIANGLES, 0, ship.count)\n            gl.depthMask(true)\n          }\n          const fade = ship.fade\n          if (hangarNow) for (const h of hgShips) drawShip(h.M, h.sz, h.rl, fade * shipAlpha, h.tint, h.model)\n          else drawShip(M, size, roll, fade * shipAlpha, [1, 1, 1], ship)\n        }\n        gl.bindVertexArray(null)\n\n        // the finished scene: resolve it, put it on screen, then the glass over it\n        if (sc) {\n          gl.bindFramebuffer(gl.READ_FRAMEBUFFER, sc.ms); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, sc.fb)\n          gl.blitFramebuffer(0, 0, W, H, 0, 0, W, H, gl.COLOR_BUFFER_BIT, gl.NEAREST)\n          gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n          gl.viewport(0, 0, W, H)\n          gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND)\n          gl.useProgram(progCopy.p)\n          gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, sc.tex); gl.uniform1i(progCopy.u.uSrc, 0)\n          gl.drawArrays(gl.TRIANGLES, 0, 3)\n          if (glassOn) drawGlass(t)\n        }\n      }\n\n      // reticle follows the ship on screen (CSS px); in a transition it stays put and fades\n      if (!!tr !== retHidden) { retHidden = !!tr; mm.classList.toggle('trv', retHidden) }\n      const cs = xf(VP.VP, pos)\n      if (cs[3] > 0 && !tr) {\n        const cssW = W / dpr, cssH = H / dpr\n        const x = (cs[0] / cs[3] * 0.5 + 0.5) * cssW, y = (1 - (cs[1] / cs[3] * 0.5 + 0.5)) * cssH\n        const k = Math.max(0.5, size / -z / Math.tan(0.31) * cssH * 0.5 / 200) // the brackets frame the ship\n        reticle.style.transform = `translate3d(${x.toFixed(1)}px,${y.toFixed(1)}px,0) scale(${k.toFixed(3)})`\n      }\n      // readouts at ~8 Hz\n      if (now >= nextRead) {\n        nextRead = now + 125\n        rSync.textContent = (97.2 + Math.sin(t * 0.8) * 1.1 + Math.random() * 0.4).toFixed(1)\n        rVel.textContent = (0.82 + boost * 0.11 + Math.random() * 0.01).toFixed(2) + 'c'\n        rGd.textContent = glow > 1.8 ? 'MAX' : 'OK'\n        rHdg.textContent = String(Math.round(((yaw * 180 / Math.PI) % 360 + 360) % 360)).padStart(3, '0')\n        rAlt.textContent = String(Math.round(1200 + bob * 900)).padStart(4, '0')\n        rBoost.textContent = Math.round(boost * 100) + '%'\n        const d = new Date(); rClock.textContent = [d.getHours(), d.getMinutes(), d.getSeconds()].map((v) => String(v).padStart(2, '0')).join(':')\n      }\n    }\n    raf = requestAnimationFrame(tick)\n    undo.push(() => cancelAnimationFrame(raf))\n    // the screen the URL asks for, and the pilot's record (written, then read for the profile)\n    route()\n    loadRuns()\n    undo.push(() => {\n      try {\n        const ext = gl && gl.getExtension('WEBGL_lose_context')\n        if (ext) ext.loseContext()\n      } catch (e) {}\n    })\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"routerRef":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"routerRef"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6acaae6ae6d99449482f147e","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Runs_DEV","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF DEV data bridge: an invisible block beside TFSF_MainMenu_DEV that holds modules the menu has\n// no parameters for, and lends them to it on the page's runtime:\n//   rt.runsApi    the TFSF Runs DataSet (`runs`): the pilot's record, for /profile\n//   rt.lobbyApi   the lobbies, lobbyPlayers and signals DataSets, for /multiplayer and the lobby room\n//                 (query, create, remove, and patch: a partial save, as TFSF_Menu_DEV does it)\n//   rt.lobbySock  the lobby socket (`socket`, a PowerCordSocket): emit, subscribe, unsubscribe, onMessage\nexport default {\n  css: `:host { display: block; width: 0; height: 0; overflow: hidden; pointer-events: none; }`,\n  render() { return '' },\n  mounted(d2) {\n    const rt = window.__TFSF_DEV__ || (window.__TFSF_DEV__ = {})\n    const lend = (name) => ({\n      create: (o) => d2.data(name).create(o),\n      query: (q) => d2.data(name).query(q),\n      remove: (o) => d2.data(name).remove(o),\n      patch: (o) => d2.data(name).raw.saveRecord(o),\n    })\n    const s = () => d2.socket('socket')\n    const runs = lend('runs')\n    const lobby = { lobbies: lend('lobbies'), players: lend('lobbyPlayers'), signals: lend('signals') }\n    const sock = {\n      emit: (msg, room) => s().emit(msg, room),\n      subscribe: (room) => s().subscribe(room),\n      unsubscribe: (room) => s().unsubscribe(room),\n      onMessage: (fn) => s().onMessage(fn),\n    }\n    rt.runsApi = runs\n    rt.lobbyApi = lobby\n    rt.lobbySock = sock\n    d2.onUnmount(() => {\n      if (rt.runsApi === runs) rt.runsApi = null\n      if (rt.lobbyApi === lobby) rt.lobbyApi = null\n      if (rt.lobbySock === sock) rt.lobbySock = null\n    })\n  },\n}\n"}},"lobbies":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lobbies"}}}},"lobbyPlayers":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lobbyPlayers"}}}},"runs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"runs"}}}},"signals":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"signals"}}}},"socket":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"socket"}}}}}}}}}},{"_id":"6aceb0a13b0c8ee937027a85","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"MAGI_World","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// MAGI — miniature-look RPG prototype. Raw WebGPU: shadow pass → HDR scene pass → tilt-shift/grade post pass.\n\nconst SCENE_WGSL = `\nstruct U {\n  viewProj: mat4x4f, lightViewProj: mat4x4f,\n  camPos: vec4f, sunDir: vec4f, sunColor: vec4f, skyColor: vec4f, groundColor: vec4f, fogColor: vec4f,\n  lights: array<vec4f, 16>,\n};\n@group(0) @binding(0) var<uniform> u: U;\n@group(0) @binding(1) var shadowTex: texture_depth_2d;\n@group(0) @binding(2) var shadowSmp: sampler_comparison;\n\nstruct VIn {\n  @location(0) pos: vec3f, @location(1) nrm: vec3f, @location(2) col: vec3f,\n  @location(3) ip: vec4f, @location(4) ir: vec4f, @location(5) it: vec4f,\n};\nstruct VOut {\n  @builtin(position) clip: vec4f,\n  @location(0) world: vec3f, @location(1) nrm: vec3f, @location(2) col: vec3f,\n  @location(3) @interpolate(flat) mat: f32,\n};\nstruct P { p: vec3f, n: vec3f };\n\nfn place(v: VIn) -> P {\n  let c = cos(v.ir.x); let s = sin(v.ir.x);\n  var p = v.pos * v.ip.w;\n  let h = max(v.pos.y, 0.0);\n  let t = u.camPos.w;\n  let sw = v.ir.y * h * h * 0.04;\n  p.x += sw * sin(t * 1.3 + v.ir.z);\n  p.z += sw * 0.6 * cos(t * 1.1 + v.ir.z * 1.7);\n  // ir.w: tilt about local z (held/swung/thrown items)\n  let ct = cos(v.ir.w); let st = sin(v.ir.w);\n  p = vec3f(ct * p.x - st * p.y, st * p.x + ct * p.y, p.z);\n  let n0 = vec3f(ct * v.nrm.x - st * v.nrm.y, st * v.nrm.x + ct * v.nrm.y, v.nrm.z);\n  let rp = vec3f(c * p.x + s * p.z, p.y, -s * p.x + c * p.z);\n  let n = vec3f(c * n0.x + s * n0.z, n0.y, -s * n0.x + c * n0.z);\n  return P(rp + v.ip.xyz, n);\n}\n\n@vertex fn vsShadow(v: VIn) -> @builtin(position) vec4f {\n  return u.lightViewProj * vec4f(place(v).p, 1.0);\n}\n\n@vertex fn vsMain(v: VIn) -> VOut {\n  let q = place(v);\n  var o: VOut;\n  o.clip = u.viewProj * vec4f(q.p, 1.0);\n  o.world = q.p; o.nrm = q.n; o.col = v.col * v.it.rgb; o.mat = v.it.w;\n  return o;\n}\n\nfn shadow(world: vec3f, n: vec3f) -> f32 {\n  let lc = u.lightViewProj * vec4f(world + n * 0.06, 1.0);\n  let uv = lc.xy * vec2f(0.5, -0.5) + 0.5;\n  let z = lc.z - 0.001;\n  let tx = 1.0 / 2048.0;\n  var s = 0.0;\n  for (var y = -1; y <= 1; y++) {\n    for (var x = -1; x <= 1; x++) {\n      s += textureSampleCompareLevel(shadowTex, shadowSmp, uv + vec2f(f32(x), f32(y)) * tx * 1.25, z);\n    }\n  }\n  let inside = all(uv > vec2f(0.0)) && all(uv < vec2f(1.0)) && z < 1.0;\n  return select(1.0, s / 9.0, inside);\n}\n\nfn plight(p: vec4f, c: vec4f, world: vec3f, n: vec3f) -> vec3f {\n  let d = p.xyz - world;\n  let dist = length(d);\n  let att = pow(clamp(1.0 - dist / p.w, 0.0, 1.0), 2.0);\n  return c.rgb * c.w * att * max(dot(n, d / max(dist, 1e-3)) * 0.6 + 0.4, 0.0);\n}\n\nfn h2(p: vec2f) -> f32 { return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453); }\nfn n2(p: vec2f) -> f32 {\n  let i = floor(p); let f = fract(p); let w = f * f * (3.0 - 2.0 * f);\n  return mix(mix(h2(i), h2(i + vec2f(1.0, 0.0)), w.x), mix(h2(i + vec2f(0.0, 1.0)), h2(i + vec2f(1.0, 1.0)), w.x), w.y);\n}\n\n@fragment fn fsMain(i: VOut) -> @location(0) vec4f {\n  let n = normalize(i.nrm);\n  let L = normalize(u.sunDir.xyz);\n  let foliage = i.mat > 0.5 && i.mat < 1.5;\n  let nl = dot(n, L);\n  let ndl = select(max(nl, 0.0), clamp((nl + 0.6) / 1.6, 0.0, 1.0), foliage);\n  let sh = shadow(i.world, n);\n  let hemi = mix(u.groundColor.rgb, u.skyColor.rgb, n.y * 0.5 + 0.5);\n  let V = normalize(u.camPos.xyz - i.world);\n  // procedural surface grain: world-space noise breaks up flat albedo (terrain gets extra blade-scale speckle)\n  var alb = i.col;\n  if (i.mat < 0.5) {\n    let q = i.world.xz + vec2f(i.world.y * 0.9, -i.world.y * 0.6);\n    var g = n2(q * 2.3) * 0.55 + n2(q * 9.0) * 0.45;\n    if (i.mat > 0.2) { g = g * 0.6 + n2(i.world.xz * 31.0) * 0.4; }\n    alb *= 0.74 + 0.5 * g;\n  }\n  var lit = alb * (hemi + u.sunColor.rgb * u.sunDir.w * ndl * sh);\n  for (var k = 0u; k < 8u; k++) {\n    let lp = u.lights[k * 2u];\n    if (lp.w > 0.0) { lit += alb * plight(lp, u.lights[k * 2u + 1u], i.world, n); }\n  }\n  if (foliage) {\n    lit += alb * u.sunColor.rgb * pow(1.0 - max(dot(n, V), 0.0), 3.0) * 0.12 * (0.3 + 0.7 * sh);\n  }\n  if (i.mat > 1.5) { lit = i.col * 8.0; }\n  let d = length(u.camPos.xyz - i.world);\n  let f = 1.0 - exp(-max(d - 28.0, 0.0) * u.fogColor.w);\n  let hf = clamp((1.0 - i.world.y) * 0.06, 0.0, 0.6) * clamp(d / 90.0, 0.0, 1.0);\n  lit = mix(lit, u.fogColor.rgb, clamp(f + hf, 0.0, 1.0));\n  return vec4f(lit, 1.0);\n}\n`\n\nconst POST_WGSL = `\nstruct PU {\n  res: vec2f, near: f32, far: f32,\n  focus: f32, range: f32, time: f32, tilt: f32,\n  maxBlur: f32, exposure: f32, a: f32, b: f32,\n  sky: vec4f,\n};\n@group(0) @binding(0) var<uniform> pu: PU;\n@group(0) @binding(1) var hdr: texture_2d<f32>;\n@group(0) @binding(2) var dep: texture_depth_2d;\n@group(0) @binding(3) var smp: sampler;\n\n@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {\n  let p = vec2f(f32((i << 1u) & 2u), f32(i & 2u));\n  return vec4f(p * 2.0 - 1.0, 0.0, 1.0);\n}\n\nfn rawDepth(px: vec2f) -> f32 {\n  return textureLoad(dep, vec2i(clamp(px, vec2f(0.0), pu.res - 1.0)), 0);\n}\nfn lin(d: f32) -> f32 { return pu.near * pu.far / (pu.far - d * (pu.far - pu.near)); }\nfn coc(L: f32, y: f32) -> f32 {\n  let b = clamp(abs(L - pu.focus) / pu.range - 0.1, 0.0, 1.0);\n  let band = smoothstep(0.3, 0.62, abs(y - 0.52));\n  return max(b, band * 0.6) * pu.maxBlur * pu.tilt;\n}\nfn aces(x: vec3f) -> vec3f {\n  return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));\n}\nfn hash(p: vec2f) -> f32 { return fract(sin(dot(p, vec2f(12.9898, 78.233))) * 43758.5453); }\n\nfn scene(uv: vec2f, d: f32) -> vec3f {\n  let c = textureSampleLevel(hdr, smp, uv, 0.0).rgb;\n  // sky: vertical haze gradient behind everything\n  let sky = mix(pu.sky.rgb * 1.08, pu.sky.rgb * 0.82, clamp(uv.y * 1.4, 0.0, 1.0));\n  return select(c, sky, d >= 1.0);\n}\n\n@fragment fn fs(@builtin(position) fc: vec4f) -> @location(0) vec4f {\n  let uv = fc.xy / pu.res;\n  let cd = rawDepth(fc.xy);\n  let cL = lin(cd);\n  let cS = coc(cL, uv.y);\n  var col = scene(uv, cd);\n  var glow = vec3f(0.0);\n  var tot = 1.0;\n  var r = 1.0;\n  var ang = 0.0;\n  for (var k = 0; k < 128; k++) {\n    if (r >= pu.maxBlur) { break; }\n    ang += 2.39996323;\n    let spx = fc.xy + vec2f(cos(ang), sin(ang)) * r;\n    let sd = rawDepth(spx);\n    let sc = scene(spx / pu.res, sd);\n    let sL = lin(sd);\n    var sS = coc(sL, spx.y / pu.res.y);\n    if (sL > cL) { sS = clamp(sS, 0.0, cS * 2.0); }\n    let m = smoothstep(r - 0.5, r + 0.5, sS);\n    col += mix(col / tot, sc, m);\n    glow += max(sc - vec3f(1.7), vec3f(0.0));\n    tot += 1.0;\n    r += 1.0 / r;\n  }\n  col /= tot;\n  col += glow / tot * 1.6;\n  col *= pu.exposure;\n  col = aces(col);\n  col = pow(col, vec3f(1.0 / 2.2));\n  let l = dot(col, vec3f(0.299, 0.587, 0.114));\n  col = mix(vec3f(l), col, 0.9);\n  col = col * vec3f(1.02, 1.0, 0.95) + vec3f(0.012, 0.014, 0.022);\n  let v = uv - 0.5;\n  col *= 1.0 - dot(v, v) * 0.85;\n  col += (hash(fc.xy + fract(pu.time) * 91.0) - 0.5) * 0.03;\n  return vec4f(col, 1.0);\n}\n`\n\n// ---------- math ----------\nconst M4 = {\n  persp(fov, a, n, f) {\n    const t = 1 / Math.tan(fov / 2), m = new Float32Array(16)\n    m[0] = t / a; m[5] = t; m[10] = f / (n - f); m[11] = -1; m[14] = (n * f) / (n - f)\n    return m\n  },\n  ortho(l, r, b, t, n, f) {\n    const m = new Float32Array(16)\n    m[0] = 2 / (r - l); m[5] = 2 / (t - b); m[10] = 1 / (n - f)\n    m[12] = -(r + l) / (r - l); m[13] = -(t + b) / (t - b); m[14] = n / (n - f); m[15] = 1\n    return m\n  },\n  lookAt(e, c, up) {\n    const z = norm(sub(e, c)), x = norm(cross(up, z)), y = cross(z, x)\n    return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, e), -dot(y, e), -dot(z, e), 1])\n  },\n  mul(a, b) {\n    const o = new Float32Array(16)\n    for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {\n      let s = 0\n      for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]\n      o[c * 4 + r] = s\n    }\n    return o\n  },\n  inv(m) {\n    const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m\n    const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10\n    const b03 = a01 * a12 - a02 * a11, b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12\n    const b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30, b08 = a20 * a33 - a23 * a30\n    const b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32\n    const d = 1 / (b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06)\n    return new Float32Array([\n      (a11 * b11 - a12 * b10 + a13 * b09) * d, (a02 * b10 - a01 * b11 - a03 * b09) * d,\n      (a31 * b05 - a32 * b04 + a33 * b03) * d, (a22 * b04 - a21 * b05 - a23 * b03) * d,\n      (a12 * b08 - a10 * b11 - a13 * b07) * d, (a00 * b11 - a02 * b08 + a03 * b07) * d,\n      (a32 * b02 - a30 * b05 - a33 * b01) * d, (a20 * b05 - a22 * b02 + a23 * b01) * d,\n      (a10 * b10 - a11 * b08 + a13 * b06) * d, (a01 * b08 - a00 * b10 - a03 * b06) * d,\n      (a30 * b04 - a31 * b02 + a33 * b00) * d, (a21 * b02 - a20 * b04 - a23 * b00) * d,\n      (a11 * b07 - a10 * b09 - a12 * b06) * d, (a00 * b09 - a01 * b07 + a02 * b06) * d,\n      (a31 * b01 - a30 * b03 - a32 * b00) * d, (a20 * b03 - a21 * b01 + a22 * b00) * d,\n    ])\n  },\n  rotZ(a) { const c = Math.cos(a), s = Math.sin(a), m = new Float32Array(16); m[0] = c; m[1] = s; m[4] = -s; m[5] = c; m[10] = 1; m[15] = 1; return m },\n  xform(m, v) {\n    const [x, y, z] = v, w = m[3] * x + m[7] * y + m[11] * z + m[15]\n    return [(m[0] * x + m[4] * y + m[8] * z + m[12]) / w, (m[1] * x + m[5] * y + m[9] * z + m[13]) / w, (m[2] * x + m[6] * y + m[10] * z + m[14]) / w]\n  },\n}\nfunction sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }\nfunction dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }\nfunction cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] }\nfunction norm(a) { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l] }\nconst clamp = (x, a, b) => Math.min(b, Math.max(a, x))\nconst sstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t) }\nconst mix = (a, b, t) => a + (b - a) * t\nconst mix3 = (a, b, t) => [mix(a[0], b[0], t), mix(a[1], b[1], t), mix(a[2], b[2], t)]\n// colors are authored in sRGB, shaded in linear\nconst C = (r, g, b) => [r ** 2.2, g ** 2.2, b ** 2.2]\n\nfunction rng(seed) {\n  return () => {\n    seed |= 0; seed = (seed + 0x6d2b79f5) | 0\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n  }\n}\n\n// ---------- world shape ----------\nfunction hash2(x, z) { const s = Math.sin(x * 127.1 + z * 311.7) * 43758.5453; return s - Math.floor(s) }\nfunction vnoise(x, z) {\n  const ix = Math.floor(x), iz = Math.floor(z), fx = x - ix, fz = z - iz\n  const ux = fx * fx * (3 - 2 * fx), uz = fz * fz * (3 - 2 * fz)\n  const a = hash2(ix, iz), b = hash2(ix + 1, iz), c = hash2(ix, iz + 1), d = hash2(ix + 1, iz + 1)\n  return (mix(mix(a, b, ux), mix(c, d, ux), uz)) * 2 - 1\n}\nfunction fbm(x, z, o = 4) { let s = 0, a = 0.5, f = 1; for (let i = 0; i < o; i++) { s += a * vnoise(x * f, z * f); f *= 2.03; a *= 0.5 } return s }\n\nconst VILLAGE = [0, 0]\nconst RUIN = [24, -16]\nconst PATH = [[-8, 60], [-5, 40], [-2, 24], [0, 12], [2, 3], [8, -6], [16, -12], [22, -13.5]]\nfunction pathDist(x, z) {\n  let best = 1e9\n  for (let i = 0; i < PATH.length - 1; i++) {\n    const [ax, az] = PATH[i], [bx, bz] = PATH[i + 1]\n    const dx = bx - ax, dz = bz - az\n    const t = clamp(((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz), 0, 1)\n    best = Math.min(best, Math.hypot(x - ax - dx * t, z - az - dz * t))\n  }\n  return best + fbm(x * 0.3, z * 0.3, 2) * 0.6\n}\nfunction cliffEdge(x, z) { return -x * 0.75 - z * 0.35 + fbm(x * 0.05 + 7, z * 0.05, 3) * 12 }\nfunction heightAt(x, z) {\n  const n = fbm(x * 0.03, z * 0.03)\n  let h = n * 2.6 + fbm(x * 0.13, z * 0.13, 2) * 0.35\n  const e = cliffEdge(x, z)\n  h += sstep(24, 27, e) * 7 + sstep(40, 42.5, e) * 6 + sstep(27, 60, e) * 2\n  h -= sstep(78, 100, Math.hypot(x, z)) * 14\n  const v = 1 - sstep(9, 17, Math.hypot(x - VILLAGE[0], z - VILLAGE[1]))\n  h = mix(h, 0.4 + n * 0.4, v * 0.9)\n  const rv = 1 - sstep(8, 14, Math.hypot(x - RUIN[0] + 4, z - RUIN[1]))\n  h = mix(h, 0.6 + n * 0.3, rv * 0.85)\n  h -= (1 - sstep(1.2, 2.6, pathDist(x, z))) * 0.12\n  return h\n}\n\n// ---------- meshes ----------\nfunction newMesh() { return { v: [], i: [] } }\nfunction addV(m, p, n, c) { m.v.push(p[0], p[1], p[2], n[0], n[1], n[2], c[0], c[1], c[2]); return m.v.length / 9 - 1 }\nfunction face(m, pts, col, center) {\n  let n = norm(cross(sub(pts[1], pts[0]), sub(pts[2], pts[0])))\n  const cen = pts.reduce((a, p) => [a[0] + p[0] / pts.length, a[1] + p[1] / pts.length, a[2] + p[2] / pts.length], [0, 0, 0])\n  if (dot(n, sub(cen, center)) < 0) n = [-n[0], -n[1], -n[2]]\n  const ids = pts.map(p => addV(m, p, n, col))\n  for (let k = 1; k < ids.length - 1; k++) m.i.push(ids[0], ids[k], ids[k + 1])\n}\nfunction rotY(p, c, a) {\n  const s = Math.sin(a), co = Math.cos(a), x = p[0] - c[0], z = p[2] - c[2]\n  return [c[0] + co * x + s * z, p[1], c[2] - s * x + co * z]\n}\nfunction box(m, c, s, col, ry = 0, top = null) {\n  const [hx, hy, hz] = [s[0] / 2, s[1] / 2, s[2] / 2]\n  const P = (x, y, z) => rotY([c[0] + x * hx, c[1] + y * hy, c[2] + z * hz], c, ry)\n  const v = [P(-1, -1, -1), P(1, -1, -1), P(1, -1, 1), P(-1, -1, 1), P(-1, 1, -1), P(1, 1, -1), P(1, 1, 1), P(-1, 1, 1)]\n  const dark = col.map(x => x * 0.82)\n  face(m, [v[0], v[1], v[5], v[4]], dark, c); face(m, [v[1], v[2], v[6], v[5]], col, c)\n  face(m, [v[2], v[3], v[7], v[6]], dark, c); face(m, [v[3], v[0], v[4], v[7]], col, c)\n  face(m, [v[4], v[5], v[6], v[7]], top || col, c); face(m, [v[0], v[3], v[2], v[1]], dark, c)\n}\nfunction gable(m, c, w, d, h, col) {\n  const [x, y, z] = c, A = [x - w / 2, y, z - d / 2], B = [x + w / 2, y, z - d / 2], Cc = [x + w / 2, y, z + d / 2], D = [x - w / 2, y, z + d / 2]\n  const R1 = [x - w / 2, y + h, z], R2 = [x + w / 2, y + h, z], cen = [x, y + h * 0.3, z]\n  face(m, [A, B, R2, R1], col, cen); face(m, [D, Cc, R2, R1], col.map(v => v * 0.9), cen)\n  face(m, [A, R1, D], col.map(v => v * 0.7), cen); face(m, [B, R2, Cc], col.map(v => v * 0.7), cen)\n  face(m, [A, B, Cc, D], col.map(v => v * 0.4), [x, y + 1, z])\n}\nfunction cyl(m, c, r0, r1, h, seg, col, jit = 0, R = Math.random) {\n  const base = m.v.length / 9\n  for (let k = 0; k <= seg; k++) {\n    const a = (k % seg) / seg * Math.PI * 2, ca = Math.cos(a), sa = Math.sin(a)\n    const j = 1 + (k % seg === 0 ? 0 : (R() - 0.5) * jit)\n    const n = norm([ca * h, r0 - r1, sa * h])\n    addV(m, [c[0] + ca * r0 * j, c[1], c[2] + sa * r0 * j], n, col.map(v => v * 0.7))\n    addV(m, [c[0] + ca * r1 * j, c[1] + h, c[2] + sa * r1 * j], n, col)\n  }\n  for (let k = 0; k < seg; k++) { const a = base + k * 2; m.i.push(a, a + 1, a + 3, a, a + 3, a + 2) }\n  if (r1 > 0.001) {\n    const top = addV(m, [c[0], c[1] + h, c[2]], [0, 1, 0], col)\n    for (let k = 0; k < seg; k++) {\n      const a = (k / seg) * Math.PI * 2, b = ((k + 1) / seg) * Math.PI * 2\n      const ia = addV(m, [c[0] + Math.cos(a) * r1, c[1] + h, c[2] + Math.sin(a) * r1], [0, 1, 0], col)\n      const ib = addV(m, [c[0] + Math.cos(b) * r1, c[1] + h, c[2] + Math.sin(b) * r1], [0, 1, 0], col)\n      m.i.push(top, ia, ib)\n    }\n  }\n}\nlet ICO = {}\nfunction icosphere(sub) {\n  if (ICO[sub]) return ICO[sub]\n  const t = (1 + Math.sqrt(5)) / 2\n  let v = [[-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0], [0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t], [t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1]].map(norm)\n  let f = [[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1]]\n  for (let s = 0; s < sub; s++) {\n    const cache = new Map(), nf = []\n    const mid = (a, b) => {\n      const k = a < b ? a + '_' + b : b + '_' + a\n      if (!cache.has(k)) { v.push(norm([(v[a][0] + v[b][0]) / 2, (v[a][1] + v[b][1]) / 2, (v[a][2] + v[b][2]) / 2])); cache.set(k, v.length - 1) }\n      return cache.get(k)\n    }\n    for (const [a, b, c] of f) { const ab = mid(a, b), bc = mid(b, c), ca = mid(c, a); nf.push([a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]) }\n    f = nf\n  }\n  return (ICO[sub] = { v, f })\n}\n// lumpy sphere with recomputed smooth normals; colFn(dir) gives per-vertex color\nfunction blob(m, c, r, sq, sub, jit, colFn, R) {\n  const { v, f } = icosphere(sub), base = m.v.length / 9\n  const pts = v.map(d => {\n    const j = 1 + (R() - 0.5) * jit\n    return [c[0] + d[0] * r * sq[0] * j, c[1] + d[1] * r * sq[1] * j, c[2] + d[2] * r * sq[2] * j]\n  })\n  const nrm = pts.map(() => [0, 0, 0])\n  for (const [a, b, cc] of f) {\n    const n = cross(sub3(pts[b], pts[a]), sub3(pts[cc], pts[a]))\n    for (const k of [a, b, cc]) { nrm[k][0] += n[0]; nrm[k][1] += n[1]; nrm[k][2] += n[2] }\n  }\n  pts.forEach((p, k) => {\n    let n = norm(nrm[k]); if (dot(n, v[k]) < 0) n = [-n[0], -n[1], -n[2]]\n    addV(m, p, n, colFn(v[k]))\n  })\n  for (const [a, b, cc] of f) m.i.push(base + a, base + b, base + cc)\n}\nconst sub3 = sub\n\nfunction buildMeshes() {\n  const R = rng(7)\n  const meshes = {}\n  // tree: trunk + clustered foliage; foliage is white-ish so the instance tint picks the hue\n  {\n    const m = newMesh()\n    cyl(m, [0, 0, 0], 0.2, 0.09, 2.0, 6, C(0.32, 0.24, 0.17))\n    const lobes = [[0, 2.4, 0, 1.0]]\n    for (let k = 0; k < 10; k++) { const a = k * 2.4, r = 0.55 + R() * 0.45, y = 1.7 + R() * 1.5; lobes.push([Math.cos(a) * r, y, Math.sin(a) * r, 0.5 + R() * 0.3]) }\n    for (const [x, y, z, r] of lobes) blob(m, [x, y, z], r, [1, 0.85, 1], 2, 0.38, d => {\n      const k = (0.4 + 0.6 * (d[1] * 0.5 + 0.5)) * (0.72 + R() * 0.3)\n      return [k, k, k]\n    }, R)\n    meshes.tree = m\n  }\n  {\n    const m = newMesh()\n    const bc = d => { const k = (0.45 + 0.55 * (d[1] * 0.5 + 0.5)) * (0.8 + R() * 0.35); return [k, k, k] }\n    for (const [x, y, z, r] of [[0, 0.35, 0, 0.6], [0.5, 0.25, 0.3, 0.45], [-0.45, 0.25, 0.2, 0.42], [0.1, 0.3, -0.45, 0.45], [0.05, 0.65, 0.1, 0.4]]) blob(m, [x, y, z], r, [1.1, 0.8, 1], 2, 0.5, bc, R)\n    meshes.bush = m\n  }\n  // rock slab with moss on top\n  {\n    const m = newMesh(), grey = C(0.56, 0.55, 0.52), moss = C(0.45, 0.46, 0.22)\n    blob(m, [0, 0.1, 0], 1, [1.5, 0.55, 1.1], 1, 0.45, d => mix3(grey, moss, sstep(0.55, 0.9, d[1]) * 0.7).map(x => x * (0.85 + R() * 0.25)), R)\n    meshes.rock = m\n  }\n  // hut: plank walls, beams, thatch/shingle gable roof\n  {\n    const m = newMesh(), wood = C(0.33, 0.27, 0.21), beam = C(0.2, 0.16, 0.12), roof = C(0.38, 0.34, 0.28)\n    box(m, [0, 1.15, 0], [4.2, 2.3, 3.2], wood)\n    for (const [x, z] of [[-2.1, -1.6], [2.1, -1.6], [2.1, 1.6], [-2.1, 1.6]]) box(m, [x, 1.2, z], [0.28, 2.4, 0.28], beam)\n    box(m, [0, 2.25, 1.62], [4.4, 0.18, 0.12], beam); box(m, [0, 2.25, -1.62], [4.4, 0.18, 0.12], beam)\n    box(m, [0.6, 0.85, 1.62], [0.9, 1.7, 0.08], C(0.1, 0.08, 0.07))\n    box(m, [-1.2, 1.4, 1.62], [0.7, 0.5, 0.08], C(0.12, 0.1, 0.08))\n    gable(m, [0, 2.3, 0], 5.0, 4.2, 1.7, roof)\n    box(m, [2.6, 0.4, 0.6], [0.8, 0.8, 0.8], C(0.3, 0.25, 0.18), 0.4)\n    meshes.hut = m\n  }\n  // ruined chapel: square tower + broken nave walls with arched gaps\n  {\n    const m = newMesh(), st = C(0.6, 0.57, 0.5), dk = C(0.47, 0.45, 0.4), ivy = C(0.4, 0.42, 0.28)\n    box(m, [0, 5.5, 0], [3.6, 11, 3.6], st)\n    box(m, [0, 0.4, 0], [4.2, 0.8, 4.2], dk)\n    box(m, [0, 7.8, 0], [3.9, 0.3, 3.9], dk); box(m, [0, 10.9, 0], [4.0, 0.35, 4.0], dk)\n    for (const [x, z] of [[-1.6, -1.6], [1.6, -1.6], [1.6, 1.6], [-1.6, 1.6], [0, -1.6], [0, 1.6], [1.6, 0], [-1.6, 0]]) box(m, [x, 11.5, z], [0.7, 0.9, 0.7], st)\n    for (const y of [3.5, 6.2, 9.2]) for (const s of [-1, 1]) box(m, [0, y, s * 1.82], [0.55, 1.1, 0.06], C(0.08, 0.07, 0.07))\n    box(m, [3.4, 1.9, 0], [3.2, 3.8, 2.6], st)\n    gable(m, [3.4, 3.8, 0], 3.2, 2.9, 1.2, dk)\n    box(m, [5.03, 1.1, 0], [0.08, 2.2, 1.1], C(0.06, 0.05, 0.05))\n    // nave: two walls of piers with varying broken heights, plus a gable end\n    for (const s of [-1, 1]) for (let k = 0; k < 6; k++) {\n      const x = -3.2 - k * 1.8, hgt = [6, 5.4, 4.2, 5.0, 2.8, 1.6][k] * (s > 0 ? 1 : 0.85)\n      box(m, [x, hgt / 2, s * 3], [0.9, hgt, 0.7], k % 2 ? st : dk)\n      if (k < 5 && hgt > 3) box(m, [x - 0.9, hgt - 0.6, s * 3], [1.0, 1.2, 0.6], st)\n      if (k % 2 === 0) box(m, [x + 0.15, hgt * 0.3, s * 3.37], [0.5, hgt * 0.45, 0.05], ivy)\n    }\n    box(m, [-13.2, 2.4, 0], [0.7, 4.8, 6.6], dk)\n    box(m, [-7, 0.15, 0], [12, 0.3, 6.4], C(0.42, 0.4, 0.34))\n    meshes.ruin = m\n  }\n  // lamp pole with horned crossbar\n  {\n    const m = newMesh(), iron = C(0.12, 0.11, 0.1)\n    cyl(m, [0, 0, 0], 0.07, 0.05, 3.2, 5, iron)\n    box(m, [0.35, 3.25, 0], [0.75, 0.07, 0.07], iron, 0); box(m, [-0.35, 3.25, 0], [0.75, 0.07, 0.07], iron, 0)\n    box(m, [0.72, 3.42, 0], [0.07, 0.4, 0.07], iron); box(m, [-0.72, 3.42, 0], [0.07, 0.4, 0.07], iron)\n    box(m, [0, 2.9, 0], [0.16, 0.3, 0.16], C(0.25, 0.22, 0.18))\n    meshes.pole = m\n  }\n  // villager: robe, head, straw hat\n  {\n    const m = newMesh()\n    cyl(m, [0, 0, 0], 0.3, 0.13, 0.8, 8, C(0.86, 0.84, 0.78))\n    blob(m, [0, 0.95, 0], 0.16, [1, 1.05, 1], 1, 0.05, () => C(0.78, 0.62, 0.5), R)\n    cyl(m, [0, 1.04, 0], 0.32, 0.0, 0.2, 9, C(0.7, 0.6, 0.38))\n    box(m, [0, 0.5, -0.2], [0.34, 0.45, 0.16], C(0.42, 0.33, 0.24))\n    meshes.villager = m\n  }\n  // player knight: gilded armour, blue cape, shield\n  {\n    const m = newMesh(), gold = C(0.78, 0.62, 0.3), blue = C(0.18, 0.28, 0.62)\n    cyl(m, [0, 0, 0], 0.26, 0.2, 0.75, 8, gold)\n    blob(m, [0, 0.92, 0], 0.19, [1, 1.1, 1], 1, 0.05, () => gold, R)\n    box(m, [0, 0.55, -0.22], [0.5, 0.75, 0.06], blue)\n    blob(m, [0.3, 0.55, 0.05], 0.26, [0.25, 1, 1], 1, 0.05, () => C(0.5, 0.4, 0.25), R)\n    box(m, [-0.3, 0.9, 0.12], [0.05, 1.1, 0.05], C(0.75, 0.75, 0.72), 0)\n    meshes.knight = m\n  }\n  // grass tuft: a fan of thin blades, dark at the root\n  {\n    const m = newMesh()\n    for (let k = 0; k < 7; k++) {\n      const a = k * 2.2 + R() * 0.5, lean = 0.25 + R() * 0.35, h = 0.6 + R() * 0.6, w = 0.05\n      const ca = Math.cos(a), sa = Math.sin(a), n = norm([ca, 0.6, sa])\n      addV(m, [-sa * w, 0, ca * w], n, [0.45, 0.45, 0.45]); addV(m, [sa * w, 0, -ca * w], n, [0.45, 0.45, 0.45])\n      addV(m, [ca * lean, h, sa * lean], n, [1.1, 1.1, 1.1])\n      const b = m.v.length / 9 - 3; m.i.push(b, b + 1, b + 2)\n    }\n    meshes.tuft = m\n  }\n  {\n    const m = newMesh()\n    blob(m, [0, 0, 0], 1, [1, 0.7, 1], 0, 0.5, d => { const k = 0.7 + 0.3 * d[1]; return [k, k, k] }, R)\n    meshes.flower = m\n  }\n  {\n    const m = newMesh()\n    blob(m, [0, 0, 0], 1, [1, 1, 1], 1, 0.1, () => [1, 1, 1], R)\n    meshes.ember = m\n  }\n  {\n    const m = newMesh()\n    cyl(m, [0, 0, 0], 1, 0.25, 1, 9, C(0.5, 0.5, 0.52), 0.5, R)\n    meshes.spire = m\n  }\n  // pickups: pebble, and a stick lying along +x from its pivot (so a held stick swings from the grip)\n  {\n    const m = newMesh()\n    blob(m, [0, 0, 0], 0.17, [1.2, 0.65, 1], 1, 0.35, () => C(0.55, 0.54, 0.51).map(x => x * (0.85 + R() * 0.3)), R)\n    meshes.pebble = m\n  }\n  {\n    const m = newMesh(), bark = C(0.45, 0.32, 0.21)\n    box(m, [0.42, 0.035, 0], [0.95, 0.07, 0.07], bark)\n    box(m, [0.55, 0.035, 0.09], [0.3, 0.05, 0.05], bark, -0.7)\n    meshes.stick = m\n  }\n  // lightning bolt: jagged crossed-quad beam rising from the strike point, with two forks\n  {\n    const m = newMesh(), col = [1, 1, 1]\n    const beam = (a, b, w) => {\n      for (const [px, pz] of [[w, 0], [0, w]]) face(m, [[a[0] - px, a[1], a[2] - pz], [a[0] + px, a[1], a[2] + pz], [b[0] + px, b[1], b[2] + pz], [b[0] - px, b[1], b[2] - pz]], col, [a[0] + pz, a[1], a[2] + px])\n    }\n    const path = (x, y, z, len, steps, w) => { let p = [x, y, z]; for (let k = 0; k < steps; k++) { const q = [p[0] + (R() - 0.5) * 1.4, p[1] + len / steps, p[2] + (R() - 0.5) * 1.4]; beam(p, q, w); p = q } }\n    path(0, 0, 0, 26, 14, 0.1)\n    path(0.3, 9, 0.2, 5, 4, 0.05); path(-0.4, 15, -0.1, 4, 3, 0.05)\n    meshes.bolt = m\n  }\n  // unit ring of dashes for spell reticles (instance scale = radius)\n  {\n    const m = newMesh()\n    for (let k = 0; k < 28; k++) { const a = (k / 28) * Math.PI * 2; box(m, [Math.cos(a), 0.02, Math.sin(a)], [0.05, 0.04, 0.14], [1, 1, 1], -a) }\n    meshes.ring = m\n  }\n  // aim indicator pieces: a unit flat tile and an arrowhead, both pointing along +z\n  {\n    const m = newMesh(), n = [0, 1, 0], c = [1, 1, 1]\n    const q = [[-0.5, 0, -0.5], [0.5, 0, -0.5], [0.5, 0, 0.5], [-0.5, 0, 0.5]].map(p => addV(m, p, n, c))\n    m.i.push(q[0], q[1], q[2], q[0], q[2], q[3])\n    meshes.plate = m\n  }\n  {\n    const m = newMesh(), n = [0, 1, 0], c = [1, 1, 1]\n    const t = [[-0.6, 0, 0], [0.6, 0, 0], [0, 0, 0.9]].map(p => addV(m, p, n, c))\n    m.i.push(t[0], t[1], t[2])\n    meshes.arrowhead = m\n  }\n  meshes.terrain = buildTerrain()\n  return meshes\n}\n\nfunction buildTerrain() {\n  const m = newMesh(), N = 220, S = 200, step = S / N\n  const H = new Float32Array((N + 1) * (N + 1))\n  for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) H[j * (N + 1) + i] = heightAt(-S / 2 + i * step, -S / 2 + j * step)\n  const h = (i, j) => H[clamp(j, 0, N) * (N + 1) + clamp(i, 0, N)]\n  const olive = C(0.4, 0.42, 0.21), ochre = C(0.6, 0.5, 0.23), pink = C(0.74, 0.45, 0.5)\n  const dirt = C(0.38, 0.32, 0.26), rock = C(0.5, 0.49, 0.46), dark = C(0.3, 0.3, 0.2)\n  for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) {\n    const x = -S / 2 + i * step, z = -S / 2 + j * step, y = h(i, j)\n    const n = norm([h(i - 1, j) - h(i + 1, j), 2 * step, h(i, j - 1) - h(i, j + 1)])\n    let col = mix3(olive, ochre, sstep(-0.25, 0.35, fbm(x * 0.05 + 3, z * 0.05 - 2, 3)))\n    col = mix3(col, dark, sstep(0.1, 0.5, fbm(x * 0.2, z * 0.2, 2)) * 0.25)\n    const pk = sstep(0.18, 0.42, fbm(x * 0.14 + 11, z * 0.14, 3)) * (0.35 + 0.65 * (1 - sstep(10, 40, Math.hypot(x, z))))\n    col = mix3(col, pink, pk * 0.75)\n    col = mix3(col, dirt, 1 - sstep(1.0, 2.4, pathDist(x, z)))\n    col = mix3(col, rock, sstep(0.22, 0.45, 1 - n[1]))\n    col = col.map(c => c * (0.9 + hash2(x * 3.1, z * 2.7) * 0.2))\n    addV(m, [x, y, z], n, col)\n  }\n  for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {\n    const a = j * (N + 1) + i, b = a + 1, c = a + N + 1, d = c + 1\n    m.i.push(a, c, b, b, c, d)\n  }\n  m.H = H; m.N = N; m.S = S\n  return m\n}\n\nfunction slopeAt(x, z) {\n  const e = 0.6\n  return Math.hypot(heightAt(x + e, z) - heightAt(x - e, z), heightAt(x, z + e) - heightAt(x, z - e)) / (2 * e)\n}\n\n// instance record: pos.xyz scale | rotY sway phase _ | tint.rgb mat\nfunction placeWorld() {\n  const R = rng(1337), I = { tree: [], bush: [], rock: [], hut: [], ruin: [], pole: [], flower: [], spire: [], terrain: [[0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0.3]], tuft: [] }\n  const put = (k, x, z, s, ry, sway, tint, mat = 0, dy = 0) => I[k].push([x, heightAt(x, z) + dy, z, s, ry, sway, R() * 6.28, 0, tint[0], tint[1], tint[2], mat])\n  const leaf = [C(0.95, 0.72, 0.16), C(0.9, 0.62, 0.12), C(0.85, 0.48, 0.14), C(0.98, 0.8, 0.3), C(0.72, 0.62, 0.22)]\n  const blossom = C(0.95, 0.55, 0.66)\n  const clear = (x, z, r) => Math.hypot(x - VILLAGE[0], z - VILLAGE[1]) > r && Math.hypot(x - RUIN[0] + 5, z - RUIN[1]) > 11 && pathDist(x, z) > 3.2\n  let n = 0\n  while (I.tree.length < 340 && n++ < 20000) {\n    const x = (R() - 0.5) * 170, z = (R() - 0.5) * 170\n    if (Math.hypot(x, z) > 86 || !clear(x, z, 14) || slopeAt(x, z) > 0.55) continue\n    if (fbm(x * 0.045 + 40, z * 0.045, 3) < -0.05 + R() * 0.15) continue\n    const pinkTree = R() < 0.08\n    put('tree', x, z, 0.8 + R() * 0.7, R() * 6.28, 1, pinkTree ? blossom : leaf[(R() * leaf.length) | 0], 1, -0.1)\n  }\n  n = 0\n  while (I.bush.length < 260 && n++ < 20000) {\n    const x = (R() - 0.5) * 160, z = (R() - 0.5) * 160\n    if (!clear(x, z, 8) || slopeAt(x, z) > 0.6) continue\n    put('bush', x, z, 0.6 + R() * 0.8, R() * 6.28, 0.6, R() < 0.35 ? leaf[(R() * 5) | 0].map(c => c * 0.8) : [C(0.45, 0.5, 0.24), C(0.55, 0.52, 0.22), C(0.38, 0.42, 0.2)][(R() * 3) | 0], 1)\n  }\n  n = 0\n  while (I.rock.length < 110 && n++ < 20000) {\n    const x = (R() - 0.5) * 160, z = (R() - 0.5) * 160\n    if (pathDist(x, z) < 2.4 || Math.hypot(x, z) < 9) continue\n    const e = cliffEdge(x, z), nearCliff = Math.abs(e - 25) < 5 || Math.abs(e - 41) < 4\n    if (!nearCliff && R() > 0.25) continue\n    put('rock', x, z, 0.7 + R() * 1.8, R() * 6.28, 0, [1, 1, 1], 0, -0.2)\n  }\n  const huts = [[-7, -3, 0.3], [6, 5, -0.5], [-4, 8, 1.4], [9, -4, 0.9], [-10, 6, 2.1]]\n  for (const [x, z, r] of huts) put('hut', x, z, 1, r, 0, [1, 1, 1], 0, -0.05)\n  put('ruin', RUIN[0], RUIN[1], 1, 0.35, 0, [1, 1, 1], 0, -0.1)\n  for (let k = 0; k < PATH.length - 1; k++) {\n    const [ax, az] = PATH[k], [bx, bz] = PATH[k + 1], L = Math.hypot(bx - ax, bz - az)\n    for (let t = 2; t < L; t += 7) {\n      const px = ax + (bx - ax) * t / L, pz = az + (bz - az) * t / L, nx = -(bz - az) / L, nz = (bx - ax) / L\n      const side = (k + Math.round(t)) % 2 ? 2.6 : -2.6\n      put('pole', px + nx * side, pz + nz * side, 1, Math.atan2(bx - ax, bz - az), 0, [1, 1, 1], 0, -0.1)\n    }\n  }\n  n = 0\n  const fl = [C(0.86, 0.46, 0.56), C(0.9, 0.56, 0.64), C(0.85, 0.7, 0.28), C(0.8, 0.78, 0.7), C(0.5, 0.55, 0.26)]\n  while (I.flower.length < 9000 && n++ < 90000) {\n    const x = (R() - 0.5) * 150, z = (R() - 0.5) * 150\n    if (pathDist(x, z) < 1.6 || slopeAt(x, z) > 0.4) continue\n    const pk = fbm(x * 0.14 + 11, z * 0.14, 3)\n    const tint = pk > 0.15 && R() < 0.85 ? fl[(R() * 2) | 0] : fl[2 + ((R() * 3) | 0)]\n    put('flower', x, z, 0.07 + R() * 0.1, R() * 6.28, 3, tint, 1, 0.02)\n  }\n  n = 0\n  const gr = [C(0.48, 0.5, 0.24), C(0.62, 0.54, 0.24), C(0.42, 0.44, 0.22), C(0.7, 0.6, 0.3)]\n  while (I.tuft.length < 16000 && n++ < 120000) {\n    const x = (R() - 0.5) * 150, z = (R() - 0.5) * 150\n    if (pathDist(x, z) < 1.4 || slopeAt(x, z) > 0.45 || Math.hypot(x, z) > 80) continue\n    put('tuft', x, z, 0.35 + R() * 0.45, R() * 6.28, 4, gr[(R() * gr.length) | 0], 1, -0.02)\n  }\n  for (let k = 0; k < 9; k++) {\n    const a = -2.2 + k * 0.45 + R() * 0.2, r = 110 + R() * 40\n    const x = Math.cos(a) * r, z = Math.sin(a) * r\n    I.spire.push([x, -14, z, 1, R() * 6, 0, 0, 0, 1, 1, 1, 0])\n  }\n  return I\n}\n\n// ---------- items, spells, colliders, pickups ----------\nconst ITEMS = {\n  stick: {\n    name: 'Stick', max: 64, action: 'Swing',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M6 26 26 6\" stroke=\"#6e4b2c\" stroke-width=\"3.4\" stroke-linecap=\"round\"/><path d=\"M15 17 10 10\" stroke=\"#6e4b2c\" stroke-width=\"2.2\" stroke-linecap=\"round\"/><path d=\"M8 24 24 8\" stroke=\"#a87c50\" stroke-width=\"1\" stroke-linecap=\"round\"/></svg>`,\n  },\n  pebble: {\n    name: 'Pebble', max: 64, action: 'Throw',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M6 20c0-6 5-10 11-10s9 3 9 8-4 7-10 7-10-1-10-5z\" fill=\"#8d8a84\"/><path d=\"M11 15c2-2 5-3 8-2\" stroke=\"#c9c5bc\" stroke-width=\"1.6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`,\n  },\n  torch: {\n    name: 'Torch', max: 16, action: 'Plant',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M9 27 21 13\" stroke=\"#6e4b2c\" stroke-width=\"3.4\" stroke-linecap=\"round\"/><path d=\"M22 4c3 4 5 6 3 9s-6 3-7 0 2-5 4-9z\" fill=\"#ff9a3d\"/><path d=\"M22 8c1 2 2 3 1 4.5s-3 1-3.4 0 1-2.5 2.4-4.5z\" fill=\"#ffe08a\"/></svg>`,\n  },\n  ember: {\n    name: 'Ember Stone', max: 16, action: 'Throw',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M6 20c0-6 5-10 11-10s9 3 9 8-4 7-10 7-10-1-10-5z\" fill=\"#e0612a\"/><path d=\"M11 16c2-2 6-3 9-1\" stroke=\"#ffd27a\" stroke-width=\"1.8\" fill=\"none\" stroke-linecap=\"round\"/></svg>`,\n  },\n  shard: {\n    name: 'Storm Shard', max: 16, action: 'Throw',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M16 3 24 13 19 29 11 27 8 12z\" fill=\"#5e8fe8\"/><path d=\"M16 3 19 29M8 12l16 1\" stroke=\"#c9dcff\" stroke-width=\"1.2\" fill=\"none\"/></svg>`,\n  },\n}\nconst SPELLS = [\n  {\n    id: 'fire', name: 'Fireball', cost: 10, itemCost: 5, nova: 'Ring of Fire', novaCost: 18, radius: 2.4, range: 16, glow: [1, 0.42, 0.1], css: '#ff8a3d',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M16 3c5 6 9 10 9 16a9 9 0 0 1-18 0c0-4 2-7 4-9 0 3 1 5 3 6-1-5 0-9 2-13z\" fill=\"#ff7a2e\"/><path d=\"M16 14c3 3 4 5 4 8a4 4 0 0 1-8 0c0-2 1-3 2-4 0 2 1 2 2 3-.5-3 0-5 0-7z\" fill=\"#ffd27a\"/></svg>`,\n  },\n  {\n    id: 'thunder', name: 'Thunderstrike', cost: 14, itemCost: 6, nova: 'Storm Circle', novaCost: 20, radius: 2.0, range: 16, glow: [0.55, 0.75, 1], css: '#8fb8ff',\n    icon: `<svg viewBox=\"0 0 32 32\"><path d=\"M19 2 7 18h8l-3 12 13-17h-8z\" fill=\"#9cc0ff\"/><path d=\"M19 2 7 18h8\" stroke=\"#e8f0ff\" stroke-width=\"1.2\" fill=\"none\"/></svg>`,\n  },\n]\n// spell cast on an item lying on the ground: new id, null = destroyed, missing = nothing happens\nconst TRANSMUTE = { fire: { stick: 'torch', pebble: 'ember' }, thunder: { pebble: 'shard', stick: null, torch: 'stick' } }\nconst ITEM_VERB = { fire: { stick: 'Ignite', pebble: 'Heat' }, thunder: { pebble: 'Charge', stick: 'Shatter', torch: 'Douse' } }\nconst LINES = ['Lovely weather for wandering.', 'They say the old chapel still hums at night.', 'Have you felt it? The spell, I mean.', 'Mind the cliffs, little one.', 'I lost a pebble around here somewhere…', 'The trees drop sticks if you give them a good shake.', 'Careful with that fire, it spreads to the trees.']\n\n// colliders: t0 = circle {r}, t1 = oriented box {c,s,hx,hz}; R = broad-phase radius\nfunction buildColliders(inst) {\n  const cols = []\n  const obb = (cx, cz, rot, lx, lz, hx, hz, kind) => {\n    const c = Math.cos(rot), s = Math.sin(rot)\n    cols.push({ t: 1, x: cx + c * lx + s * lz, z: cz - s * lx + c * lz, c, s, hx, hz, R: Math.hypot(hx, hz), kind })\n  }\n  const circ = (x, z, r, kind, i) => cols.push({ t: 0, x, z, r, R: r, kind, i })\n  for (const h of inst.hut) { obb(h[0], h[2], h[4], 0, 0, 2.3, 1.78, 'hut'); obb(h[0], h[2], h[4], 2.6, 0.6, 0.5, 0.5) }\n  for (const g of inst.ruin) {\n    obb(g[0], g[2], g[4], 0, 0, 2.1, 2.1, 'ruin'); obb(g[0], g[2], g[4], 3.4, 0, 1.6, 1.3, 'ruin')\n    for (const s of [-1, 1]) for (let k = 0; k < 6; k++) obb(g[0], g[2], g[4], -3.2 - k * 1.8, s * 3, 0.45, 0.35)\n    obb(g[0], g[2], g[4], -13.2, 0, 0.35, 3.3)\n  }\n  for (const r of inst.rock) obb(r[0], r[2], r[4], 0, 0, 1.25 * r[3], 0.9 * r[3], 'rock')\n  inst.tree.forEach((t, i) => circ(t[0], t[2], 0.28 * t[3], 'tree', i))\n  for (const p of inst.pole) circ(p[0], p[2], 0.14)\n  return cols\n}\n// push circle o{x,z} of radius rad out of every collider; returns contact count\nfunction collide(o, rad, cols) {\n  let hit = 0\n  for (const k of cols) {\n    const dx = o.x - k.x, dz = o.z - k.z\n    if (Math.abs(dx) > k.R + rad || Math.abs(dz) > k.R + rad) continue\n    if (k.t === 0) {\n      const d = Math.hypot(dx, dz), m = k.r + rad\n      if (d < m && d > 1e-6) { o.x = k.x + (dx / d) * m; o.z = k.z + (dz / d) * m; hit++ }\n      continue\n    }\n    let lx = k.c * dx - k.s * dz, lz = k.s * dx + k.c * dz\n    const qx = clamp(lx, -k.hx, k.hx), qz = clamp(lz, -k.hz, k.hz)\n    const ex = lx - qx, ez = lz - qz, d = Math.hypot(ex, ez)\n    if (d >= rad) continue\n    if (d > 1e-6) { lx = qx + (ex / d) * rad; lz = qz + (ez / d) * rad }\n    else if (k.hx - Math.abs(lx) < k.hz - Math.abs(lz)) lx = Math.sign(lx || 1) * (k.hx + rad)\n    else lz = Math.sign(lz || 1) * (k.hz + rad)\n    o.x = k.x + k.c * lx + k.s * lz; o.z = k.z - k.s * lx + k.c * lz; hit++\n  }\n  return hit\n}\nfunction obbDist(k, x, z) {\n  const dx = x - k.x, dz = z - k.z, lx = k.c * dx - k.s * dz, lz = k.s * dx + k.c * dz\n  return Math.hypot(lx - clamp(lx, -k.hx, k.hx), lz - clamp(lz, -k.hz, k.hz))\n}\nfunction spawnPickups(inst, cols) {\n  const R = rng(4242), out = []\n  const add = (id, x, z) => {\n    if (Math.hypot(x, z) > 78 || slopeAt(x, z) > 0.5 || collide({ x, z }, 0.35, cols)) return\n    out.push({ id, x, z, ry: R() * 6.28 })\n  }\n  for (const t of inst.tree) if (R() < 0.35) { const a = R() * 6.28, r = 0.9 + R() * 1.8; add('stick', t[0] + Math.cos(a) * r, t[2] + Math.sin(a) * r) }\n  for (const r of inst.rock) for (let k = 0; k < 2; k++) if (R() < 0.6) { const a = R() * 6.28, d = 1.6 * r[3] + 0.4 + R(); add('pebble', r[0] + Math.cos(a) * d, r[2] + Math.sin(a) * d) }\n  for (let k = 0; k < 16; k++) add(k % 2 ? 'stick' : 'pebble', -1.5 + (R() - 0.5) * 14, 15 + (R() - 0.5) * 12)\n  for (let k = 0; k < 50; k++) add(R() < 0.5 ? 'stick' : 'pebble', (R() - 0.5) * 70, (R() - 0.5) * 70)\n  return out\n}\nconst lerpAngle = (a, b, t) => { const d = ((((b - a + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI; return a + d * t }\nconst HP_MAX = 100, MP_MAX = 60, AP_FILL = 2.0, MP_REGEN = 3, V_HP = 40, V_ATK = 8, V_ATB = 2.2\nconst FLAME = C(1, 0.5, 0.14).map(c => c * 0.55), FLAME_RED = C(1, 0.26, 0.06).map(c => c * 0.45), SPARK = [0.5, 0.7, 1].map(c => c * 0.6)\n\nexport default {\n  css: `\n:host, .magi { display:block; width:100%; height:100%; }\n.magi { position:relative; overflow:hidden; background:#9a9c9a; font-family: 'Cormorant Garamond', Georgia, serif; outline:none; cursor:crosshair; user-select:none; -webkit-user-select:none; }\n.cv { position:absolute; inset:0; width:100%; height:100%; display:block; }\n.hud { position:absolute; left:20px; top:16px; color:#f4efe2; text-shadow:0 1px 6px rgba(0,0,0,.55); pointer-events:none; }\n.hud h1 { margin:0; font-size:30px; font-weight:500; letter-spacing:.42em; }\n.hud p { margin:2px 0 0; font-size:12px; letter-spacing:.18em; text-transform:uppercase; opacity:.8; font-family: ui-sans-serif, system-ui, sans-serif; }\n.stats { display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; width:428px; pointer-events:none; color:#f4efe2; font:10px ui-monospace, monospace; text-shadow:0 1px 3px rgba(0,0,0,.7); }\n.st { display:grid; grid-template-columns:1fr auto; row-gap:3px; align-items:end; }\n.st em { font-style:normal; opacity:.8; }\n.st i { grid-row:2; grid-column:1 / -1; }\n.st i { position:relative; height:8px; border-radius:4px; background:rgba(20,20,18,.55); box-shadow:inset 0 0 0 1px rgba(244,239,226,.2); overflow:hidden; }\n.st i b { position:absolute; left:0; top:0; bottom:0; border-radius:4px; }\n.st.hp b { background:linear-gradient(90deg, #a52f36, #e5636a); }\n.st.mp b { background:linear-gradient(90deg, #3561c4, #7fa9ff); }\n.st.ap b { background:linear-gradient(90deg, #a57a24, #f2c46b); }\n.st.ap.ready i { box-shadow:inset 0 0 0 1px #f2c46b, 0 0 12px rgba(242,196,107,.65); }\n.st.deny { animation:shake .3s; }\n.st.deny i { box-shadow:inset 0 0 0 1px #e5636a; }\n@keyframes shake { 25% { transform:translateX(-4px) } 75% { transform:translateX(4px) } }\n.keys { position:absolute; left:20px; bottom:16px; max-width:330px; color:#f4efe2; font:11px/2 ui-sans-serif, system-ui, sans-serif; letter-spacing:.04em; opacity:.85; text-shadow:0 1px 4px rgba(0,0,0,.6); pointer-events:none; }\n.keys b, .tag b { display:inline-block; padding:0 5px; margin-right:4px; border:1px solid currentColor; border-radius:3px; font-weight:500; font-size:10px; line-height:16px; opacity:.9; }\n.keys span { white-space:nowrap; margin-right:10px; }\n.dbg { position:absolute; right:14px; top:12px; color:#f4efe2; font:11px/1.5 ui-monospace, monospace; background:rgba(20,20,18,.55); padding:8px 11px; border-radius:6px; }\n.dbg pre { margin:0; font:inherit; white-space:pre; pointer-events:none; }\n.dbg .camctl { display:grid; grid-template-columns:44px 1fr 56px; gap:2px 8px; align-items:center; margin-top:8px; padding-top:8px; border-top:1px solid rgba(244,239,226,.15); }\n.dbg input[type=range] { width:100%; height:14px; margin:0; accent-color:#8fb8ff; }\n.dbg output { text-align:right; opacity:.85; }\n.dbg button { grid-column:1 / -1; justify-self:end; margin-top:4px; font:10px ui-monospace, monospace; color:#f4efe2; background:rgba(244,239,226,.1); border:1px solid rgba(244,239,226,.3); border-radius:4px; padding:2px 8px; cursor:pointer; }\n.mini { position:absolute; right:14px; top:10px; width:200px; pointer-events:none; }\n.mini[hidden] { display:none; }\n.mini canvas { display:block; width:200px; height:200px; }\n.mini .loc { margin-top:-6px; text-align:center; font-weight:500; font-size:14px; letter-spacing:.22em; text-transform:uppercase; color:#e3edff; text-shadow:0 0 8px rgba(70,130,255,.8), 0 1px 3px rgba(0,0,0,.7); }\n.dbg[hidden] { display:none; }\n.bar { position:absolute; left:50%; bottom:16px; transform:translateX(-50%); display:flex; flex-direction:column; align-items:center; gap:6px; }\n.held { font:12px ui-sans-serif, system-ui, sans-serif; color:#f4efe2; text-shadow:0 1px 4px rgba(0,0,0,.7); letter-spacing:.06em; min-height:16px; pointer-events:none; }\n.hotbar, .grid { display:grid; grid-template-columns:repeat(9, 44px); gap:4px; }\n.slot { position:relative; width:44px; height:44px; box-sizing:border-box; border-radius:8px; background:rgba(28,26,22,.55); border:1px solid rgba(244,239,226,.22); display:flex; align-items:center; justify-content:center; cursor:pointer; }\n.slot:hover { background:rgba(70,64,54,.7); }\n.slot svg { width:30px; height:30px; pointer-events:none; }\n.slot .n, .cursor .n { position:absolute; right:4px; bottom:1px; font:600 11px ui-sans-serif, system-ui, sans-serif; color:#fff; text-shadow:0 1px 2px #000; pointer-events:none; }\n.slot .k { position:absolute; left:4px; top:1px; font:9px ui-monospace, monospace; color:rgba(244,239,226,.55); pointer-events:none; }\n.slot.sel { border-color:rgba(244,239,226,.75); }\n.slot.eq { border-color:#f2c46b; background:rgba(66,52,26,.7); box-shadow:0 0 0 2px rgba(242,196,107,.4), 0 0 14px rgba(242,196,107,.35); }\n.slot.spell { border-color:var(--c); border-style:dashed; }\n.slot.spell .n { color:#bcd2ff; font-size:9px; }\n.slot.spell.eq { border-style:solid; background:rgba(40,36,44,.75); box-shadow:0 0 0 2px var(--c), 0 0 16px var(--c); }\n.slot.spell.lock { cursor:default; opacity:.6; }\n.slot.spell.poor svg { opacity:.35; }\n.inv { position:absolute; inset:0; background:rgba(14,14,12,.38); display:flex; align-items:center; justify-content:center; cursor:default; }\n.inv[hidden], .dead[hidden] { display:none; }\n.panel { background:rgba(36,33,28,.94); border:1px solid rgba(244,239,226,.2); border-radius:12px; padding:14px 18px 12px; color:#f4efe2; box-shadow:0 20px 60px rgba(0,0,0,.4); }\n.panel h2 { margin:0 0 10px; font-weight:500; font-size:18px; letter-spacing:.32em; text-transform:uppercase; }\n.panel .hot { margin-top:12px; padding-top:12px; border-top:1px solid rgba(244,239,226,.12); }\n.panel .tip { margin:10px 0 0; font:11px ui-sans-serif, system-ui, sans-serif; opacity:.6; }\n.cursor { position:absolute; width:44px; height:44px; margin:-22px 0 0 -22px; pointer-events:none; display:flex; align-items:center; justify-content:center; }\n.cursor svg { width:34px; height:34px; }\n.world { position:absolute; inset:0; pointer-events:none; overflow:hidden; }\n.tag { position:absolute; transform:translate(-50%, -100%); font:12px/1.5 ui-sans-serif, system-ui, sans-serif; white-space:nowrap; padding:4px 10px; border-radius:10px; color:#2b2a27; background:rgba(250,246,236,.94); box-shadow:0 2px 10px rgba(0,0,0,.2); }\n.tag.prompt { color:#f4efe2; background:rgba(24,23,20,.66); box-shadow:none; transition:opacity .15s; }\n.tag.prompt.wait { opacity:.5; }\n.tag[hidden] { display:none; }\n.toasts { position:absolute; left:50%; bottom:170px; transform:translateX(-50%); display:flex; flex-direction:column; align-items:center; gap:4px; pointer-events:none; }\n.toast { font:12px ui-sans-serif, system-ui, sans-serif; color:#f4efe2; background:rgba(24,23,20,.62); padding:3px 11px; border-radius:10px; animation:tf 1.8s forwards; }\n@keyframes tf { 0% { opacity:0; transform:translateY(6px) } 12% { opacity:1; transform:none } 75% { opacity:1 } 100% { opacity:0 } }\n.hurt { position:absolute; inset:0; pointer-events:none; opacity:0; background:radial-gradient(ellipse at center, transparent 45%, rgba(170,20,20,.55) 100%); }\n.dead { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; background:rgba(12,10,10,.55); color:#f4efe2; pointer-events:none; }\n.dead h2 { margin:0; font-weight:500; font-size:34px; letter-spacing:.4em; text-transform:uppercase; }\n.dead p { margin:0; font:12px ui-sans-serif, system-ui, sans-serif; letter-spacing:.1em; opacity:.7; }\n.vhp { position:absolute; transform:translate(-50%, -100%); width:46px; height:5px; border-radius:3px; background:rgba(20,20,18,.75); box-shadow:0 0 0 1px rgba(244,239,226,.4); overflow:hidden; }\n.vhp b { position:absolute; left:0; top:0; bottom:0; background:linear-gradient(90deg, #a52f36, #ef6b6b); }\n.vhp.calm b { background:linear-gradient(90deg, #3f8f4f, #7fd28f); }\n.vhp[hidden] { display:none; }\n.err { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; text-align:center; padding:24px; color:#f4efe2; background:#2b2a27; font:14px ui-sans-serif, system-ui, sans-serif; }\n.err[hidden] { display:none; }\n`,\n  render() {\n    return `<div class=\"magi\" tabindex=\"0\" data-d2-static>\n  <canvas class=\"cv\"></canvas>\n  <div class=\"hurt\"></div>\n  <div class=\"hud\"><h1>MAGI</h1><p>Prototype · WebGPU</p></div>\n  <div class=\"dbg\" hidden><pre></pre><div class=\"camctl\"></div></div>\n  <div class=\"mini\"><canvas width=\"400\" height=\"400\"></canvas><div class=\"loc\"></div></div>\n  <div class=\"world\"></div>\n  <div class=\"toasts\"></div>\n  <div class=\"keys\"><span><b>WASD</b>move</span><span><b>Space</b>sprint</span><span><b>LMB</b>pick up</span><span><b>F</b><b>RMB</b>interact</span><span><b>1</b><b>2</b>spells</span><span><b>3–9</b>items</span><span><b>E</b>inventory</span><span><b>Q</b>drop</span><span><b>T</b>tilt-shift</span><span><b>F3</b>debug</span></div>\n  <div class=\"bar\">\n    <div class=\"stats\">\n      <div class=\"st hp\"><span>HP</span><i><b></b></i><em></em></div>\n      <div class=\"st mp\"><span>MP</span><i><b></b></i><em></em></div>\n      <div class=\"st ap\"><span>AP</span><i><b></b></i><em></em></div>\n    </div>\n    <div class=\"held\"></div><div class=\"hotbar\"></div>\n  </div>\n  <div class=\"inv\" hidden><div class=\"panel\"><h2>Inventory</h2><div class=\"grid main\"></div><div class=\"grid hot\"></div><p class=\"tip\">Click to move a stack · right-click to split / place one · shift-click to send between bag and hotbar</p></div><div class=\"cursor\"></div></div>\n  <div class=\"dead\" hidden><h2>You faded</h2><p>The spell that made you is drawing you back together…</p></div>\n  <div class=\"err\" hidden></div>\n</div>`\n  },\n  async mounted(d2) {\n    const $ = s => d2.$(s)\n    const wrap = $('.magi'), canvas = $('.cv'), errEl = $('.err'), dbgEl = $('.dbg'), dbgPre = $('.dbg pre'), miniEl = $('.mini'), miniCv = $('.mini canvas')\n    const fail = msg => { errEl.hidden = false; errEl.textContent = msg }\n    if (!navigator.gpu) return fail('This prototype needs WebGPU — open it in a recent Chrome, Edge or Safari.')\n    const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' })\n    if (!adapter) return fail('No WebGPU adapter available on this device.')\n    const device = await adapter.requestDevice()\n    let alive = true\n    d2.onUnmount(() => { alive = false; device.destroy() })\n    device.lost.then(i => { if (alive) fail('GPU device lost: ' + i.message) })\n    device.addEventListener('uncapturederror', e => d2.log('webgpu:', e.error.message))\n    const ctx = canvas.getContext('webgpu'), format = navigator.gpu.getPreferredCanvasFormat()\n    ctx.configure({ device, format, alphaMode: 'opaque' })\n\n    // ---- world + geometry upload ----\n    const meshes = buildMeshes(), inst = placeWorld()\n    const cols = buildColliders(inst), pickups = spawnPickups(inst, cols)\n    inst.villager = []; inst.knight = []; inst.ember = []\n    const gpuBuf = (data, usage) => {\n      const b = device.createBuffer({ size: Math.max(16, (data.byteLength + 3) & ~3), usage: usage | GPUBufferUsage.COPY_DST })\n      device.queue.writeBuffer(b, 0, data)\n      return b\n    }\n    const draws = {}\n    for (const [k, m] of Object.entries(meshes)) {\n      draws[k] = { vb: gpuBuf(new Float32Array(m.v), GPUBufferUsage.VERTEX), ib: gpuBuf(new Uint32Array(m.i), GPUBufferUsage.INDEX), n: m.i.length, count: 0, cap: 0, inb: null }\n    }\n    const setInstances = (k, rows) => {\n      const d = draws[k], data = new Float32Array(rows.length * 12)\n      rows.forEach((r, i) => data.set(r, i * 12))\n      if (!d.inb || d.cap < rows.length) {\n        d.cap = Math.max(16, rows.length, Math.ceil(d.cap * 1.5))\n        d.inb = device.createBuffer({ size: d.cap * 48, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST })\n      }\n      if (data.byteLength) device.queue.writeBuffer(d.inb, 0, data)\n      d.count = rows.length\n    }\n    for (const k of Object.keys(inst)) if (inst[k].length) setInstances(k, inst[k])\n    const DRAW_ORDER = ['terrain', 'ruin', 'hut', 'rock', 'pole', 'tree', 'bush', 'tuft', 'flower', 'spire', 'villager', 'knight', 'pebble', 'stick', 'ring', 'plate', 'arrowhead', 'ember', 'bolt']\n    const SHADOWLESS = new Set(['ring', 'plate', 'arrowhead', 'ember', 'bolt'])\n\n    // ---- pipelines ----\n    const sceneMod = device.createShaderModule({ code: SCENE_WGSL }), postMod = device.createShaderModule({ code: POST_WGSL })\n    const vbufs = [\n      { arrayStride: 36, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }, { shaderLocation: 1, offset: 12, format: 'float32x3' }, { shaderLocation: 2, offset: 24, format: 'float32x3' }] },\n      { arrayStride: 48, stepMode: 'instance', attributes: [{ shaderLocation: 3, offset: 0, format: 'float32x4' }, { shaderLocation: 4, offset: 16, format: 'float32x4' }, { shaderLocation: 5, offset: 32, format: 'float32x4' }] },\n    ]\n    const shadowPipe = device.createRenderPipeline({\n      layout: 'auto', vertex: { module: sceneMod, entryPoint: 'vsShadow', buffers: vbufs },\n      primitive: { topology: 'triangle-list', cullMode: 'none' },\n      depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less', depthBias: 2, depthBiasSlopeScale: 2.5 },\n    })\n    const mainPipe = device.createRenderPipeline({\n      layout: 'auto', vertex: { module: sceneMod, entryPoint: 'vsMain', buffers: vbufs },\n      fragment: { module: sceneMod, entryPoint: 'fsMain', targets: [{ format: 'rgba16float' }] },\n      primitive: { topology: 'triangle-list', cullMode: 'none' },\n      depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' },\n    })\n    const postPipe = device.createRenderPipeline({\n      layout: 'auto', vertex: { module: postMod, entryPoint: 'vs' },\n      fragment: { module: postMod, entryPoint: 'fs', targets: [{ format }] },\n      primitive: { topology: 'triangle-list' },\n    })\n    const SHADOW = 2048\n    const shadowTex = device.createTexture({ size: [SHADOW, SHADOW], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING })\n    const uBuf = device.createBuffer({ size: 480, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })\n    const pBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })\n    const cmpSmp = device.createSampler({ compare: 'less', magFilter: 'linear', minFilter: 'linear' })\n    const linSmp = device.createSampler({ magFilter: 'linear', minFilter: 'linear' })\n    const shadowBG = device.createBindGroup({ layout: shadowPipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uBuf } }] })\n    const mainBG = device.createBindGroup({ layout: mainPipe.getBindGroupLayout(0), entries: [\n      { binding: 0, resource: { buffer: uBuf } }, { binding: 1, resource: shadowTex.createView() }, { binding: 2, resource: cmpSmp }] })\n    let W = 0, H = 0, scale = 1, hdrTex, depthTex, postBG\n    const resize = () => {\n      scale = Math.min(window.devicePixelRatio || 1, 1.5)\n      const w = Math.max(1, Math.round(canvas.clientWidth * scale)), h = Math.max(1, Math.round(canvas.clientHeight * scale))\n      if (w === W && h === H) return\n      W = canvas.width = w; H = canvas.height = h\n      hdrTex?.destroy(); depthTex?.destroy()\n      hdrTex = device.createTexture({ size: [W, H], format: 'rgba16float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING })\n      depthTex = device.createTexture({ size: [W, H], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING })\n      postBG = device.createBindGroup({ layout: postPipe.getBindGroupLayout(0), entries: [\n        { binding: 0, resource: { buffer: pBuf } }, { binding: 1, resource: hdrTex.createView() },\n        { binding: 2, resource: depthTex.createView() }, { binding: 3, resource: linSmp }] })\n    }\n\n    // ---- game state ----\n    const R = rng(99)\n    const SPAWN = { x: -1.5, z: 17 }\n    const player = { ...SPAWN, ry: Math.PI, vx: 0, vz: 0, speed: 0, bob: 0, swing: 0, pick: 0, contacts: 0, sayT: 0 }\n    const stats = { hp: HP_MAX, mp: MP_MAX, ap: 1, lastHurt: -99, hurtFx: 0, dead: 0 }\n    const villagers = Array.from({ length: 8 }, () => {\n      const a = R() * 6.28, r = 3 + R() * 8\n      const x = Math.cos(a) * r, z = Math.sin(a) * r\n      return { x, z, hx: x, hz: z, tx: 0, tz: 0, wait: R() * 3, ry: 0, bob: R() * 6, sayT: 0, flee: 0, fx: 0, fz: 0, hp: V_HP, hostile: false, combat: 0, atb: 0, dead: 0, lunge: 0 }\n    })\n    // fixed camera (GDD); middle-drag orbits and the F3 panel adjusts every axis, for debugging only\n    const CAM0 = { yaw: 0.55, pitch: 0.78, roll: 0, dist: 20, fov: 0.52, lift: 0.8 }\n    const cam = { ...CAM0, tx: player.x, tz: player.z, ty: 0 }\n    let tilt = 1, showDbg = false, invOpen = false, hover = false, orbit = null, time = 0, flash = 0, aiming = null\n    const keys = new Set(), mouse = { x: 0, y: 0 }\n    let viewProj = M4.persp(cam.fov, 1, 1, 400), eye = [0, 30, 0]\n\n    // effects\n    const parts = [], fires = [], bolts = [], flashes = [], timers = []\n    const burning = new Map(), charred = new Set()\n    const later = (t, fn) => timers.push({ t, fn })\n    const emit = (x, y, z, o) => {\n      if (parts.length > 900) return\n      parts.push({ x, y, z, vx: o.vx || 0, vy: o.vy || 0, vz: o.vz || 0, life: o.life || 0.8, t: 0, s0: o.s0 || 0.2, s1: o.s1 ?? 0.02, col: o.col || FLAME, g: o.g || 0 })\n    }\n    const flame = (x, y, z, spread, s) => emit(x + (R() - 0.5) * spread, y, z + (R() - 0.5) * spread, {\n      vx: (R() - 0.5) * 0.4, vy: 1.1 + R() * 1.6, vz: (R() - 0.5) * 0.4, life: 0.45 + R() * 0.5, s0: s * (0.45 + R() * 0.45), col: R() < 0.3 ? FLAME_RED : FLAME,\n    })\n    const burst = (x, y, z, n, speed, col, s = 0.22) => {\n      for (let k = 0; k < n; k++) {\n        const a = R() * 6.28, e = (R() - 0.3) * 1.4, v = speed * (0.4 + R() * 0.6)\n        emit(x, y, z, { vx: Math.cos(a) * Math.cos(e) * v, vy: Math.sin(e) * v + 1, vz: Math.sin(a) * Math.cos(e) * v, life: 0.35 + R() * 0.4, s0: s, col, g: 6 })\n      }\n    }\n\n    // ---- inventory: slots 0–1 are spells, 2–8 item hotbar, 9–35 bag ----\n    const slots = Array(36).fill(null)\n    let sel = 2, equipped = false, cursor = null\n    const held = () => (equipped && sel >= 2 ? slots[sel] : null)\n    const spell = () => (equipped && sel < 2 ? SPELLS[sel] : null)\n    const fill = (id, n, a, b) => {\n      const max = ITEMS[id].max\n      for (let i = a; i < b && n > 0; i++) { const s = slots[i]; if (s && s.id === id && s.n < max) { const k = Math.min(n, max - s.n); s.n += k; n -= k } }\n      for (let i = a; i < b && n > 0; i++) if (!slots[i]) { const k = Math.min(n, max); slots[i] = { id, n: k }; n -= k }\n      return n\n    }\n    const hotEl = $('.hotbar'), mainEl = $('.inv .main'), invHotEl = $('.inv .hot'), invEl = $('.inv'), curEl = $('.cursor'), heldEl = $('.held')\n    const stackHTML = s => (s ? ITEMS[s.id].icon + (s.n > 1 ? `<span class=\"n\">${s.n}</span>` : '') : '')\n    const slotHTML = (s, i, key) => `<div class=\"slot\" data-i=\"${i}\">${key ? `<span class=\"k\">${key}</span>` : ''}${stackHTML(s)}</div>`\n    const spellHTML = (i, lock) => {\n      const sp = SPELLS[i], eq = !lock && equipped && sel === i ? ' eq' : '', poor = stats.mp < sp.cost ? ' poor' : ''\n      return `<div class=\"slot spell${eq}${lock ? ' lock' : ''}${poor}\" data-i=\"${i}\" style=\"--c:${sp.css}\" title=\"${sp.name}\"><span class=\"k\">${i + 1}</span>${sp.icon}<span class=\"n\">${sp.cost}</span></div>`\n    }\n    let lastPoor = ''\n    function drawSlots() {\n      hotEl.innerHTML = spellHTML(0) + spellHTML(1) + slots.slice(2, 9).map((s, i) => slotHTML(s, i + 2, i + 3)).join('')\n      if (sel >= 2) hotEl.children[sel].classList.add('sel')\n      if (equipped && sel >= 2) hotEl.children[sel].classList.add('eq')\n      mainEl.innerHTML = slots.slice(9).map((s, i) => slotHTML(s, i + 9)).join('')\n      invHotEl.innerHTML = spellHTML(0, true) + spellHTML(1, true) + slots.slice(2, 9).map((s, i) => slotHTML(s, i + 2, i + 3)).join('')\n      curEl.innerHTML = stackHTML(cursor)\n      const h = held(), sp = spell()\n      heldEl.textContent = sp ? `${sp.name} — hold F / RMB to aim, release to cast · LMB cast on item · Space ${sp.nova}`\n        : h ? `${ITEMS[h.id].name} — F / RMB to ${ITEMS[h.id].action.toLowerCase()}` : equipped ? 'Empty hand' : ''\n      lastPoor = SPELLS.map(s => stats.mp < s.cost).join()\n    }\n    const equipSlot = i => { aiming = null; if (i === sel && equipped) equipped = false; else { sel = i; equipped = true } drawSlots() }\n    function invClick(i, button, shift) {\n      if (i < 2) return\n      const s = slots[i]\n      if (shift && s && !cursor) {\n        slots[i] = null\n        const left = i < 9 ? fill(s.id, s.n, 9, 36) : fill(s.id, s.n, 2, 9)\n        if (left) slots[i] = { id: s.id, n: left }\n      } else if (button === 2) {\n        if (!cursor && s) { const k = Math.ceil(s.n / 2); cursor = { id: s.id, n: k }; s.n -= k; if (!s.n) slots[i] = null }\n        else if (cursor && (!s || (s.id === cursor.id && s.n < ITEMS[s.id].max))) {\n          if (s) s.n++; else slots[i] = { id: cursor.id, n: 1 }\n          if (!--cursor.n) cursor = null\n        }\n      } else if (!cursor) { if (s) { cursor = s; slots[i] = null } }\n      else if (!s) { slots[i] = cursor; cursor = null }\n      else if (s.id === cursor.id) { const k = Math.min(cursor.n, ITEMS[s.id].max - s.n); s.n += k; cursor.n -= k; if (!cursor.n) cursor = null }\n      else { slots[i] = cursor; cursor = s }\n      drawSlots()\n    }\n    function toggleInv(open) {\n      invOpen = open; invEl.hidden = !open; keys.clear(); aiming = null\n      if (!open && cursor) { const left = fill(cursor.id, cursor.n, 2, 36); for (let k = 0; k < left; k++) dropAt(cursor.id); cursor = null }\n      drawSlots()\n    }\n\n    // ---- floating tags, toasts, stat bars ----\n    const worldEl = $('.world'), toastsEl = $('.toasts'), hurtEl = $('.hurt'), deadEl = $('.dead')\n    const mkTag = cls => { const t = document.createElement('div'); t.className = 'tag ' + cls; t.hidden = true; worldEl.appendChild(t); return t }\n    const promptEl = mkTag('prompt')\n    const say = (ent, text, dur = 3.2) => { ent.tag ??= mkTag('bubble'); ent.tag.textContent = text; ent.sayT = dur }\n    const toast = msg => { const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; toastsEl.appendChild(t); setTimeout(() => t.remove(), 1800) }\n    const placeTag = (el, x, y, z) => {\n      const p = M4.xform(viewProj, [x, y, z])\n      el.hidden = p[2] > 1 || Math.abs(p[0]) > 1.1 || Math.abs(p[1]) > 1.1\n      el.style.left = (p[0] * 0.5 + 0.5) * wrap.clientWidth + 'px'\n      el.style.top = (0.5 - p[1] * 0.5) * wrap.clientHeight + 'px'\n    }\n    const bars = Object.fromEntries(['hp', 'mp', 'ap'].map(k => { const el = $('.st.' + k); return [k, { el, b: el.querySelector('b'), t: el.querySelector('em') }] }))\n    const deny = k => { const el = bars[k].el; el.classList.remove('deny'); void el.offsetWidth; el.classList.add('deny'); setTimeout(() => el.classList.remove('deny'), 320) }\n\n    // ---- stats ----\n    function hurt(n, why) {\n      if (stats.dead) return\n      stats.hp = Math.max(0, stats.hp - n); stats.lastHurt = time; stats.hurtFx = Math.min(1, stats.hurtFx + 0.35 + n / 40)\n      if (stats.hp <= 0) { stats.dead = 3.5; deadEl.hidden = false; keys.clear(); toast(why ? `Faded — ${why}` : 'Faded') }\n    }\n    // every active action needs a full AP bar (FF12-style) and spends all of it\n    function act(mpCost, fn) {\n      if (stats.dead) return false\n      if (stats.ap < 1) { deny('ap'); return false }\n      if (stats.mp < mpCost) { deny('mp'); toast('Not enough MP'); return false }\n      stats.ap = 0; stats.mp -= mpCost; fn(); return true\n    }\n\n    // ---- world helpers ----\n    const REACH = 1.7\n    const fwd = (o, d) => [o.x + Math.sin(o.ry) * d, o.z + Math.cos(o.ry) * d]\n    const face = (x, z) => { player.ry = Math.atan2(x - player.x, z - player.z) }\n    function nearestPickup(x = player.x, z = player.z, maxD = REACH) {\n      let best = null, bd = maxD\n      for (const p of pickups) { const d = Math.hypot(p.x - x, p.z - z); if (d < bd) { bd = d; best = p } }\n      return best\n    }\n    function dropAt(id, x, z, up = false) {\n      [x, z] = x === undefined ? fwd(player, 0.9) : [x, z]\n      const o = { x, z }; collide(o, 0.3, cols)\n      pickups.push({ id, x: o.x, z: o.z, ry: R() * 6.28, up })\n    }\n    const removePickup = p => { const i = pickups.indexOf(p); if (i >= 0) pickups.splice(i, 1) }\n    function groundAt(sx, sy) {\n      const inv = M4.inv(viewProj), nx = (sx / wrap.clientWidth) * 2 - 1, ny = 1 - (sy / wrap.clientHeight) * 2\n      const a = M4.xform(inv, [nx, ny, 0]), b = M4.xform(inv, [nx, ny, 1]), d = norm(sub(b, a))\n      if (d[1] >= -1e-3) return null\n      let t = Math.max(0, (a[1] - 24) / -d[1]), prev = t\n      for (; t < 300; t += 1) {\n        if (a[1] + d[1] * t < heightAt(a[0] + d[0] * t, a[2] + d[2] * t)) {\n          let lo = prev, hi = t\n          for (let k = 0; k < 8; k++) { const m = (lo + hi) / 2; if (a[1] + d[1] * m < heightAt(a[0] + d[0] * m, a[2] + d[2] * m)) hi = m; else lo = m }\n          return [a[0] + d[0] * hi, a[2] + d[2] * hi]\n        }\n        prev = t\n      }\n      return null\n    }\n    // where a spell lands: under the cursor while the mouse is over the game, else straight ahead\n    function aimPoint(sp) {\n      let p = hover ? groundAt(mouse.x, mouse.y) : null\n      if (!p) p = fwd(player, 8)\n      const dx = p[0] - player.x, dz = p[1] - player.z, d = Math.hypot(dx, dz)\n      return d > sp.range ? [player.x + (dx / d) * sp.range, player.z + (dz / d) * sp.range] : p\n    }\n    function spellItemTarget(sp) {\n      const a = hover ? groundAt(mouse.x, mouse.y) : null\n      const p = a ? nearestPickup(a[0], a[1], 1.8) : nearestPickup(player.x, player.z, 8)\n      return p && Math.hypot(p.x - player.x, p.z - player.z) <= sp.range ? p : null\n    }\n\n    // ---- fire, lightning, transmutation ----\n    function igniteTree(k) {\n      if (k.kind !== 'tree' || charred.has(k.i) || burning.has(k.i)) return\n      burning.set(k.i, 7 + R() * 3)\n    }\n    function scare(x, z, r, lines) {\n      for (const v of villagers) {\n        const d = Math.hypot(v.x - x, v.z - z)\n        if (d < r && !v.hostile && !v.dead) { v.flee = 2.4; v.fx = x; v.fz = z; v.wait = 0; say(v, lines[(R() * lines.length) | 0], 2) }\n      }\n    }\n    // villagers turn hostile when the player hurts them; at 0 HP they fade and return calm 20s later\n    function hitVillager(v, n) {\n      if (v.dead || n <= 0) return\n      v.hp -= n; v.combat = 8; v.flee = 0\n      if (!v.hostile) { v.hostile = true; v.atb = 0.4; say(v, [\"You'll pay for that!\", 'How dare you!', 'That does it!'][(R() * 3) | 0], 2) }\n      if (n >= 1) burst(v.x, heightAt(v.x, v.z) + 0.8, v.z, 8, 2, FLAME_RED, 0.07)\n      if (v.hp <= 0) {\n        v.dead = 20; v.hostile = false; v.combat = 0; v.sayT = 0\n        burst(v.x, heightAt(v.x, v.z) + 0.6, v.z, 30, 3, SPARK, 0.1); toast('A villager fades away…')\n      }\n    }\n    function addFire(x, z, r, life) { fires.push({ x, z, r, t: life, max: life, acc: 0 }) }\n    function explode(x, z, r) {\n      const y = heightAt(x, z)\n      burst(x, y + 0.6, z, 46, 7, FLAME, 0.28)\n      flashes.push({ x, y: y + 1.5, z, r: 14, col: [1, 0.55, 0.2], I: 7, t: 0.35, max: 0.35 })\n      flash = Math.max(flash, 0.35)\n      addFire(x, z, r * 0.6, 6)\n      for (const k of cols) if (k.kind === 'tree' && Math.hypot(k.x - x, k.z - z) < r + k.r) igniteTree(k)\n      for (const v of villagers) { const d = Math.hypot(v.x - x, v.z - z); if (d < r + 0.4) hitVillager(v, 18 + 14 * (1 - d / (r + 0.4))) }\n      scare(x, z, r + 3, ['Fire!', 'Yikes!', 'Run!'])\n      if (Math.hypot(player.x - x, player.z - z) < r) hurt(15, 'caught in your own blast')\n    }\n    function strike(x, z, r, item) {\n      const y = heightAt(x, z)\n      bolts.push({ x, y, z, ry: R() * 6.28, t: 0.28 })\n      flashes.push({ x, y: y + 2, z, r: 18, col: [0.6, 0.75, 1], I: 9, t: 0.3, max: 0.3 })\n      flash = 1\n      burst(x, y + 0.3, z, 30, 6, SPARK, 0.12)\n      for (const v of villagers) if (!v.dead && Math.hypot(v.x - x, v.z - z) < r + 0.4) { v.wait = 1.6; hitVillager(v, 30); if (!v.dead) say(v, 'Zzzt!', 1.6) }\n      if (!item) for (const k of cols) if (k.kind === 'tree' && Math.hypot(k.x - x, k.z - z) < r + k.r && R() < 0.5) igniteTree(k)\n      if (Math.hypot(player.x - x, player.z - z) < r) hurt(20, 'struck by your own lightning')\n      if (item) transmute(item, 'thunder')\n    }\n    function transmute(p, spellId) {\n      if (!pickups.includes(p)) return\n      const from = p.id, to = TRANSMUTE[spellId][from]\n      if (to === undefined) return toast('Nothing happens')\n      const y = heightAt(p.x, p.z) + 0.2\n      if (to === null) { removePickup(p); burst(p.x, y, p.z, 24, 4, spellId === 'fire' ? FLAME : SPARK, 0.1); return toast(`The ${ITEMS[from].name.toLowerCase()} shatters`) }\n      p.id = to; p.up = false\n      burst(p.x, y, p.z, 18, 3, spellId === 'fire' ? FLAME : SPARK, 0.12)\n      toast(`${ITEMS[from].name} → ${ITEMS[to].name}`)\n    }\n\n    // ---- actions ----\n    const projectiles = []\n    function throwHeld(id) {\n      const s = Math.sin(player.ry), c = Math.cos(player.ry)\n      projectiles.push({ kind: 'item', id, x: player.x + s * 0.4, y: heightAt(player.x, player.z) + 1.0, z: player.z + c * 0.4, vx: s * 7.5 + player.vx * 0.5, vy: 4.2, vz: c * 7.5 + player.vz * 0.5, ry: player.ry, spin: 0, hit: false })\n      player.swing = 0.25\n    }\n    function launchFireball(tx, tz, item) {\n      const y0 = heightAt(player.x, player.z) + 1.1, ty = heightAt(tx, tz) + (item ? 0.2 : 0.6)\n      const dx = tx - player.x, dy = ty - y0, dz = tz - player.z, d = Math.hypot(dx, dy, dz) || 1, v = 17\n      projectiles.push({ kind: 'fireball', x: player.x, y: y0, z: player.z, vx: (dx / d) * v, vy: (dy / d) * v, vz: (dz / d) * v, life: d / v, item })\n    }\n    function castAt(sp) {\n      const [tx, tz] = aimPoint(sp)\n      act(sp.cost, () => {\n        face(tx, tz); player.swing = 0.3\n        if (sp.id === 'fire') launchFireball(tx, tz)\n        else later(0.12, () => strike(tx, tz, sp.radius))\n      })\n    }\n    function castOnItem(sp) {\n      const p = spellItemTarget(sp)\n      if (!p) return toast('No item in range')\n      act(sp.itemCost, () => {\n        face(p.x, p.z); player.swing = 0.3\n        if (sp.id === 'fire') launchFireball(p.x, p.z, p)\n        else later(0.12, () => strike(p.x, p.z, 0.7, p))\n      })\n    }\n    function castNova(sp) {\n      act(sp.novaCost, () => {\n        const x = player.x, z = player.z, y = heightAt(x, z)\n        if (sp.id === 'fire') {\n          for (let k = 0; k < 12; k++) { const a = (k / 12) * Math.PI * 2; addFire(x + Math.cos(a) * 3.2, z + Math.sin(a) * 3.2, 0.85, 5) }\n          for (let k = 0; k < 60; k++) { const a = R() * 6.28, v = 5 + R() * 3; emit(x, y + 0.5, z, { vx: Math.cos(a) * v, vy: 0.6 + R(), vz: Math.sin(a) * v, life: 0.55, s0: 0.3, col: FLAME }) }\n          flashes.push({ x, y: y + 1.5, z, r: 16, col: [1, 0.55, 0.2], I: 6, t: 0.4, max: 0.4 }); flash = Math.max(flash, 0.3)\n          for (const k of cols) if (k.kind === 'tree' && Math.hypot(k.x - x, k.z - z) < 4.4 + k.r) igniteTree(k)\n          scare(x, z, 7, ['Fire!', 'Aaah!', 'Watch it!'])\n        } else {\n          for (let k = 0; k < 6; k++) later(k * 0.11, () => { const a = R() * 6.28, r = 2.6 + R() * 2; strike(x + Math.cos(a) * r, z + Math.sin(a) * r, 1.5) })\n          burst(x, y + 1, z, 30, 3, SPARK, 0.1)\n          hurt(5, 'storm feedback')\n        }\n      })\n    }\n    function interactTarget() {\n      const sp = spell()\n      if (sp) return { verb: `Cast ${sp.name}`, kind: 'spell', sp }\n      const h = held()\n      if (h) return { verb: ITEMS[h.id].action, kind: h.id }\n      let best = null, bd = 2.2\n      for (const v of villagers) { if (v.hostile || v.dead) continue; const d = Math.hypot(v.x - player.x, v.z - player.z); if (d < bd) { bd = d; best = { verb: 'Talk', kind: 'villager', v } } }\n      if (best) return best\n      bd = 1.1\n      for (const k of cols) {\n        if (k.kind === 'tree' && !charred.has(k.i)) { const d = Math.hypot(k.x - player.x, k.z - player.z) - k.r; if (d < bd) { bd = d; best = { verb: 'Shake tree', kind: 'tree', k } } }\n        else if ((k.kind === 'hut' || k.kind === 'ruin') && obbDist(k, player.x, player.z) < 0.9) best ??= { verb: k.kind === 'hut' ? 'Knock' : 'Inspect', kind: k.kind }\n      }\n      return best\n    }\n    const shaking = new Map()\n    function shakeTree(k) {\n      if (shaking.has(k.i)) return\n      shaking.set(k.i, 0.7)\n      if (R() < 0.4) { const a = R() * 6.28, r = 1.2 + R(); dropAt('stick', k.x + Math.cos(a) * r, k.z + Math.sin(a) * r) }\n    }\n    function takeHeld() {\n      const s = held(); if (!s) return null\n      if (!--s.n) slots[sel] = null\n      drawSlots(); return s.id\n    }\n    function interact() {\n      if (stats.dead) return\n      const t = interactTarget()\n      if (!t) return\n      if (t.kind === 'spell') return castAt(t.sp)\n      if (t.kind === 'villager') {\n        say(t.v, LINES[(R() * LINES.length) | 0]); t.v.wait = 3.5\n        t.v.ry = Math.atan2(player.x - t.v.x, player.z - t.v.z); face(t.v.x, t.v.z)\n      } else if (t.kind === 'tree') shakeTree(t.k)\n      else if (t.kind === 'hut') say(player, 'Knock knock… nobody home.')\n      else if (t.kind === 'ruin') say(player, 'The old stones hum with a faint, familiar magic.')\n      else if (t.kind === 'stick') act(0, () => {\n        player.swing = 0.3\n        const [fx, fz] = fwd(player, 1.1)\n        for (const v of villagers) if (!v.dead && Math.hypot(v.x - fx, v.z - fz) < 1.1) { hitVillager(v, 8); v.wait = 0.5; v.ry = Math.atan2(player.x - v.x, player.z - v.z) }\n        for (const k of cols) if (k.kind === 'tree' && Math.hypot(k.x - fx, k.z - fz) < k.r + 1.0) shakeTree(k)\n      })\n      else if (t.kind === 'torch') act(0, () => { const [x, z] = fwd(player, 0.8); takeHeld(); dropAt('torch', x, z, true) })\n      else act(0, () => { const id = takeHeld(); if (id) throwHeld(id) })\n    }\n    // hold-to-aim: pressing F / RMB with a spell selected shows the arrow, releasing casts\n    const release = () => { aiming = null; const sp = spell(); if (sp && !invOpen && !stats.dead) castAt(sp) }\n    function pickUp() {\n      if (stats.dead) return\n      const sp = spell()\n      if (sp) return castOnItem(sp)\n      const p = nearestPickup()\n      if (!p) return\n      if (fill(p.id, 1, 2, 36)) return toast('Inventory full')\n      removePickup(p)\n      face(p.x, p.z); player.pick = 0.3\n      toast(`+1 ${ITEMS[p.id].name}`); drawSlots()\n    }\n\n    // ---- movement + collision ----\n    const steep = (ax, az, bx, bz) => Math.abs(heightAt(bx, bz) - heightAt(ax, az)) / (Math.hypot(bx - ax, bz - az) || 1) > 0.9\n    function moveAgent(o, sx, sz, rad) {\n      let nx = o.x + sx, nz = o.z + sz\n      if (steep(o.x, o.z, nx, nz)) {\n        if (sx && !steep(o.x, o.z, o.x + sx, o.z)) nz = o.z\n        else if (sz && !steep(o.x, o.z, o.x, o.z + sz)) nx = o.x\n        else { nx = o.x; nz = o.z }\n      }\n      const q = { x: nx, z: nz }\n      const hit = collide(q, rad, cols) + collide(q, rad, cols)\n      const rr = Math.hypot(q.x, q.z)\n      if (rr > 80) { q.x *= 80 / rr; q.z *= 80 / rr }\n      o.x = q.x; o.z = q.z\n      return hit\n    }\n\n    // ---- input ----\n    const focused = () => hover || wrap.getRootNode().activeElement === wrap\n    d2.on(wrap, 'pointerenter', () => { hover = true })\n    d2.on(wrap, 'pointerleave', () => { hover = false })\n    d2.on(window, 'blur', () => keys.clear())\n    d2.on(wrap, 'contextmenu', e => e.preventDefault())\n    d2.on(window, 'keydown', e => {\n      if (!focused()) return\n      const c = e.code\n      if (c === 'KeyE' || (c === 'Escape' && invOpen)) { if (!stats.dead) toggleInv(!invOpen); e.preventDefault(); return }\n      if (c === 'Escape' && aiming) { aiming = null; return }\n      if (c === 'F3') { showDbg = !showDbg; dbgEl.hidden = !showDbg; miniEl.hidden = showDbg; e.preventDefault(); return }\n      if (c === 'KeyT') tilt = tilt ? 0 : 1\n      if (invOpen || stats.dead) return\n      if (/^Digit[1-9]$/.test(c)) equipSlot(+c[5] - 1)\n      if (c === 'KeyF' && !e.repeat) { if (spell()) aiming = 'key'; else interact() }\n      if (c === 'KeyQ' && !e.repeat) { const id = takeHeld(); if (id) dropAt(id) }\n      if (c === 'Space') { e.preventDefault(); if (spell()) { if (!e.repeat) castNova(spell()); return } }\n      if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'ShiftLeft'].includes(c)) { keys.add(c); e.preventDefault() }\n    })\n    d2.on(window, 'keyup', e => { keys.delete(e.code); if (e.code === 'KeyF' && aiming === 'key') release() })\n    d2.on(wrap, 'wheel', e => { e.preventDefault(); if (!invOpen) cam.dist = clamp(cam.dist * (1 + Math.sign(e.deltaY) * 0.08), 6, 80) }, { passive: false })\n    d2.on(canvas, 'pointerdown', e => {\n      wrap.focus()\n      if (e.button === 0) pickUp()\n      else if (e.button === 2) { if (spell()) aiming = 'mouse'; else interact() }\n      else if (e.button === 1) { e.preventDefault(); orbit = { x: e.clientX, y: e.clientY } }\n    })\n    d2.on(window, 'pointerup', e => { orbit = null; if (e.button === 2 && aiming === 'mouse') release() })\n    d2.on(wrap, 'pointermove', e => {\n      const r = wrap.getBoundingClientRect()\n      mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top\n      curEl.style.left = mouse.x + 'px'; curEl.style.top = mouse.y + 'px'\n      if (orbit) {\n        cam.yaw -= (e.clientX - orbit.x) * 0.006; cam.pitch = clamp(cam.pitch + (e.clientY - orbit.y) * 0.004, 0.25, 1.45)\n        orbit = { x: e.clientX, y: e.clientY }\n      }\n    })\n    d2.on(hotEl, 'pointerdown', e => { e.stopPropagation(); const s = e.target.closest('.slot'); if (s && !stats.dead) equipSlot(+s.dataset.i) })\n    d2.on(invEl, 'pointerdown', e => {\n      e.preventDefault()\n      const s = e.target.closest('.slot')\n      if (s) invClick(+s.dataset.i, e.button, e.shiftKey)\n      else if (!e.target.closest('.panel')) toggleInv(false)\n    })\n    drawSlots()\n\n    // ---- debug camera rig: every axis adjustable from the F3 panel ----\n    const CAM_AXES = [['yaw', -3.14, 3.14, 0.01, 1], ['pitch', 0.1, 1.55, 0.01, 1], ['roll', -0.6, 0.6, 0.01, 1], ['dist', 6, 80, 0.5, 0], ['fov', 0.15, 1.4, 0.01, 1], ['lift', -2, 6, 0.1, 0]]\n    const camCtl = $('.dbg .camctl')\n    camCtl.innerHTML = CAM_AXES.map(([k, a, b, st]) => `<label>${k === 'dist' ? 'zoom' : k}</label><input type=\"range\" data-k=\"${k}\" min=\"${a}\" max=\"${b}\" step=\"${st}\"><output data-o=\"${k}\"></output>`).join('') + '<button type=\"button\">reset camera</button>'\n    const camInputs = [...camCtl.querySelectorAll('input')], camOuts = [...camCtl.querySelectorAll('output')]\n    function syncCamCtl() {\n      const active = wrap.getRootNode().activeElement\n      for (const el of camInputs) if (el !== active) el.value = cam[el.dataset.k]\n      for (const o of camOuts) { const ax = CAM_AXES.find(a => a[0] === o.dataset.o), v = cam[ax[0]]; o.textContent = ax[4] ? ((v * 180) / Math.PI).toFixed(1) + '°' : v.toFixed(1) }\n    }\n    d2.on(camCtl, 'input', e => { const k = e.target.dataset.k; if (k) cam[k] = +e.target.value })\n    d2.on(camCtl, 'click', e => { if (e.target.tagName === 'BUTTON') Object.assign(cam, CAM0) })\n\n    // ---- minimap: FF12-style translucent blue relief with contour lines, camera-up ----\n    const MAP = 512, MK = MAP / 200, MZ = 2.6\n    const mapImg = document.createElement('canvas'); mapImg.width = mapImg.height = MAP\n    {\n      const T = meshes.terrain, N = T.N, step = T.S / N, hh = (i, j) => T.H[clamp(j, 0, N) * (N + 1) + clamp(i, 0, N)]\n      const g = document.createElement('canvas'); g.width = g.height = N + 1\n      const gc = g.getContext('2d'), img = gc.createImageData(N + 1, N + 1), band = h => Math.floor(h / 2.5)\n      for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) {\n        const x = -T.S / 2 + i * step, z = -T.S / 2 + j * step, r = Math.hypot(x, z)\n        if (r > 84) continue\n        const h = hh(i, j), sl = Math.hypot(hh(i + 1, j) - hh(i - 1, j), hh(i, j + 1) - hh(i, j - 1)) / (2 * step), tone = clamp((h + 4) / 20, 0, 1)\n        let c = [40 + tone * 60, 80 + tone * 80, 150 + tone * 80, 110]\n        if (sl > 0.55) c = [195, 228, 255, 235]\n        else if (band(h) !== band(hh(i + 1, j)) || band(h) !== band(hh(i, j + 1))) c = [120, 175, 245, 175]\n        else if (pathDist(x, z) < 1.7) c = [160, 205, 255, 175]\n        const o = (j * (N + 1) + i) * 4\n        img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = c[3] * (1 - sstep(74, 84, r))\n      }\n      gc.putImageData(img, 0, 0)\n      const mc = mapImg.getContext('2d')\n      mc.drawImage(g, 0, 0, MAP, MAP)\n      mc.fillStyle = 'rgba(150,200,255,.28)'\n      for (const t of inst.tree) { mc.beginPath(); mc.arc((t[0] + 100) * MK, (t[2] + 100) * MK, 1.4 * t[3], 0, 6.283); mc.fill() }\n      mc.lineWidth = 1.4; mc.strokeStyle = 'rgba(230,242,255,.95)'; mc.fillStyle = 'rgba(170,210,255,.4)'\n      for (const k of cols) if (k.t === 1 && k.kind !== 'rock') {\n        mc.save(); mc.translate((k.x + 100) * MK, (k.z + 100) * MK); mc.transform(k.c, -k.s, k.s, k.c, 0, 0)\n        mc.fillRect(-k.hx * MK, -k.hz * MK, 2 * k.hx * MK, 2 * k.hz * MK); mc.strokeRect(-k.hx * MK, -k.hz * MK, 2 * k.hx * MK, 2 * k.hz * MK)\n        mc.restore()\n      }\n    }\n    const mctx = miniCv.getContext('2d'), MW = miniCv.width, MR = MW / 2, locEl = $('.mini .loc')\n    const placeName = (x, z) => Math.hypot(x - RUIN[0], z - RUIN[1]) < 14 ? 'Old Chapel' : Math.hypot(x, z) < 17 ? 'Waystone Village' : cliffEdge(x, z) > 25 ? 'Amber Heights' : 'Waystone Vale'\n    function drawMini() {\n      const g = mctx, c = Math.cos(cam.yaw), s = Math.sin(cam.yaw)\n      g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, MW, MW)\n      const bg = g.createRadialGradient(MR, MR, MR * 0.1, MR, MR, MR)\n      bg.addColorStop(0, 'rgba(14,30,64,.5)'); bg.addColorStop(1, 'rgba(6,14,36,.78)')\n      g.fillStyle = bg; g.fillRect(0, 0, MW, MW)\n      g.save(); g.translate(MR, MR); g.rotate(cam.yaw); g.scale(MZ, MZ); g.translate(-(player.x + 100) * MK, -(player.z + 100) * MK)\n      g.drawImage(mapImg, 0, 0)\n      g.restore()\n      // soft circular fade instead of a hard frame\n      const fade = g.createRadialGradient(MR, MR, MR * 0.6, MR, MR, MR - 4)\n      fade.addColorStop(0, 'rgba(0,0,0,1)'); fade.addColorStop(1, 'rgba(0,0,0,0)')\n      g.globalCompositeOperation = 'destination-in'; g.fillStyle = fade; g.fillRect(0, 0, MW, MW); g.globalCompositeOperation = 'source-over'\n      const at = (x, z) => { const dx = (x - player.x) * MK * MZ, dz = (z - player.z) * MK * MZ; return [MR + c * dx - s * dz, MR + s * dx + c * dz] }\n      const dot = (x, z, r, fill) => {\n        const [sx, sy] = at(x, z)\n        if (Math.hypot(sx - MR, sy - MR) > MR - 28) return\n        g.beginPath(); g.arc(sx, sy, r, 0, 6.283); g.fillStyle = fill; g.shadowColor = fill; g.shadowBlur = 10; g.fill(); g.shadowBlur = 0\n      }\n      for (const f of fires) dot(f.x, f.z, 4, 'rgba(255,150,60,.9)')\n      for (const v of villagers) if (!v.dead) dot(v.x, v.z, v.hostile ? 7 : 5, v.hostile ? '#ff3b3b' : '#9fe0ff')\n      const hx = Math.sin(player.ry), hz = Math.cos(player.ry)\n      g.save(); g.translate(MR, MR); g.rotate(Math.atan2(s * hx + c * hz, c * hx - s * hz))\n      g.beginPath(); g.moveTo(16, 0); g.lineTo(-10, -11); g.lineTo(-4, 0); g.lineTo(-10, 11); g.closePath()\n      g.fillStyle = '#fff4c2'; g.shadowColor = '#ffcf5a'; g.shadowBlur = 12; g.fill(); g.restore()\n      g.font = '600 26px Georgia, serif'; g.textAlign = 'center'; g.textBaseline = 'middle'\n      g.fillStyle = '#fff'; g.shadowColor = '#3d7bff'; g.shadowBlur = 10\n      g.fillText('N', MR + s * (MR - 34), MR - c * (MR - 34)); g.shadowBlur = 0\n      const name = placeName(player.x, player.z)\n      if (locEl.textContent !== name) locEl.textContent = name\n    }\n\n    const sun = norm([-0.55, 0.62, 0.42])\n    const U = new Float32Array(120), PUv = new Float32Array(16)\n    const fog = C(0.66, 0.67, 0.67)\n    let last = performance.now(), fpsAcc = 0, fpsN = 0, fps = 0, ms = 0, lastPrompt = '', nLights = 0\n\n    d2.raf(() => {\n      if (!alive) return false\n      if (!canvas.clientWidth || !canvas.clientHeight) return\n      resize()\n      const now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now\n      fpsAcc += dt; fpsN++\n      if (fpsAcc > 0.5) { fps = fpsN / fpsAcc; ms = (fpsAcc / fpsN) * 1000; fpsAcc = 0; fpsN = 0 }\n      time = now / 1000\n\n      // stats: AP fills like an ATB gauge, MP regenerates, HP only after 5s without damage\n      if (stats.dead) {\n        stats.dead -= dt\n        if (stats.dead <= 0) {\n          stats.dead = 0; deadEl.hidden = true\n          Object.assign(player, SPAWN, { vx: 0, vz: 0 }); Object.assign(stats, { hp: HP_MAX, mp: MP_MAX, ap: 0 })\n          for (const v of villagers) { v.hostile = false; v.combat = 0 }\n          toast('You re-form at the waystone')\n        }\n      } else {\n        stats.ap = Math.min(1, stats.ap + dt / AP_FILL)\n        stats.mp = Math.min(MP_MAX, stats.mp + MP_REGEN * dt)\n        if (time - stats.lastHurt > 5) stats.hp = Math.min(HP_MAX, stats.hp + 2 * dt)\n      }\n      stats.hurtFx = Math.max(0, stats.hurtFx - dt * 1.6)\n      flash = Math.max(0, flash - dt * 4)\n      for (let i = timers.length - 1; i >= 0; i--) if ((timers[i].t -= dt) <= 0) { const f = timers[i].fn; timers.splice(i, 1); f() }\n\n      // player: camera-relative input, eased velocity, turn toward input (Animal Crossing feel)\n      const fx = -Math.sin(cam.yaw), fz = -Math.cos(cam.yaw), rx = Math.cos(cam.yaw), rz = -Math.sin(cam.yaw)\n      let ix = 0, iz = 0\n      if (!stats.dead) {\n        if (keys.has('KeyW') || keys.has('ArrowUp')) { ix += fx; iz += fz }\n        if (keys.has('KeyS') || keys.has('ArrowDown')) { ix -= fx; iz -= fz }\n        if (keys.has('KeyD') || keys.has('ArrowRight')) { ix += rx; iz += rz }\n        if (keys.has('KeyA') || keys.has('ArrowLeft')) { ix -= rx; iz -= rz }\n      }\n      const il = Math.hypot(ix, iz), sprint = (keys.has('Space') && !spell()) || keys.has('ShiftLeft')\n      const vmax = sprint ? 6.8 : 3.4, ke = 1 - Math.exp(-(il ? 12 : 16) * dt)\n      player.vx += ((il ? (ix / il) * vmax : 0) - player.vx) * ke\n      player.vz += ((il ? (iz / il) * vmax : 0) - player.vz) * ke\n      player.speed = Math.hypot(player.vx, player.vz)\n      player.contacts = 0\n      if (player.speed > 0.02) {\n        const ox = player.x, oz = player.z\n        player.contacts = moveAgent(player, player.vx * dt, player.vz * dt, 0.38)\n        for (const v of villagers) {\n          const dx = player.x - v.x, dz = player.z - v.z, d = Math.hypot(dx, dz)\n          if (!v.dead && d < 0.7 && d > 1e-4) { player.x = v.x + (dx / d) * 0.7; player.z = v.z + (dz / d) * 0.7 }\n        }\n        // bleed velocity into walls so we slide instead of grinding\n        if (player.contacts) { player.vx = (player.x - ox) / dt; player.vz = (player.z - oz) / dt }\n        player.bob += dt * player.speed * 2.6\n      }\n      if (il) player.ry = lerpAngle(player.ry, Math.atan2(ix, iz), 1 - Math.exp(-14 * dt))\n      player.swing = Math.max(0, player.swing - dt); player.pick = Math.max(0, player.pick - dt)\n\n      for (const v of villagers) {\n        v.sayT = Math.max(0, v.sayT - dt); v.lunge = Math.max(0, v.lunge - dt)\n        if (v.dead) {\n          v.dead -= dt\n          if (v.dead <= 0) Object.assign(v, { dead: 0, x: v.hx, z: v.hz, tx: v.hx, tz: v.hz, hp: V_HP, hostile: false, combat: 0, wait: 1 })\n          continue\n        }\n        v.combat = Math.max(0, v.combat - dt)\n        if (v.hostile) {\n          // chase the player and strike whenever its own ATB gauge is full\n          const dx = player.x - v.x, dz = player.z - v.z, d = Math.hypot(dx, dz)\n          v.atb = Math.min(1, v.atb + dt / V_ATB)\n          if (stats.dead || (d > 28 && !v.combat)) { v.hostile = false; v.combat = 0; say(v, 'Hmph.', 1.5); continue }\n          if (d < 9) v.combat = Math.max(v.combat, 2)\n          if (v.wait > 0) { v.wait -= dt; continue }\n          v.ry = lerpAngle(v.ry, Math.atan2(dx, dz), 1 - Math.exp(-10 * dt))\n          if (d > 1.05) { moveAgent(v, (dx / d) * 2.7 * dt, (dz / d) * 2.7 * dt, 0.35); v.bob += dt * 12 }\n          else if (v.atb >= 1) { v.atb = 0; v.lunge = 0.25; hurt(V_ATK, 'beaten by an angry villager'); burst(player.x, heightAt(player.x, player.z) + 0.9, player.z, 6, 2, FLAME_RED, 0.06) }\n          continue\n        }\n        v.hp = Math.min(V_HP, v.hp + dt * 2)\n        if (v.flee > 0) {\n          v.flee -= dt\n          const dx = v.x - v.fx, dz = v.z - v.fz, d = Math.hypot(dx, dz) || 1\n          moveAgent(v, (dx / d) * 4.5 * dt, (dz / d) * 4.5 * dt, 0.35)\n          v.ry = lerpAngle(v.ry, Math.atan2(dx, dz), 1 - Math.exp(-12 * dt)); v.bob += dt * 16\n          v.tx = v.x; v.tz = v.z\n          continue\n        }\n        if (v.wait > 0) { v.wait -= dt; continue }\n        const dx = v.tx - v.x, dz = v.tz - v.z, d = Math.hypot(dx, dz)\n        if (d < 0.25) { const a = R() * 6.28, r = 2 + R() * 10; v.tx = Math.cos(a) * r; v.tz = Math.sin(a) * r; v.wait = 1 + R() * 4; continue }\n        const ox = v.x, oz = v.z\n        moveAgent(v, (dx / d) * 1.3 * dt, (dz / d) * 1.3 * dt, 0.35)\n        if (Math.hypot(v.x - ox, v.z - oz) < 0.4 * 1.3 * dt) { v.tx = v.x; v.tz = v.z }   // blocked: pick a new spot next frame\n        v.ry = lerpAngle(v.ry, Math.atan2(dx, dz), 1 - Math.exp(-8 * dt)); v.bob += dt * 6\n      }\n      player.sayT = Math.max(0, player.sayT - dt)\n\n      // projectiles: thrown items arc and land; fireballs fly straight and explode\n      for (let i = projectiles.length - 1; i >= 0; i--) {\n        const p = projectiles[i]\n        if (p.kind === 'fireball') {\n          p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; p.life -= dt\n          for (let k = 0; k < 3; k++) emit(p.x, p.y, p.z, { vx: (R() - 0.5), vy: 0.6 + R(), vz: (R() - 0.5), life: 0.3 + R() * 0.25, s0: 0.28, col: R() < 0.4 ? FLAME_RED : FLAME })\n          const o = { x: p.x, z: p.z }, blocked = !p.item && collide(o, 0.2, cols)\n          if (p.life <= 0 || blocked || p.y < heightAt(p.x, p.z)) {\n            projectiles.splice(i, 1)\n            if (p.item) { transmute(p.item, 'fire'); burst(p.x, p.y, p.z, 14, 3, FLAME, 0.14) }\n            else explode(p.x, p.z, SPELLS[0].radius)\n          }\n          continue\n        }\n        p.vy -= 14 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; p.spin += dt * 14\n        if (p.id === 'ember' && R() < 0.5) emit(p.x, p.y, p.z, { vy: 0.5, life: 0.3, s0: 0.12, col: FLAME })\n        if (p.id === 'shard' && R() < 0.5) emit(p.x, p.y, p.z, { vy: 0.3, life: 0.25, s0: 0.08, col: SPARK })\n        const o = { x: p.x, z: p.z }\n        if (collide(o, 0.12, cols)) { p.x = o.x; p.z = o.z; p.vx *= -0.3; p.vz *= -0.3 }\n        for (const v of villagers) if (!v.dead && !p.hit && Math.hypot(v.x - p.x, v.z - p.z) < 0.5 && p.y - heightAt(v.x, v.z) < 1.3) { p.hit = true; hitVillager(v, 6); if (!v.dead) say(v, 'Ouch!', 1.2); v.wait = 0.6; p.vx *= -0.25; p.vz *= -0.25 }\n        const g = heightAt(p.x, p.z) + 0.06\n        if (p.y < g) {\n          if (p.id === 'ember') { projectiles.splice(i, 1); addFire(p.x, p.z, 1.3, 6); burst(p.x, g, p.z, 20, 4, FLAME); scare(p.x, p.z, 4, ['Fire!']); for (const k of cols) if (k.kind === 'tree' && Math.hypot(k.x - p.x, k.z - p.z) < 1.5 + k.r) igniteTree(k); dropAt('pebble', p.x, p.z) }\n          else if (p.id === 'shard') { projectiles.splice(i, 1); strike(p.x, p.z, SPELLS[1].radius) }\n          else if (p.vy < -2.5) { p.y = g; p.vy *= -0.3; p.vx *= 0.5; p.vz *= 0.5 }\n          else { projectiles.splice(i, 1); dropAt(p.id, p.x, p.z) }\n        }\n      }\n\n      // fires: flames, damage, spread to trees\n      const lights = []\n      for (let i = fires.length - 1; i >= 0; i--) {\n        const f = fires[i], g = heightAt(f.x, f.z), k = Math.min(1, f.t / 1.5)\n        f.t -= dt; f.acc += f.r * f.r * 34 * k * dt\n        for (; f.acc >= 1; f.acc--) { const a = R() * 6.28, rr = Math.sqrt(R()) * f.r; flame(f.x + Math.cos(a) * rr, g + 0.05, f.z + Math.sin(a) * rr, 0.2, 0.32 * k + 0.08) }\n        if (Math.hypot(player.x - f.x, player.z - f.z) < f.r + 0.25) hurt(14 * dt, 'burned')\n        for (const v of villagers) if (!v.dead && Math.hypot(v.x - f.x, v.z - f.z) < f.r + 0.5) { hitVillager(v, 12 * dt); if (!v.hostile && v.flee <= 0) { v.flee = 1.5; v.fx = f.x; v.fz = f.z; say(v, 'Hot hot hot!', 1.5) } }\n        lights.push({ x: f.x, y: g + 1.1, z: f.z, r: 4 + f.r * 3, col: [1, 0.5, 0.18], I: 2.6 * k })\n        if (f.t <= 0) fires.splice(i, 1)\n      }\n      for (const [i, t] of burning) {\n        const row = inst.tree[i], s = row[3], left = t - dt, k = Math.min(1, left / 2)\n        for (let n = 0; n < 2; n++) if (R() < 0.8) flame(row[0] + (R() - 0.5) * 1.6 * s, row[1] + (1.7 + R() * 1.6) * s, row[2] + (R() - 0.5) * 1.6 * s, 0.3, 0.42 * k + 0.1)\n        lights.push({ x: row[0], y: row[1] + 2.6 * s, z: row[2], r: 9, col: [1, 0.5, 0.18], I: 3.5 * k })\n        if (left <= 0) {\n          burning.delete(i); charred.add(i)\n          row[8] *= 0.12; row[9] *= 0.1; row[10] *= 0.09; row[5] = 0.2\n          device.queue.writeBuffer(draws.tree.inb, i * 48, new Float32Array(row))\n        } else burning.set(i, left)\n      }\n      for (let i = flashes.length - 1; i >= 0; i--) { const f = flashes[i]; f.t -= dt; if (f.t <= 0) flashes.splice(i, 1); else lights.push({ ...f, I: f.I * (f.t / f.max) }) }\n      for (let i = bolts.length - 1; i >= 0; i--) if ((bolts[i].t -= dt) <= 0) bolts.splice(i, 1)\n      for (let i = parts.length - 1; i >= 0; i--) {\n        const p = parts[i]\n        p.t += dt\n        if (p.t >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue }\n        p.vy -= p.g * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt\n      }\n\n      // dynamic instances\n      const py = heightAt(player.x, player.z)\n      const crouch = player.pick ? Math.sin((player.pick / 0.3) * Math.PI) * 0.18 : 0\n      const pY = py + Math.abs(Math.sin(player.bob)) * 0.07 * Math.min(player.speed / 3, 1) - crouch\n      setInstances('knight', stats.dead ? [] : [[player.x, pY, player.z, 1, player.ry, 0, 0, 0, 1, 1, 1, 0]])\n      setInstances('villager', villagers.filter(v => !v.dead).map(v => {\n        const l = v.lunge > 0 ? Math.sin((v.lunge / 0.25) * Math.PI) * 0.35 : 0\n        return [v.x + Math.sin(v.ry) * l, heightAt(v.x, v.z) + (v.wait > 0 ? 0 : Math.abs(Math.sin(v.bob)) * 0.04), v.z + Math.cos(v.ry) * l, 1, v.ry, 0, 0, 0, ...(v.hostile ? [1.35, 0.62, 0.55] : [1, 1, 1]), 0]\n      }))\n      const cy = Math.cos(player.ry), sy = Math.sin(player.ry)\n      const flick = 0.85 + Math.sin(time * 13) * 0.08 + Math.sin(time * 7.3) * 0.07\n      const torch = M4.xform(new Float32Array([Math.cos(0.35), 0, -Math.sin(0.35), 0, 0, 1, 0, 0, Math.sin(0.35), 0, Math.cos(0.35), 0, RUIN[0], heightAt(...RUIN) - 0.1, RUIN[1], 1]), [5.3, 2.5, 0.75])\n      const lantern = [player.x + cy * 0.35, pY + 1.15, player.z - sy * 0.35]\n      const rows = { pebble: [], stick: [], ring: [], bolt: [], plate: [], arrowhead: [], ember: [[...torch, 0.13, 0, 0, 0, 0, ...C(1, 0.62, 0.25).map(c => c * flick), 2]] }\n      if (!stats.dead) rows.ember.push([...lantern, 0.08, 0, 0, 0, 0, ...C(1, 0.75, 0.4), 2])\n      const GLOW = { ember: C(1, 0.42, 0.12).map(c => c * 0.4), shard: [0.18, 0.4, 1].map(c => c * 0.45) }\n      // a torch tip: stick mesh runs 0.9 along local +x, tilted by `tl`, yawed by `a`\n      const tip = (x, y, z, a, tl, s) => { const X = 0.9 * s * Math.cos(tl); return [x + Math.cos(a) * X, y + 0.9 * s * Math.sin(tl), z - Math.sin(a) * X] }\n      const torchFx = (t, big) => { if (R() < (big ? 0.9 : 0.6)) flame(t[0], t[1] + 0.05, t[2], 0.08, big ? 0.16 : 0.12); lights.push({ x: t[0], y: t[1] + 0.4, z: t[2], r: big ? 7 : 5, col: [1, 0.55, 0.22], I: 2.2 }) }\n      for (const p of pickups) {\n        const y = heightAt(p.x, p.z)\n        if (p.id === 'stick' || p.id === 'torch') {\n          const s = p.up ? 1.6 : 1, tl = p.up ? Math.PI / 2 : 0\n          rows.stick.push([p.x, y + 0.01, p.z, s, p.ry, 0, 0, tl, 1, 1, 1, 0])\n          if (p.id === 'torch') torchFx(tip(p.x, y + 0.01, p.z, p.ry, tl, s), p.up)\n        } else if (GLOW[p.id]) {\n          rows.pebble.push([p.x, y + 0.06, p.z, p.id === 'shard' ? 1.2 : 1, p.ry, 0, 0, p.id === 'shard' ? 0.8 : 0, ...GLOW[p.id], 2])\n          if (R() < 0.15) emit(p.x, y + 0.15, p.z, { vy: 0.5, life: 0.4, s0: 0.06, col: p.id === 'ember' ? FLAME : SPARK })\n          lights.push({ x: p.x, y: y + 0.5, z: p.z, r: 2.5, col: p.id === 'ember' ? [1, 0.45, 0.15] : [0.5, 0.7, 1], I: 1.2 })\n        } else rows.pebble.push([p.x, y + 0.05, p.z, 1, p.ry, 0, 0, 0, 1, 1, 1, 0])\n      }\n      for (const p of projectiles) if (p.kind === 'item') rows.pebble.push([p.x, p.y, p.z, 1, p.ry, 0, 0, p.spin, ...(GLOW[p.id] ? [...GLOW[p.id], 2] : [1, 1, 1, 0])])\n        else { rows.ember.push([p.x, p.y, p.z, 0.32, 0, 0, 0, 0, ...C(1, 0.55, 0.2).map(c => c * 0.4), 2]); lights.push({ x: p.x, y: p.y, z: p.z, r: 8, col: [1, 0.5, 0.18], I: 4 }) }\n      const h = held(), sp = spell()\n      const hand = (lx, ly, lz) => [player.x + cy * lx + sy * lz, pY + ly, player.z - sy * lx + cy * lz]\n      if (!stats.dead) {\n        if (h?.id === 'stick' || h?.id === 'torch') {\n          const u = player.swing ? 1 - player.swing / 0.3 : 0, tl = 0.9 - Math.sin(u * Math.PI) * 1.7, at = hand(-0.36, 0.5, 0.12)\n          rows.stick.push([...at, 1, player.ry - Math.PI / 2, 0, 0, tl, 1, 1, 1, 0])\n          if (h.id === 'torch') torchFx(tip(...at, player.ry - Math.PI / 2, tl, 1), true)\n        } else if (h) rows.pebble.push([...hand(-0.34, 0.58, 0.2), h.id === 'shard' ? 1.2 : 1, player.ry, 0, 0, 0, ...(GLOW[h.id] ? [...GLOW[h.id], 2] : [1, 1, 1, 0])])\n        if (sp) {\n          // spell in hand: a small orb of its element, plus a reticle where it will land\n          const orb = hand(-0.3, 0.75, 0.25), k = stats.ap >= 1 ? 1 : 0.35\n          rows.ember.push([...orb, 0.1 + 0.02 * Math.sin(time * 9), 0, 0, 0, 0, ...C(...sp.glow).map(c => c * 0.45 * k), 2])\n          if (R() < 0.4 * k) emit(orb[0], orb[1], orb[2], { vy: 0.4, vx: (R() - 0.5) * 0.4, vz: (R() - 0.5) * 0.4, life: 0.35, s0: 0.06, col: sp.id === 'fire' ? FLAME : SPARK })\n          if (!invOpen) {\n            const [ax, az] = aimPoint(sp), ring = C(...sp.glow).map(c => c * 0.16 * k)\n            rows.ring.push([ax, heightAt(ax, az) + 0.08, az, sp.radius, time * 0.6, 0, 0, 0, ...ring, 2])\n            // LoL-style skillshot arrow while the cast button is held: terrain-hugging tiles + arrowhead\n            const adx = ax - player.x, adz = az - player.z, L = Math.hypot(adx, adz)\n            if (aiming && L > 0.9) {\n              const ux = adx / L, uz = adz / L, ary = Math.atan2(adx, adz), blue = [0.2, 0.45, 1].map(c => c * 0.22 * k), TW = 0.5, end = L - 0.75\n              for (let t = 0.6; t < end; t += TW * 0.9) { const x = player.x + ux * (t + TW / 2), z = player.z + uz * (t + TW / 2); rows.plate.push([x, heightAt(x, z) + 0.07, z, TW, ary, 0, 0, 0, ...blue, 2]) }\n              const ex = player.x + ux * end, ez = player.z + uz * end\n              rows.arrowhead.push([ex, heightAt(ex, ez) + 0.08, ez, 1, ary, 0, 0, 0, ...blue.map(c => c * 1.3), 2])\n            }\n            const it = spellItemTarget(sp)\n            if (it) rows.ring.push([it.x, heightAt(it.x, it.z) + 0.06, it.z, 0.55, -time * 1.5, 0, 0, 0, ...ring.map(c => c * 1.6), 2])\n          }\n        }\n      }\n      for (const b of bolts) rows.bolt.push([b.x, b.y, b.z, 1, b.ry, 0, 0, 0, ...[0.55, 0.7, 1].map(c => c * (0.35 + 0.35 * R())), 2])\n      for (const p of parts) {\n        const u = p.t / p.life, f = 1 - u\n        rows.ember.push([p.x, p.y, p.z, p.s0 + (p.s1 - p.s0) * u, 0, 0, 0, 0, p.col[0] * f, p.col[1] * f, p.col[2] * f, 2])\n      }\n      for (const k of Object.keys(rows)) setInstances(k, rows[k])\n      for (const [i, t] of shaking) {\n        const left = t - dt, row = inst.tree[i].slice(), a = Math.max(left, 0) / 0.7\n        row[0] += Math.sin(time * 47) * 0.07 * a; row[2] += Math.cos(time * 39) * 0.05 * a\n        device.queue.writeBuffer(draws.tree.inb, i * 48, new Float32Array(row))\n        if (left <= 0) shaking.delete(i); else shaking.set(i, left)\n      }\n      lights.push({ x: torch[0], y: torch[1], z: torch[2], r: 9, col: [1, 0.55, 0.2], I: 3.5 * flick })\n      if (!stats.dead) lights.push({ x: lantern[0], y: lantern[1], z: lantern[2], r: 5, col: [1, 0.7, 0.4], I: 1.6 })\n\n      // camera\n      const k = 1 - Math.exp(-dt * 4)\n      cam.tx += (player.x - cam.tx) * k; cam.tz += (player.z - cam.tz) * k; cam.ty += (py + cam.lift - cam.ty) * k\n      const target = [cam.tx, cam.ty, cam.tz]\n      eye = [target[0] + Math.sin(cam.yaw) * Math.cos(cam.pitch) * cam.dist, target[1] + Math.sin(cam.pitch) * cam.dist, target[2] + Math.cos(cam.yaw) * Math.cos(cam.pitch) * cam.dist]\n      const near = 1, far = 420\n      viewProj = M4.mul(M4.persp(cam.fov, W / H, near, far), M4.mul(M4.rotZ(cam.roll), M4.lookAt(eye, target, [0, 1, 0])))\n      const lightVP = M4.mul(M4.ortho(-48, 48, -48, 48, 1, 260), M4.lookAt([target[0] + sun[0] * 120, target[1] + sun[1] * 120, target[2] + sun[2] * 120], target, [0, 1, 0]))\n\n      // HUD: stat bars, prompt above the player, speech bubbles, debug panel\n      for (const [key, v, max, txt] of [['hp', stats.hp, HP_MAX, `${Math.ceil(stats.hp)}/${HP_MAX}`], ['mp', stats.mp, MP_MAX, `${Math.floor(stats.mp)}/${MP_MAX}`], ['ap', stats.ap, 1, stats.ap >= 1 ? 'READY' : `${Math.floor(stats.ap * 100)}%`]]) {\n        bars[key].b.style.width = (v / max) * 100 + '%'; bars[key].t.textContent = txt\n      }\n      bars.ap.el.classList.toggle('ready', stats.ap >= 1)\n      hurtEl.style.opacity = Math.min(1, stats.hurtFx).toFixed(3)\n      if (SPELLS.map(s => stats.mp < s.cost).join() !== lastPoor) drawSlots()\n      let prompt = ''\n      if (!invOpen && !stats.dead) {\n        if (sp) {\n          const it = spellItemTarget(sp)\n          prompt = [aiming ? `Release to cast ${sp.name} · ${sp.cost} MP` : `Hold <b>F</b><b>RMB</b>${sp.name} · ${sp.cost} MP`,\n            it && `<b>LMB</b>${ITEM_VERB[sp.id][it.id] || 'Cast on'} ${ITEMS[it.id].name} · ${sp.itemCost} MP`,\n            `<b>Space</b>${sp.nova} · ${sp.novaCost} MP`].filter(Boolean).join('<br>')\n        } else {\n          const pk = nearestPickup(), it = interactTarget()\n          prompt = [pk && `<b>LMB</b>Pick up ${ITEMS[pk.id].name}`, it && `<b>F</b>${it.verb}`].filter(Boolean).join('<br>')\n        }\n      }\n      if (prompt !== lastPrompt) { promptEl.innerHTML = prompt; lastPrompt = prompt }\n      promptEl.classList.toggle('wait', stats.ap < 1 && !!sp)\n      placeTag(promptEl, player.x, pY + (player.sayT ? 2.5 : 1.7), player.z)\n      if (!prompt) promptEl.hidden = true\n      for (const v of [player, ...villagers]) if (v.tag) {\n        placeTag(v.tag, v.x, heightAt(v.x, v.z) + (v === player ? 1.6 : 1.9), v.z)\n        if (!v.sayT) v.tag.hidden = true\n      }\n      // life bars over villagers that are in combat\n      for (const v of villagers) {\n        if (!v.dead && (v.hostile || v.combat > 0)) {\n          if (!v.hpEl) { v.hpEl = document.createElement('div'); v.hpEl.className = 'vhp'; v.hpEl.innerHTML = '<b></b>'; worldEl.appendChild(v.hpEl) }\n          placeTag(v.hpEl, v.x, heightAt(v.x, v.z) + 1.5, v.z)\n          v.hpEl.firstChild.style.width = Math.max(0, (v.hp / V_HP) * 100) + '%'\n          v.hpEl.classList.toggle('calm', !v.hostile)\n        } else if (v.hpEl) v.hpEl.hidden = true\n      }\n\n      // lights: the 8 strongest near the camera focus\n      lights.sort((a, b) => b.I / (1 + Math.hypot(b.x - target[0], b.z - target[2]) / 12) - a.I / (1 + Math.hypot(a.x - target[0], a.z - target[2]) / 12))\n      nLights = Math.min(8, lights.length)\n      U.fill(0, 56)\n      for (let i = 0; i < nLights; i++) { const l = lights[i]; U.set([l.x, l.y, l.z, l.r, ...l.col, l.I], 56 + i * 8) }\n\n      if (showDbg) {\n        let tris = 0, instances = 0, calls = 0\n        for (const d of Object.values(draws)) if (d.count) { tris += (d.n / 3) * d.count; instances += d.count; calls++ }\n        const deg = r => ((r * 180) / Math.PI).toFixed(1) + '°'\n        const hs = held()\n        dbgPre.textContent = [\n          `fps ${fps.toFixed(0)} · ${ms.toFixed(1)} ms · ${W}×${H} @${scale}x`,\n          `cam yaw ${deg(cam.yaw)} pitch ${deg(cam.pitch)} roll ${deg(cam.roll)} zoom ${cam.dist.toFixed(1)}`,\n          `    fov ${deg(cam.fov)} lift ${cam.lift.toFixed(1)}`,\n          `    focus ${cam.dist.toFixed(1)} · tilt-shift ${tilt ? 'on' : 'off'}${cam.yaw !== CAM0.yaw || cam.pitch !== CAM0.pitch ? ' · orbited' : ''}`,\n          `player ${player.x.toFixed(2)}, ${py.toFixed(2)}, ${player.z.toFixed(2)} · heading ${deg(((player.ry % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI))}`,\n          `    speed ${player.speed.toFixed(2)} ${player.speed < 0.05 ? 'idle' : sprint ? 'sprint' : 'walk'} · slope ${slopeAt(player.x, player.z).toFixed(2)} · contacts ${player.contacts}`,\n          `hp ${stats.hp.toFixed(1)} · mp ${stats.mp.toFixed(1)} · ap ${(stats.ap * 100).toFixed(0)}%${stats.dead ? ' · DEAD' : ''}`,\n          `slot ${sel + 1}${equipped ? ' equipped' : ''} · ${sp ? `spell ${sp.name}` : hs ? `held ${ITEMS[hs.id].name} ×${hs.n}` : 'empty hand'}`,\n          `fx particles ${parts.length} · fires ${fires.length} · burning trees ${burning.size} · charred ${charred.size}`,\n          `   lights ${nLights}/8 (of ${lights.length}) · projectiles ${projectiles.length} · flash ${flash.toFixed(2)}`,\n          `world pickups ${pickups.length} · colliders ${cols.length}${aiming ? ' · aiming' : ''}`,\n          `villagers ${villagers.filter(v => !v.dead).length}/${villagers.length} · hostile ${villagers.filter(v => v.hostile).length} · in combat ${villagers.filter(v => v.combat > 0).length}`,\n          `gpu ${calls * 2 + 1} draws · ${(tris / 1e6).toFixed(2)}M tris · ${(instances / 1000).toFixed(1)}k inst`,\n        ].join('\\n')\n      }\n\n      if (showDbg) syncCamCtl(); else drawMini()\n\n      U.set(viewProj, 0); U.set(lightVP, 16)\n      U.set([...eye, time], 32); U.set([...sun, 1], 36)\n      U.set([1.0 * 2.8, 0.86 * 2.8, 0.66 * 2.8, 0], 40)\n      U.set([0.24 + flash * 0.5, 0.27 + flash * 0.6, 0.33 + flash * 0.8, 0], 44); U.set([0.15, 0.12, 0.08, 0], 48)\n      U.set([...fog, 0.0095], 52)\n      device.queue.writeBuffer(uBuf, 0, U)\n      const focus = Math.hypot(eye[0] - target[0], eye[1] - target[1], eye[2] - target[2])\n      PUv.set([W, H, near, far, focus, focus * 0.3, time, tilt, 12 * (H / 900), 0.92 + flash * 0.35, 0, 0, ...fog, 1])\n      device.queue.writeBuffer(pBuf, 0, PUv)\n\n      const enc = device.createCommandEncoder()\n      const drawAll = (pass, shadow) => {\n        for (const name of DRAW_ORDER) {\n          const d = draws[name]; if (!d.count || (shadow && SHADOWLESS.has(name))) continue\n          pass.setVertexBuffer(0, d.vb); pass.setVertexBuffer(1, d.inb); pass.setIndexBuffer(d.ib, 'uint32')\n          pass.drawIndexed(d.n, d.count)\n        }\n      }\n      const shp = enc.beginRenderPass({ colorAttachments: [], depthStencilAttachment: { view: shadowTex.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'store' } })\n      shp.setPipeline(shadowPipe); shp.setBindGroup(0, shadowBG); drawAll(shp, true); shp.end()\n      const mp = enc.beginRenderPass({\n        colorAttachments: [{ view: hdrTex.createView(), clearValue: { r: fog[0], g: fog[1], b: fog[2], a: 1 }, loadOp: 'clear', storeOp: 'store' }],\n        depthStencilAttachment: { view: depthTex.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'store' },\n      })\n      mp.setPipeline(mainPipe); mp.setBindGroup(0, mainBG); drawAll(mp, false); mp.end()\n      const pp = enc.beginRenderPass({ colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }] })\n      pp.setPipeline(postPipe); pp.setBindGroup(0, postBG); pp.draw(3); pp.end()\n      device.queue.submit([enc.finish()])\n    })\n  },\n}\n"}}}}}}}},{"_id":"67d5e1e8698690841856d023","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"component":"JfsOzWC5Bl9CZyvsPoJgN","package":"lucca-playground","definition":{"params":{"portions":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"transform-bricks"}}},"component":"data2-core-types@Reference"}},"component":"data2-core-functions@StringConcat"},"inlineParamsControl":{"component":"data2-core-primitives@Boolean","params":{"content":false}}}},{"_id":"5ed9bed7b9e0891ce913e884","component":"data2-core-types@Component","params":{"package":"data2-core","component":"DataSource","renderable":{"dimensions":{"disabled":false,"component":"data2-core-types@Dimensions","params":{"width":1366,"exactWidth":false,"exactHeight":false,"height":768}}}}},{"_id":"5f472ae463c680de4ff296e9","component":"data2-core-types@Component","params":{"package":"data2-core","component":"Data2TableDataSource","extends":"data2-core@DataSource","renderable":{"dimensions":{"disabled":false,"component":"data2-core-types@Dimensions","params":{"width":1366,"exactWidth":false,"exactHeight":false,"height":768}}}}},{"_id":"5ecd05ded68ce41360f74b43","component":"data2-core-types@Component","params":{"extendable":{"placeholder":"children"},"component":"Block","package":"data2-core-primitives"}},{"_id":"5f494791a575399a45020f87","component":"data2-core-types@Component","params":{"definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"html@input","params":{"style":{"width":"100%","height":"100%","boxSizing":"border-box","background":{"component":"data2-core-flow@If","params":{"compare":[{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"type","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":"checkbox"}}],"then":{"component":"data2-core-flow@If","params":{"compare":[{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"component":"data2-core-types@DataLinkGetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"comparisonSubject":true}}],"then":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"checkboxFill"}}}},"rgba(0,118,255,1)"]},"component":"data2-core-logical-operators@Or"},"else":{"params":{"content":"none"},"component":"data2-core-primitives@String"}}},"else":{"params":{"content":"none"},"component":"data2-core-primitives@String"}}},"color":{"params":{"reference":"super.textColor"},"component":"data2-core-types@Reference"},"border":"none","outline":"none","appearance":{"component":"data2-core-flow@If","params":{"compare":[{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"orient","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":"vertical"}}],"then":"slider-vertical","else":"none"}},"cursor":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"super.cursor"}}}},{"component":"data2-core-flow@If","params":{"compare":[{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"disabled","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":true}}],"then":{"component":"data2-core-primitives@String","params":{"content":"not-allowed"}},"else":{"component":"data2-core-primitives@String","params":{"content":"auto"}}}}]},"component":"data2-core-logical-operators@Or"},"fontWeight":{"component":"data2-core-primitives@String","params":{"content":"inherit"}},"paddingLeft":{"component":"data2-core-flow@If","params":{"compare":[{"params":{"comparisonSubject":{"params":{},"component":"data2-core-primitives@Undefined"},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"padding.left"}}}}},"component":"data2-core-comparison-operators@Different"}],"then":{"params":{"portions":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"padding.left"}}}},{"component":"data2-core-primitives@String","params":{"content":"px"}}]},"component":"data2-core-functions@StringConcat"}}},"paddingRight":{"component":"data2-core-flow@If","params":{"compare":[{"params":{"comparisonSubject":{"params":{},"component":"data2-core-primitives@Undefined"},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"padding.right"}}}}},"component":"data2-core-comparison-operators@Different"}],"then":{"params":{"portions":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"padding.right"}}}},{"component":"data2-core-primitives@String","params":{"content":"px"}}]},"component":"data2-core-functions@StringConcat"}}},"caretColor":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"caretColor"}}}},"textAlign":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"super.textFormat.textAlign"}}},"component":"data2-core-types@Reference"}},"tabIndex":{"component":"data2-core-types@Reference","params":{"reference":"tabIndex","source":"VIRTUAL_COMPONENT_PARAMS"}},"step":{"component":"data2-core-types@Reference","params":{"reference":"step","source":"VIRTUAL_COMPONENT_PARAMS"}},"min":{"component":"data2-core-types@Reference","params":{"reference":"min","source":"VIRTUAL_COMPONENT_PARAMS"}},"max":{"component":"data2-core-types@Reference","params":{"reference":"max","source":"VIRTUAL_COMPONENT_PARAMS"}},"maxLength":{"params":{"then":{"params":{},"component":"data2-core-primitives@Undefined"},"compare":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":"maxLength","source":"VIRTUAL_COMPONENT_PARAMS"}},"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":""}}},"component":"data2-core-comparison-operators@Equal"}],"else":{"component":"data2-core-types@Reference","params":{"reference":"maxLength","source":"VIRTUAL_COMPONENT_PARAMS"}}},"component":"data2-core-flow@If"},"orient":{"component":"data2-core-types@Reference","params":{"reference":"orient","source":"VIRTUAL_COMPONENT_PARAMS"}},"type":{"component":"data2-core-types@Reference","params":{"reference":"type","source":"VIRTUAL_COMPONENT_PARAMS"}},"placeholder":{"component":"data2-core-logical-operators@Or","params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":"placeholder","source":"VIRTUAL_COMPONENT_PARAMS"}},{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}}]}},"value":{"params":{"expression":{"params":{"content":true},"component":"data2-core-primitives@Boolean"},"defaultCase":{"component":"data2-core-types@DataLinkGetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}},"mask":{"component":"data2-core-types@Reference","params":{"reference":"mask","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"cases":[{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"type","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":"date"}},"value":{"component":"data2-core-functions@DateFormat","params":{"date":{"component":"data2-core-types@DataLinkGetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"format":"YYYY-MM-DD"}}}},{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"type","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"month"}}}},"value":{"component":"data2-core-functions@DateFormat","params":{"date":{"component":"data2-core-types@DataLinkGetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"format":{"component":"data2-core-primitives@String","params":{"content":"YYYY-MM"}}}}}},{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"type","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"datetime-local"}}}},"value":{"component":"data2-core-functions@DateFormat","params":{"date":{"component":"data2-core-types@DataLinkGetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"format":{"component":"data2-core-primitives@String","params":{"content":"YYYY-MM-DDTHH:mm"}}}}}}]},"component":"data2-core-flow@Switch"},"onChange":{"component":"data2-core-types@DataLinkSetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}},"recordUpsert":{"component":"data2-core-types@Reference","params":{"reference":"recordUpsert","source":"VIRTUAL_COMPONENT_PARAMS"}},"onChange":{"component":"data2-core-types@Reference","params":{"reference":"onChange","source":"VIRTUAL_COMPONENT_PARAMS"}},"mask":{"component":"data2-core-types@Reference","params":{"reference":"mask","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"defaultChecked":{"component":"data2-core-flow@If","params":{"compare":[{"component":"data2-core-comparision-operators@Identical","params":{"subject":{"params":{"reference":"type","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"comparisonSubject":"checkbox"}}],"then":{"component":"data2-core-types@DataLinkGetter","params":{"dataset":{"component":"data2-core-types@Reference","params":{"reference":"dataset","source":"VIRTUAL_COMPONENT_PARAMS"}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field","source":"VIRTUAL_COMPONENT_PARAMS"}},"record":{"component":"data2-core-types@Reference","params":{"reference":"record","source":"VIRTUAL_COMPONENT_PARAMS"}}}},"else":""}},"field":{"component":"data2-core-types@Reference","params":{"reference":"field"}},"opacity":{"component":"data2-core-flow@If","params":{"compare":[{"params":{"reference":"disabled","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"}],"then":0.38,"else":1}},"disabled":{"params":{"reference":"disabled","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},"autoComplete":{"component":"data2-core-flow@If","params":{"compare":[{"component":"data2-core-comparision-operators@Equal","params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":"builder"}},"comparisonSubject":true}}],"then":"new-password","else":{"params":{"compare":{"params":{"comparisonSubject":{"params":{"content":true},"component":"data2-core-primitives@Boolean"},"subject":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"autoComplete"}}},"component":"data2-core-types@Reference"}},"component":"data2-core-comparison-operators@Identical"},"else":{"params":{"components":[{"params":{"reference":"autoComplete","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"},{"component":"data2-core-primitives@String","params":{"content":"off"}}]},"component":"data2-core-logical-operators@Or"}},"component":"data2-core-flow@If"}}},"data-lpignore":{"component":"data2-core-flow@If","params":{"compare":[{"params":{"reference":"autoComplete","source":"VIRTUAL_COMPONENT_PARAMS"},"component":"data2-core-types@Reference"}],"then":"","else":"true"}},"onFocus":{"component":"data2-core-types@Reference","params":{"reference":"onFocus","source":"VIRTUAL_COMPONENT_PARAMS"}},"onBlur":{"component":"data2-core-types@Reference","params":{"reference":"onBlur","source":"VIRTUAL_COMPONENT_PARAMS"}},"onKeyPress":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"onKeyPress"}}}},"autoCapitalize":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"autoCapitalize"}}}},"spellCheck":{"params":{"expression":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"spellCheck"}}},"component":"data2-core-types@Reference"},"cases":[{"component":"data2-core-flow@Case","params":{"expression":{"params":{"content":true},"component":"data2-core-primitives@Boolean"},"value":{"component":"data2-core-primitives@String","params":{"content":"true"}}}},{"component":"data2-core-flow@Case","params":{"expression":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"value":{"component":"data2-core-primitives@String","params":{"content":"false"}}}}]},"component":"data2-core-flow@Switch"}}}}},"component":"DataFieldBlock","package":"data2-blocks","extends":"data2-core-primitives@Block"}}]}},{"reference":"ExternalComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"_id":"5f060d9b1ca96def42f3c40a","displayName":"Builder AuthStrategy","component":"data2-core@AuthenticationStrategy","system":"5e3b796d6228227ca637d0f4","params":{"credentials":[{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"status"}}],"verifier":{"component":"data2-core-types@DataSetReference","params":{"reference":"UsersDataSet"}},"sessionShared":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},"email":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true,"reference":"nR6Q9d7qdaPtZhJ28Q8Dy"},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true,"reference":"yPCJBTKo8q1Mw0B_3Kpwl"},"component":"data2-core-types@ArtboardReference"}}},"session":[{"component":"data2-core-types@DataSetColumnReference","params":{"column":"name"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"status"}}],"sessionMetadata":{"params":{"actions":[{"params":{"compare":{"params":{"subject":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},{"params":{"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"active"}},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user.status"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is active user"}]},"component":"data2-core-logical-operators@And","displayName":"is active and has _id"}},"component":"data2-core-logical-operators@Not"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check $user"},{"params":{"action":{"params":{"pattern":{"component":"data2-core-primitives@String","params":{"content":"@data2.app"}},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user.email"}}}}},"component":"data2-core-functions@Includes","displayName":"isData2User"},"resultName":{"component":"data2-core-primitives@String","params":{"content":"is_data2_user"}}},"component":"data2-core-flow@Step"},{"params":{"action":{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.d2_app"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"management"}}]},"component":"data2-core-functions@Includes","displayName":"isData2App Exception"},"resultName":{"component":"data2-core-primitives@String","params":{"content":"is_data2_app_exception"}}},"component":"data2-core-flow@Step"},{"params":{"resultName":{"component":"data2-core-primitives@String","params":{"content":"has_access"}},"action":{"params":{"compare":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"is_data2_user"}}}},{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"is_data2_app_exception"}}}}]},"component":"data2-core-logical-operators@Or"},"else":{"params":{"actions":[{"params":{"actions":[{"params":{"compare":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}},"component":"data2-core-logical-operators@Not"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check $metadata.system"},{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":"filter","args":[{"_id":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}}]},"component":"data2-core-types@Interaction"},{"params":{"compare":{"params":{"subject":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records.length"}}}}},"component":"data2-core-logical-operators@Not","displayName":"systems not found"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check system found"},{"params":{"actions":[{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":"filter","args":[{"user":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},"organization":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"cursor.current.organization._id"}}}}}]},"component":"data2-core-types@Interaction"},{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":"filter","args":[{"user":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},"system":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}}]},"component":"data2-core-types@Interaction"}]},"component":"data2-core-flow@Parallel"}]},"component":"data2-core-flow@Series","displayName":"common setup"},{"params":{"then":{"params":{"content":true},"component":"data2-core-primitives@Boolean"},"else":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"compare":{"params":{"components":{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"mapper":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"system"}}],"body":{"params":{"expression":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.visibility"}}}},"defaultCase":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"cases":[{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-primitives@String","params":{"content":"public"}},"value":{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"owner"}},{"component":"data2-core-primitives@String","params":{"content":"billing-owner"}},{"component":"data2-core-primitives@String","params":{"content":"admin"}},{"component":"data2-core-primitives@String","params":{"content":"member"}}]},"component":"data2-core-functions@Includes","displayName":"has role with access"}]},"component":"data2-core-logical-operators@And","displayName":"has org member role with access"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is org member"},{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"collaborator"}}},"component":"data2-core-comparison-operators@Equal","displayName":"is collaborator"}]},"component":"data2-core-logical-operators@And","displayName":"is org member and collaborator"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is collaborator"},{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"access"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"access.system._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"has system access"}]},"component":"data2-core-logical-operators@And","displayName":"is collaborator and has system access"}]},"component":"data2-core-logical-operators@Or"}}},{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-primitives@String","params":{"content":"private"}},"value":{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"owner"}},{"component":"data2-core-primitives@String","params":{"content":"admin"}}]},"component":"data2-core-functions@Includes","displayName":"is owner/admin"}]},"component":"data2-core-logical-operators@And","displayName":"has org member role with access"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is owner/admin"},{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is org member"},{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"access"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"access.system._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"has system access"}]},"component":"data2-core-logical-operators@And","displayName":"is member and has system access"}]},"component":"data2-core-logical-operators@Or","displayName":"is owner/admin or has system access"}}}]},"component":"data2-core-flow@Switch"}}}},"component":"data2-core-functions@ArrayMap"}},"component":"data2-core-logical-operators@And","displayName":"has org member for all systems in request"}},"component":"data2-core-flow@If"}]},"component":"data2-core-flow@Series"},"then":{"params":{"content":true},"component":"data2-core-primitives@Boolean"}},"component":"data2-core-flow@If"}},"component":"data2-core-flow@Step"},{"params":{"then":{"system":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}},"else":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"},"compare":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"has_access"}}}}},"component":"data2-core-flow@If"}]},"component":"data2-core-flow@Series"},"sessionExpiresIn":{"component":"data2-core-primitives@Number","params":{"content":604800}},"sessionRenewal":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"identityProviders":[{"provider":{"component":"data2-core-primitives@String","params":{"content":"GOOGLE"}},"clientId":{"component":"data2-core-primitives@String","params":{"content":"55470861603-22pvfc1t49lp8cjnbfk71s5ct9hp1q1v.apps.googleusercontent.com"}}}]},"reference":"Data2BuilderAuthentication","updatedAt":"2026-08-06T19:31:23.557Z"},{"_id":"5f060ee51ca96def42f3fbd1","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"UsersTableDataSource"}},"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}}},"reference":"UsersDataSet","updatedAt":"2026-03-04T21:16:40.291Z"},{"_id":"68f938cf5fb002e33752a43d","displayName":"InternalApplicationsAuthenticationStrategy","component":"data2-core@AuthenticationStrategy","system":"5e3b796d6228227ca637d0f4","params":{"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}}},"verifier":{"disabled":false,"component":"data2-core-types@DataSetReference","params":{"reference":"InternalApplicationsVerifierDataSet"}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"secret"}},"credentials":[{"component":"data2-core-types@FieldReference","params":{"key":"application"}},{"component":"data2-core-types@FieldReference","params":{"key":"secret"}},{"component":"data2-core-types@FieldReference","params":{"key":"status"}}],"session":[{"component":"data2-core-types@FieldReference","params":{"key":"application"}},{"component":"data2-core-types@FieldReference","params":{"key":"secret"}},{"component":"data2-core-types@FieldReference","params":{"key":"status"}}]},"reference":"InternalApplicationsAuthenticationStrategy","updatedAt":"2025-10-22T20:08:54.442Z","createdAt":"2025-10-22T20:04:31.198Z"},{"component":"data2-core@DataSet","reference":"SessionsDataSet","params":{"requiresConfirmation":false,"pk":"key","datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"data2sessionsdatasource"}}}},{"reference":"UsersTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@ServerSessionsDataSource","reference":"data2sessionsdatasource","params":{}},{"_id":"5f1600b70528b92601890f75","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ServiceComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ServiceComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:20.208Z","createdAt":"2020-07-20T20:38:15.436Z"},{"_id":"5f1602190528b92601890f7d","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":40000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentInstancesTableDataSource"}},"sort":{"_id":1},"pk":"_id","autoEdit":true,"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:07.089Z","createdAt":"2020-07-20T20:44:09.350Z"},{"_id":"5f16017f0528b92601890f7a","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"clientApplicationDomain"}}},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"FileComponentInstancesDataSet","updatedAt":"2023-08-03T16:08:18.193Z","createdAt":"2020-07-20T20:41:35.797Z"},{"_id":"5f1601990528b92601890f7b","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ApplicationsTableDataSource"}},"pk":"_id","autoEdit":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"params":{"column":"_id"},"component":"data2-core-types@DataSetColumnReference"}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"ApplicationsDataSet","updatedAt":"2022-12-13T21:51:13.995Z","createdAt":"2020-07-20T20:42:01.657Z"},{"_id":"5f16172a0528b92601890f85","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":10000}}},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"DataComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"DataComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:12.831Z","createdAt":"2020-07-20T22:14:02.097Z"},{"_id":"60ddc1bbda3861ca65407c5a","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"clientApplicationDomain"}}},"pagination":{"size":1},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"}},"reference":"OneFileComponentInstancesDataSet","updatedAt":"2022-02-09T19:44:38.036Z","createdAt":"2020-07-20T20:41:35.797Z"},{"_id":"63ee3d38b8c9838c62fb05fb","displayName":"DriveFileComponentInstancesDataSet","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core@Drive"}}},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"DriveFileComponentInstancesDataSet","updatedAt":"2025-12-04T21:58:32.367Z","createdAt":"2023-02-16T14:27:07.797Z"},{"reference":"ServiceComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"69483e4605714c8c39688670","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"1","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"x":96,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":0,"reference":"1"},{"_id":"69483e4605714c8c3968866d","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"2","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.8199999999999998,"exactY":1.63,"x":2,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":1,"reference":"2"},{"_id":"69483e4605714c8c3968866e","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"3","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.96,"exactY":96.42,"x":2,"y":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":2,"reference":"3"},{"_id":"69483e4605714c8c3968866f","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"4","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.75,"exactY":189.75,"x":2,"y":190}},"text":"","opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":3,"reference":"4"},{"_id":"69483e4605714c8c3968865f","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"5","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.75,"exactY":189.75,"x":2,"y":190}},"text":"","opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":0,"reference":"5"},{"_id":"69483e4605714c8c3968865c","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"x","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(141px)  translatez(-141px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"opacity":{"disabled":true,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":2,"reference":"x"},{"_id":"68e47144c6e2258edb0bd7c4","parent":"68e4710ac6e2258edb0bd7bb","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"18"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":343,"height":511}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"params":{"portions":[{"params":{"transform-bricks":[{"type":"perspective","value":{"component":"data2-core-primitives@Number","params":{"content":1800}}},{"type":"rotateX","value":{"component":"data2-core-primitives@Number","params":{"content":0}}},{"type":"rotateY","value":{"component":"data2-core-primitives@Number","params":{"content":0}}},{"type":"rotateZ","value":{"component":"data2-core-primitives@Number","params":{"content":0}}}]},"component":"lucca-playground@JfsOzWC5Bl9CZyvsPoJgN"},{"component":"data2-core-primitives@String","params":{"content":" scale(1)"}}]},"component":"data2-core-functions@StringConcat"},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"OPACITY","easing":"cubic-bezier(0.42105263157894735,0.982375,0.9407894736842105,1.070375)","duration":"200"}},{"component":"data2-core-types@Transition","params":{"scope":"TRANSFORM","duration":"360","easing":"cubic-bezier(0.13815789473684212,1.074375,0.40789473684210525,0.882375)"}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"backdropFilter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"brightness","unit":"%","value":40}}],"shadow":[{"component":"data2-core-types@Shadow","params":{"type":"inset","spread":"1","color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,0,0,1)"}}}}],"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,0,0,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}}},"reference":"main"},{"_id":"69483e4605714c8c39688663","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"circle","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(141px) rotatey(90deg) translatez(141px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}],"opacity":{"disabled":true,"component":"data2-core-types@Opacity","params":{"value":75}}},"index":3,"reference":"circle"},{"_id":"61792e31daf81fbd91aac2a8","parent":"6177f4292eebd01a05f5daf7","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"fill":{"component":"data2-core-types@Fill","params":{"backgrounds":[{"component":"data2-core-types@Background","params":{"color":{"component":"data2-core-types@ColorGradient","params":{"colors":[{"component":"data2-core-types@Color","params":{"rgba":"rgba(124,0,190,1)"}}]}}}}]}},"border":{"component":"data2-core-types@Border","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"#707070","opacity":1}},"radius":{"leftTop":0,"rightTop":0,"leftBottom":0,"rightBottom":0},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":"100","height":"60"}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":"1023","y":20,"left":false,"right":false,"growH":true,"top":true}},"text":{"component":"data2-core-primitives@String","params":{"content":"Entrar"}},"textFormat":{"disabled":false,"component":"data2-core-types@TextFormat","params":{"horizontalAlignment":"center","verticalAlignment":"middle","fontWeight":700}},"textColor":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"font":{"disabled":true,"component":"data2-core-web@GoogleFont","params":{}},"textSize":{"component":"data2-core-primitives@Number","params":{"content":13}},"onClick":{"disabled":false,"component":"data2-core-types@Interaction","params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"netshoes_routerInterface"}},"prop":"goTo","args":[{"component":"data2-core-primitives@String","params":{"content":"/login"}}]}}},"reference":"login"},{"_id":"69483e4605714c8c39688660","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"8","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":2,"x":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":3,"reference":"8"},{"_id":"69483e4605714c8c3968865b","parent":"69483e4605714c8c3968865a","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"bottom","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":542,"y":"341","exactX":false,"exactY":false}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(56deg) rotate(45deg"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(58,58,58,0)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":0,"reference":"bottom"},{"_id":"67939dbfd38a48bd82264b84","component":"data2-core-primitives@Artboard","system":"6172b957976026c073ab9058","displayName":"New Artboard (502)","params":{"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":13660,"height":7680,"exactWidth":13660,"exactHeight":7680}},"fill":{"params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill","disabled":true}},"reference":"d"},{"_id":"6aceb0a7a05c951878d0156b","component":"data2-core-primitives@Artboard","system":"6172b957976026c073ab9058","displayName":"MAGI","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"MAGI"}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1440,"height":900}},"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0}}},"reference":"gFnhmV9dQrtbmpkQN9DPC"},{"_id":"69483e4605714c8c3968865e","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"6","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.96,"exactY":96.42,"x":2,"y":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":1,"reference":"6"},{"_id":"69483e4605714c8c3968865d","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"7","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.8199999999999998,"exactY":1.63,"x":2,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":2,"reference":"7"},{"_id":"69483e4605714c8c39688661","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"9","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":2,"x":190}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":4,"reference":"9"},{"_id":"69483e4605714c8c39688662","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow4","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":185,"height":159,"exactWidth":184.99,"exactHeight":158.98}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":156,"y":-24,"exactX":155.82,"exactY":-23.78}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow4"},{"_id":"68e5c3efdbc11be40cc12a54","parent":"68e47144c6e2258edb0bd7c4","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"},"disabled":true},"gradient":{"disabled":false,"component":"data2-core-types@ColorGradient","params":{"colors":[{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"offset":"0%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(207,207,207,1)"}},"offset":"18%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(154,154,154,0.81)"}},"offset":"36%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(95,95,95,0.83)"}},"offset":"56.00000000000001%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,0,0,0.66)"}},"offset":"100%"}}],"angle":90}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":126,"height":559,"exactHeight":558.77,"exactWidth":126.2}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-25,"y":-131,"exactX":-24.569999999999997,"exactY":-130.71}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotate(45deg) scale(0,2)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"TRANSFORM","duration":"350","easing":"cubic-bezier(0,1.022375,0.32894736842105265,0.934375)","delay":"0"}},{"component":"data2-core-types@Transition","params":{"scope":"OPACITY","duration":"350","easing":"cubic-bezier(0.7105263157894737,0.01837500000000003,0.8618421052631579,0.354375)","delay":"0"}}],"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":20}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}}},"index":0,"reference":"reflect2"},{"_id":"68e47144c6e2258edb0bd7c5","parent":"68e47144c6e2258edb0bd7c4","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":126,"height":559,"exactHeight":558.77,"exactWidth":126.2}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-25,"y":-131,"exactX":-24.569999999999997,"exactY":-130.71}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotate(45deg) scale(0,1)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"TRANSFORM","duration":"350","easing":"cubic-bezier(0,1.022375,0.32894736842105265,0.934375)","delay":"0"}},{"component":"data2-core-types@Transition","params":{"scope":"OPACITY","duration":"350","easing":"cubic-bezier(0.7105263157894737,0.01837500000000003,0.8618421052631579,0.354375)","delay":"0"}}],"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":20}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}}},"index":1,"reference":"reflect"},{"_id":"69483e4605714c8c39688664","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"10","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":2,"x":190}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"10"},{"_id":"69483e4605714c8c39688665","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"11","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":2,"x":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"11"},{"_id":"69483e4605714c8c39688666","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"12","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.8199999999999998,"exactY":1.63,"x":2,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"12"},{"_id":"69483e4605714c8c39688667","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"13","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.96,"exactY":96.42,"x":2,"y":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"13"},{"_id":"69483e4605714c8c39688668","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"14","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"x":2,"y":190}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"14"},{"_id":"69483e4605714c8c39688669","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"15","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"x":96,"y":190}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"text":"","transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"15"},{"_id":"69483e4605714c8c3968866a","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow5","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":275,"exactWidth":false,"exactHeight":275,"height":275}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":"187","y":"-200","exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow5"},{"_id":"69483e4605714c8c3968866b","parent":"69483e4605714c8c39688663","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"16","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":190,"x":190}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"16"},{"_id":"69483e4605714c8c3968866c","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"roof","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"y":0,"exactX":0,"exactY":0,"growH":false,"x":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"translatez(282px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":0,"reference":"roof"},{"_id":"69483e4605714c8c39688679","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"square","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(141px) rotatey(90deg) translatez(-141px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(32,32,32,0)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":1,"reference":"square"},{"_id":"69483e4605714c8c3968867c","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"triangle","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(141px) rotatey(0deg) translatez(141px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(32,32,32,0.38)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":4,"reference":"triangle"},{"_id":"69483e4605714c8c39688673","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"blocao","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":90,"height":90,"exactHeight":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"params":{"portions":[{"params":{"portions":[{"component":"data2-core-primitives@String","params":{"content":"translatez(282px) translatey(2px) translatex(2px) translatex(94px) rotatey(-90deg)  translatez(49px) translate(43px) rotatez(90deg) translate(94px) translatey(-2px)  translate(94px) rotate(90deg) translatey(-92px) rotate(180deg) translate(-92px) "}},{"component":"data2-core-primitives@String","params":{"content":"rotate(90deg) translatey(92px)"}},{"component":"data2-core-primitives@String","params":{"content":"rotate(90deg) translate(96px) "}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatez(-44px) translatey(-48px) "}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(44px) translatez(48px)  "}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(140px) translatez(-45px)  "}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(136px) translatez(-45px)  "}},{"component":"data2-core-primitives@String","params":{"content":" rotatex(90deg)  translatey(48px) translatez(-140px)  "}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatez(-45px) translatey(-49px) "}},{"component":"data2-core-primitives@String","params":{"content":" rotatey(-45deg) translate(13.2px) translatez(34.7px)"}},{"component":"data2-core-primitives@String","params":{"content":"rotatey(-45deg) translatez(30px) translate(12px)"}},{"component":"data2-core-primitives@String","params":{"content":" rotatey(-45deg) translate(82px) translatez(8.4px) "}},{"component":"data2-core-primitives@String","params":{"content":"rotatey(-45deg) translate(79px) translatez(6px) "}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(45deg) translatey(51.5px) translatez(-59px)"}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(45deg) translatey(51.5px) translatez(-59px)"}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(45deg) translatez(-33.7px) translatey(-16.8px)"}},{"component":"data2-core-primitives@String","params":{"content":"rotatex(45deg) translatez(-31px) translatey(-16px)"}}]},"component":"data2-core-functions@StringConcat"},{"params":{"portions":[{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatex(60px) translatey(20px) scale3d(1.4,1.4,1.4)"}}]},"component":"data2-core-functions@StringConcat"}]},"component":"data2-core-functions@StringConcat"},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"text":"","transitions":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"uOm7rpCst0qVTKd9rhSE3"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.blocao-transish"}}}},"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(56,38,111,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}},"children":{"locked":true}},"index":5,"reference":"blocao"},{"_id":"6793a22dd38a48bd82264b91","parent":"67939dbfd38a48bd82264b84","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill","disabled":true},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":457,"height":191,"exactWidth":457.49,"exactHeight":190.78}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":43,"y":7030,"exactX":43,"exactY":7030.22}}}},{"_id":"6793a356d38a48bd82264b95","parent":"67939dbfd38a48bd82264b84","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill","disabled":true},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":190,"height":135}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":365,"y":7539,"exactX":365.38,"exactY":7539.31}},"children":{"locked":true}}},{"_id":"6793a460d38a48bd82264b9b","parent":"67939dbfd38a48bd82264b84","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill","disabled":true},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":190,"height":135}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":367,"y":7109,"exactX":366.61,"exactY":7109.13}},"children":{"locked":true}}},{"_id":"6793a47bd38a48bd82264ba2","parent":"67939dbfd38a48bd82264b84","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill","disabled":true},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":457,"height":191,"exactWidth":457.49,"exactHeight":190.78}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":"223","y":"7350","exactX":false,"exactY":false}},"rotate":{"component":"data2-core-primitives@Number","params":{"content":90}}}},{"_id":"21a096cb6fa932ed66653e89","parent":"6aceb0a7a05c951878d0156b","component":"lucca-playground@MAGI_World","system":"6172b957976026c073ab9058","displayName":"MAGI World","params":{"super":{"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0,"freeTransform":true,"left":true,"right":true,"top":true,"bottom":true}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1440,"height":900}}}},"reference":"Al_e-U8Pi8rV3QuCrKXsQ"},{"_id":"6acecd144e07e638b527e4f2","parent":"6aceb0a7a05c951878d0156b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"Copy of MAGI World","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0,"freeTransform":true,"left":true,"right":true,"top":true,"bottom":true,"exactX":false,"exactY":false}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1440,"height":900,"exactWidth":false,"exactHeight":false}},"childrenMode":"reference","childrenReference":{"disabled":false,"component":"data2-core-types@ComponentInstanceReference","params":{"component":"data2-core-primitives@Artboard","reference":"KJgoqwR0dbAqUsj3khRMX"}},"hide":{"params":{"comparisonSubject":{"component":"data2-core-primitives@Number","params":{"content":0}},"subject":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"sJ42d8IymicpfxQSJGu_D"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records.length"}}}}},"component":"data2-core-comparison-operators@GreaterThan"}},"reference":"Al_e-U8Pi8rV3QuCrKXsQ"},{"_id":"69483e4605714c8c39688671","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow1","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":140,"height":139,"exactWidth":140,"exactHeight":138.98}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":"-30","y":"-70","exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow1"},{"_id":"69483e4605714c8c39688672","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow2","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":140,"height":139,"exactWidth":140,"exactHeight":138.98}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":"212","y":"223","exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow2"},{"_id":"69483e4605714c8c3968867a","parent":"69483e4605714c8c39688679","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow3","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":185,"height":159,"exactWidth":184.99,"exactHeight":158.98}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow3"},{"_id":"69483e4605714c8c3968867b","parent":"69483e4605714c8c39688679","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow3a","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":154,"height":45,"exactWidth":153.99,"exactHeight":45}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":237,"exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow3a"},{"_id":"69483e4605714c8c3968867d","parent":"69483e4605714c8c3968867c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow6","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":119,"height":119,"exactWidth":119,"exactHeight":119}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":"137","y":"10","exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow6"},{"_id":"69483e4605714c8c3968867e","parent":"69483e4605714c8c3968867c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow7","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":154,"height":45,"exactWidth":153.99,"exactHeight":45}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":237,"exactX":false,"exactY":false}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow7"},{"_id":"694847eb5dbfa3a7d6cc0de4","parent":"69483e4605714c8c39688673","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"Copy of bsquare","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":90,"height":90,"exactHeight":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":false,"exactY":false}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(45px) rotatey(90deg)  translatez(-45px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"reference":"bsquare"},{"_id":"694847eb5dbfa3a7d6cc0dea","parent":"69483e4605714c8c39688673","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"Copy of btriangle","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":90,"height":90,"exactHeight":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":false,"exactY":false}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(45px)  translatez(45px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"reference":"btriangle"},{"_id":"694847eb5dbfa3a7d6cc0df0","parent":"69483e4605714c8c39688673","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"Copy of bx","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,1)"},"disabled":true},"gradient":{"disabled":false,"component":"data2-core-types@ColorGradient","params":{"colors":[{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(115,95,219,1)"}},"offset":"0%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(156,142,227,1)"}},"offset":"65.3%"}}],"angle":25}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":90,"height":90,"exactHeight":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":false,"exactY":false}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(45px)  translatez(-45px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"reference":"bx"},{"_id":"694847eb5dbfa3a7d6cc0df6","parent":"69483e4605714c8c39688673","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"Copy of bcircle","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(56,38,111,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":90,"height":90,"exactHeight":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(45px) rotatey(90deg)  translatez(45px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"reference":"bcircle"},{"_id":"694847eb5dbfa3a7d6cc0dfc","parent":"69483e4605714c8c39688673","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"Copy of broof","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,1)"},"disabled":true},"gradient":{"disabled":false,"component":"data2-core-types@ColorGradient","params":{"colors":[{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(84,65,182,1)"}},"offset":"0%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(190,179,255,1)"}},"offset":"65%"}}],"angle":235}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":90,"height":90,"exactHeight":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":false,"exactY":false}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"translatez(90px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"reference":"broof"},{"_id":"6793a233d38a48bd82264b92","parent":"6793a22dd38a48bd82264b91","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"999","leftBottom":"0","rightBottom":"0"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":457,"height":170,"exactHeight":169.91,"exactWidth":457.49}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":3,"y":-35,"exactX":3,"exactY":-34.88}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(-70deg)"}}}}},{"_id":"6793a2a3d38a48bd82264b94","parent":"6793a22dd38a48bd82264b91","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"999","leftBottom":"0","rightBottom":"0"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(26,26,26,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":457,"height":173,"exactHeight":172.61,"exactWidth":457.49}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":3,"y":-18,"exactX":false,"exactY":-17.58}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(-70deg)"}}}}},{"_id":"6793a361d38a48bd82264b96","parent":"6793a356d38a48bd82264b95","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":104,"height":96,"exactWidth":104.09,"exactHeight":96}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":68,"y":-36,"exactX":68.41,"exactY":-35.970000000000006}},"children":{"locked":true}},"index":0},{"_id":"6793a460d38a48bd82264b9c","parent":"6793a460d38a48bd82264b9b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":104,"height":94,"exactWidth":104.09,"exactHeight":93.62}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":71,"y":21,"exactX":false,"exactY":false}}},"index":0},{"_id":"6793a47bd38a48bd82264ba3","parent":"6793a47bd38a48bd82264ba2","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"999","leftBottom":"0","rightBottom":"0"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":488,"height":161,"exactHeight":161.12,"exactWidth":487.67}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":3,"y":-35,"exactX":3,"exactY":-34.88}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(-70deg)"}}}}},{"_id":"6793a47bd38a48bd82264ba4","parent":"6793a47bd38a48bd82264ba2","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"999","leftBottom":"0","rightBottom":"0"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(26,26,26,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":457,"height":173,"exactHeight":172.61,"exactWidth":457.49}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":3,"y":-18,"exactX":false,"exactY":-17.58}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(-70deg)"}}}}},{"_id":"6acecc2a4e07e638b527e4e9","component":"data2-core-primitives@Artboard","system":"6172b957976026c073ab9058","displayName":"New Artboard (1363)","params":{"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":768}},"fill":{"params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"}},"reference":"KJgoqwR0dbAqUsj3khRMX"},{"_id":"6793a385d38a48bd82264b98","parent":"6793a361d38a48bd82264b96","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(26,26,26,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":128,"height":94,"exactWidth":127.79,"exactHeight":93.62}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-29,"y":-57,"exactX":-29.44,"exactY":-56.86}},"rotate":{"component":"data2-core-primitives@Number","params":{"content":-31}}}},{"_id":"6793a386d38a48bd82264b9a","parent":"6793a361d38a48bd82264b96","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(26,26,26,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":146,"height":94,"exactWidth":145.73,"exactHeight":93.62}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-48,"y":"60","exactX":false,"exactY":false}},"rotate":{"component":"data2-core-primitives@Number","params":{"content":"31"}}}},{"_id":"6793a460d38a48bd82264b9d","parent":"6793a460d38a48bd82264b9c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(26,26,26,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":128,"height":94,"exactWidth":127.79,"exactHeight":93.62}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-29,"y":-57,"exactX":-29.44,"exactY":-56.86}},"rotate":{"component":"data2-core-primitives@Number","params":{"content":-31}}}},{"_id":"6793a460d38a48bd82264b9e","parent":"6793a460d38a48bd82264b9c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(26,26,26,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":146,"height":94,"exactWidth":145.73,"exactHeight":93.62}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-48,"y":"60","exactX":false,"exactY":false}},"rotate":{"component":"data2-core-primitives@Number","params":{"content":"31"}}}},{"_id":"6acecc2e4e07e638b527e4ea","parent":"6acecc2a4e07e638b527e4e9","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":343,"height":450}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":512,"y":159,"exactX":false,"exactY":false}}}},{"_id":"6acecc384e07e638b527e4eb","parent":"6acecc2e4e07e638b527e4ea","component":"data2-blocks@DataFieldBlock","system":"6172b957976026c073ab9058","params":{"super":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":300,"height":50}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":22,"y":37,"exactX":false,"exactY":false}}},"dataset":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"GdlIRtB0aFFT2E1hGm3kq"}},"field":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"credentials.e"}}}},{"_id":"6acecc3c4e07e638b527e4ec","parent":"6acecc2e4e07e638b527e4ea","component":"data2-blocks@DataFieldBlock","system":"6172b957976026c073ab9058","params":{"super":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":300,"height":45}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":22,"y":105,"exactX":false,"exactY":false}}},"dataset":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"GdlIRtB0aFFT2E1hGm3kq"}},"field":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"credentials.p"}}}},{"_id":"6acecc9a4e07e638b527e4f0","parent":"6acecc2e4e07e638b527e4ea","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":285,"height":76}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":29,"y":253,"exactX":false,"exactY":false}},"onClick":{"disabled":false,"component":"data2-core-types@Interaction","params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"GdlIRtB0aFFT2E1hGm3kq"}},"prop":"login"}}}}]}},{"_id":"5fc4faeec8da12be8bf3d08a","component":"data2-core@Data2TableDataSource","system":"5e3b796d6228227ca637d0f4","params":{"paginate":{"default":1000},"super":{},"table":"fileComponentInstances","schema":[{"component":"data2-core-types@Field","params":{"key":"component","title":"Component","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"system","title":"System","component":"data2-core-primitives@ObjectId"}},{"component":"data2-core-types@Field","params":{"key":"displayName","title":"Display Name","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"params","title":"Params","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"reference","title":"Reference","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"fileId"}},"title":"File ID","component":"data2-core-primitives@ObjectId"},"break":true}],"hooks":{"before":{"all":[{"hook":"softDelete"}],"find":[{"hook":"useCache","options":{"additionalGroupKeys":["$and.0.system","$and.1.system"],"skipCache":true}}],"create":[{"hook":"timestamp","options":{"fields":["createdAt","updatedAt"]}}],"update":[{"hook":"timestamp","options":{"fields":["updatedAt"]}}],"patch":[{"hook":"timestamp","options":{"fields":["updatedAt"]}}]},"after":{"all":[{"hook":"fileUrl","options":{"downloadField":"params.fileUrl","uploadField":"params.uploadFileUrl","enhanceSchema":false}}],"find":[{"hook":"useCache","options":{"additionalGroupKeys":["$and.0.system","$and.1.system"],"skipCache":true}}],"create":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"update":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"patch":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"remove":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}]}},"multi":["create"],"useEstimatedDocumentCount":true},"reference":"FileComponentInstancesTableDataSource","updatedAt":"2022-12-19T20:42:51.837Z","createdAt":"2020-11-30T13:59:52.294Z"},{"reference":"ApplicationsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6acec90a4e07e638b527e4e7","displayName":"MAGI APP","reference":"goy_sUqvXErYhfPU6pkKF","params":{"domain":{"component":"data2-core-primitives@String","params":{"content":"p-magi"}},"entryPoint":{"disabled":false,"component":"data2-core-types@ComponentInstanceReference","params":{"component":"data2-core-primitives@Artboard","reference":"gFnhmV9dQrtbmpkQN9DPC"}}},"system":"6172b957976026c073ab9058","component":"data2-core-primitives@Application"}]}},{"reference":"DataComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6a73eb729b80399cc1ea52fb","component":"data2-core@Store","system":"6172b957976026c073ab9058","params":{"initialData":{"selectedLevelId":null}},"reference":"gameStore"},{"_id":"6ac000c7c1d9b5fa79c91a25","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"datafox2keys"}}},"reference":"datafox2keysDataSet"},{"_id":"6ac6b3973cd42e411b477e29","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"tfsfkeys"}}},"reference":"tfsfkeysDataSet"},{"_id":"6ac6b3caf121324ec02776db","component":"data2-core@AuthenticationStrategy","system":"6172b957976026c073ab9058","params":{"credentials":[{"key":"email"},{"key":"password"}],"verifier":{"component":"data2-core-types@DataSetReference","params":{"reference":"tfsfusersDataSet"}},"email":"email","password":"password"},"reference":"tfsfAuth"},{"_id":"6ac000c7c5d15a59091cc155","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"datafox2keys","schema":[{"component":"data2-core-types@Field","params":{"key":"key","title":"Key","component":"data2-core-primitives@String","required":true}}]},"reference":"datafox2keys"},{"_id":"6ac6b3979cddc83ab29f2cd9","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"tfsfkeys","schema":[{"component":"data2-core-types@Field","params":{"key":"key","title":"Key","component":"data2-core-primitives@String","required":true}}]},"reference":"tfsfkeys"},{"_id":"6ac6b39478f3730f24d069ab","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"tfsfusers"}}},"reference":"tfsfusersDataSet"},{"_id":"6ac6b394862cb6202e1c93b3","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"tfsfusers","schema":[{"component":"data2-core-types@Field","params":{"key":"email","title":"Email","component":"data2-core-primitives@String","required":true}},{"component":"data2-core-types@Field","params":{"key":"password","title":"Password","component":"data2-core-types@Password","required":true}}]},"reference":"tfsfusers"},{"_id":"694849cd5dbfa3a7d6cc0dfd","component":"data2-core@Store","system":"6172b957976026c073ab9058","params":{"initialData":{"params":{"fields":{"blocao-transish":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"200","easing":"cubic-bezier(0.4473684210526316,0.542375,0.40789473684210525,0.606375)"}}]}},"component":"data2-core-types@FieldSet"}},"reference":"uOm7rpCst0qVTKd9rhSE3"},{"_id":"6acecc634e07e638b527e4ee","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"YLR4HaAUCFA4BjdJ4-CQL"}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"pagination":{"size":20}},"reference":"sJ42d8IymicpfxQSJGu_D"},{"_id":"6acecc024e07e638b527e4e8","component":"data2-core@AuthenticationStrategy","system":"6172b957976026c073ab9058","params":{"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}}},"verifier":{"disabled":false,"component":"data2-core-types@DataSetReference","params":{"reference":"sJ42d8IymicpfxQSJGu_D"}},"credentials":[{"component":"data2-core-types@FieldReference","params":{"key":"e"}},{"component":"data2-core-types@FieldReference","params":{"key":"p"}}],"email":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"e"}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"p"}}},"reference":"GdlIRtB0aFFT2E1hGm3kq"},{"_id":"6acecc524e07e638b527e4ed","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"YLR4HaAUCFA4BjdJ4-CQL","multi":["create"],"schema":[{"component":"data2-core-types@Field","params":{"title":"e","key":"e","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"title":"p","key":"p","component":"data2-core-types@Password"}}]},"reference":"YLR4HaAUCFA4BjdJ4-CQL"}]}},{"reference":"BundlesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}}],"processesParams":{}}}}