bend_puzzle/field-persistence.js
2026-07-31 12:54:46 +09:00

243 lines
17 KiB
JavaScript

(function(global){
'use strict';
const ARCHIVE_FORMAT='bend-field-save';
const ARCHIVE_VERSION=2;
const MAX_ARCHIVE_FILE_BYTES=1024*1024*1024;
const MAX_ARCHIVE_RAW_BYTES=2*1024*1024*1024;
const MAX_ARCHIVE_LINE_BYTES=8*1024*1024;
const MAX_ARCHIVE_EXPANSION_RATIO=100;
const MAX_BLOB_BYTES=64*1024*1024;
const PROGRESS_INTERVAL=250;
const WORKER_TARGET_BYTES=1024*1024;
const MAX_WORKER_RECORDS=32;
const ArchiveCodec=global.BendArchiveCodec||(typeof module==='object'&&module.exports?require('./archive-codec'):null);
if(!ArchiveCodec)throw new Error('BendArchiveCodec is not loaded');
const{encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine}=ArchiveCodec;
function abortError(){return new DOMException('The operation was canceled.','AbortError')}
function throwIfAborted(signal){if(signal?.aborted)throw signal.reason||abortError()}
function archiveError(message,code='INVALID_ARCHIVE'){const error=new Error(message);error.code=code;return error}
function validateManifestRecord(record,compression){
if(record?.type!=='manifest'||record.format!==ARCHIVE_FORMAT||record.archiveVersion!==ARCHIVE_VERSION)throw archiveError('This is not a supported Bend Field archive.','UNSUPPORTED_ARCHIVE');
if(record.compression!==compression)throw archiveError('The archive compression declaration does not match its contents.');
if(record.encoding!=='ndjson')throw archiveError('The archive encoding is not supported.','UNSUPPORTED_ARCHIVE');
if(!Number.isSafeInteger(record.boardCount)||record.boardCount<1)throw archiveError('The archive board count is invalid.');
if(!Number.isSafeInteger(record.estimatedRawBytes)||record.estimatedRawBytes<0||record.estimatedRawBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The archive size declaration is invalid.','ARCHIVE_TOO_LARGE');
return record;
}
function boardNumber(id){return/^B(?:0|[1-9]\d*)$/.test(id)?Number(id.slice(1)):-1}
function triggerDownload(file,name){
const url=URL.createObjectURL(file),link=document.createElement('a');
link.href=url;link.download=name;link.style.display='none';document.body.append(link);link.click();link.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function progressReporter(callback,base={}){
let last=0;
return(patch={},force=false)=>{
if(typeof callback!=='function')return;
const now=performance.now();
if(!force&&now-last<PROGRESS_INTERVAL)return;
last=now;callback({...base,...patch});
};
}
function createArchiveCodec(){
if(typeof Worker!=='function')return null;
let worker;try{worker=new Worker('field-persistence-worker.js')}catch(_){return null}
let nextId=0;const pending=new Map();
worker.onmessage=event=>{const message=event.data||{},entry=pending.get(message.id);if(!entry)return;pending.delete(message.id);if(message.error)entry.reject(archiveError(message.error,'ARCHIVE_WORKER'));else entry.resolve(message)};
worker.onerror=event=>{const error=archiveError(event?.message||'The archive worker failed.','ARCHIVE_WORKER');for(const entry of pending.values())entry.reject(error);pending.clear()};
const request=(op,payload={})=>new Promise((resolve,reject)=>{const id=++nextId;pending.set(id,{resolve,reject});try{worker.postMessage({id,op,...payload})}catch(error){pending.delete(id);reject(error)}});
return{encode:(records,includeInCrc=true)=>request('encode',{records,includeInCrc}),parse:lines=>request('parse',{lines}),snapshot:()=>request('snapshot'),terminate(){for(const entry of pending.values())entry.reject(archiveError('The archive worker was terminated.','ARCHIVE_WORKER'));pending.clear();worker.terminate()}};
}
async function createArchiveDestination(name,estimatedBytes=0){
if(typeof global.showSaveFilePicker==='function'){
const handle=await global.showSaveFilePicker({
suggestedName:name,
types:[{description:'Bend Field save',accept:{'application/x-bend-field-save':['.bfsave']}}]
});
const writable=await handle.createWritable();
return{
kind:'file',
writable,
async finish(){},
async abort(reason){try{await writable.abort(reason)}catch(_){}}
};
}
if(global.navigator?.storage?.getDirectory){
const root=await global.navigator.storage.getDirectory(),temporaryName=`.${name}.${Date.now()}.partial`,
handle=await root.getFileHandle(temporaryName,{create:true}),writable=await handle.createWritable();
return{
kind:'opfs',
writable,
async finish(){
const file=await handle.getFile();triggerDownload(file,name);
try{await root.removeEntry(temporaryName)}catch(_){}
},
async abort(reason){
try{await writable.abort(reason)}catch(_){}
try{await root.removeEntry(temporaryName)}catch(_){}
}
};
}
if(estimatedBytes>MAX_BLOB_BYTES)throw archiveError('This browser cannot stream a save file of this size.','UNSUPPORTED_OUTPUT');
const chunks=[];let size=0,closed=false;
const writable=new WritableStream({
write(chunk){
const bytes=chunk instanceof Uint8Array?chunk:new Uint8Array(chunk);
size+=bytes.byteLength;if(size>MAX_BLOB_BYTES)throw archiveError('The save exceeded the safe in-memory download limit.','OUTPUT_TOO_LARGE');
chunks.push(bytes.slice());
},
close(){closed=true},
abort(){chunks.length=0;size=0}
});
return{
kind:'blob',
writable,
async finish(){
if(!closed)throw archiveError('The save stream did not close.','OUTPUT_INCOMPLETE');
triggerDownload(new Blob(chunks,{type:'application/x-bend-field-save'}),name);
},
async abort(){chunks.length=0;size=0}
};
}
function outputPipeline(writable,useGzip,onBytes){
const counter=new TransformStream({transform(chunk,controller){onBytes(chunk.byteLength);controller.enqueue(chunk)}});
if(useGzip){
const gzip=new CompressionStream('gzip'),pipe=gzip.readable.pipeThrough(counter).pipeTo(writable);
return{writer:gzip.writable.getWriter(),pipe};
}
const pass=new TransformStream(),pipe=pass.readable.pipeThrough(counter).pipeTo(writable);
return{writer:pass.writable.getWriter(),pipe};
}
async function writeArchive({writable,manifest,globalState,boards,signal,onProgress}){
throwIfAborted(signal);
const useGzip=manifest.compression==='gzip';
if(useGzip&&typeof CompressionStream!=='function')throw archiveError('Gzip compression is not available.','UNSUPPORTED_COMPRESSION');
let bytesWritten=0,rawBytes=0,crc=0xffffffff,crcHex='00000000',boardsDone=0,writer=null,pipe=null;
const report=progressReporter(onProgress,{boardsTotal:manifest.boardCount||0}),codec=createArchiveCodec();
try{
({writer,pipe}=outputPipeline(writable,useGzip,amount=>{bytesWritten+=amount}));
const writeRecords=async(records,includeInCrc=true)=>{
throwIfAborted(signal);if(!records.length)return;
if(codec){const result=await codec.encode(records,includeInCrc);rawBytes=result.rawBytes;crcHex=result.crc32;for(const buffer of result.chunks){throwIfAborted(signal);await writer.write(new Uint8Array(buffer))}return}
for(const record of records){const bytes=encodeRecord(record);if(includeInCrc){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength;crcHex=crc32Hex(crc)}await writer.write(bytes)}
};
report({phase:'prepare',boardsDone,bytesRead:rawBytes,bytesWritten},true);
await writeRecords([{type:'manifest',format:ARCHIVE_FORMAT,archiveVersion:ARCHIVE_VERSION,...manifest},{type:'global',value:globalState}]);
const estimatedBoardBytes=Math.max(1024,Math.ceil((Number(manifest.estimatedRawBytes)||0)/Math.max(1,manifest.boardCount||1))),batchLimit=Math.max(1,Math.min(MAX_WORKER_RECORDS,Math.floor(WORKER_TARGET_BYTES/estimatedBoardBytes)||1));let batch=[];
for await(const board of boards){throwIfAborted(signal);batch.push({type:'board',...board});boardsDone++;if(batch.length>=batchLimit){await writeRecords(batch);batch=[];report({phase:'write',boardsDone,bytesRead:rawBytes,bytesWritten})}}
await writeRecords(batch);
if(boardsDone!==manifest.boardCount)throw archiveError('The exported board count changed during export.','FIELD_CHANGED');
if(codec){const state=await codec.snapshot();rawBytes=state.rawBytes;crcHex=state.crc32}else crcHex=crc32Hex(crc);
await writeRecords([{type:'end',boardCount:boardsDone,rawBytes,crc32:crcHex}],false);
await writer.close();await pipe;report({phase:'write',boardsDone,bytesRead:rawBytes,bytesWritten},true);
return{boardCount:boardsDone,rawBytes,bytesWritten,crc32:crcHex,compression:manifest.compression,worker:codec!=null};
}catch(error){try{await writer?.abort(error)}catch(_){}try{await pipe}catch(_){}throw error}
finally{codec?.terminate()}
}
function decodedStream(file){
const source=file.stream();
return file.slice(0,2).arrayBuffer().then(buffer=>{
const bytes=new Uint8Array(buffer),gzip=bytes[0]===0x1f&&bytes[1]===0x8b;
if(gzip){
if(typeof DecompressionStream!=='function')throw archiveError('This browser cannot read gzip save files.','UNSUPPORTED_COMPRESSION');
return{stream:source.pipeThrough(new DecompressionStream('gzip')),compression:'gzip'};
}
return{stream:source,compression:'identity'};
});
}
async function firstArchiveRecord(file,signal){
if(!(file instanceof Blob))throw archiveError('No save file was selected.');
if(file.size<=0||file.size>MAX_ARCHIVE_FILE_BYTES)throw archiveError('The save file size is outside the supported range.','ARCHIVE_TOO_LARGE');
const decoded=await decodedStream(file),reader=decoded.stream.getReader(),decoder=new TextDecoder();
let text='',decodedBytes=0;
try{
while(true){
throwIfAborted(signal);
const{value,done}=await reader.read();if(done)break;
decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_LINE_BYTES)throw archiveError('The archive manifest is too large.','LINE_TOO_LARGE');
text+=decoder.decode(value,{stream:true});const newline=text.indexOf('\n');
if(newline>=0){
const line=text.slice(0,newline).replace(/\r$/,'');let record;
try{record=JSON.parse(line)}catch(_){throw archiveError('The archive manifest is not valid JSON.')}
validateManifestRecord(record,decoded.compression);return{record,compression:decoded.compression};
}
}
throw archiveError('The archive manifest is incomplete.');
}finally{try{await reader.cancel()}catch(_){}}
}
async function inspectArchive(file,{signal}={}){
const{record}=await firstArchiveRecord(file,signal);
return{
archiveVersion:record.archiveVersion,
saveSchema:record.saveSchema,
gameplayVersion:record.gameplayVersion,
worldGeneration:record.worldGeneration,
appVersion:record.appVersion,
generatorVersion:record.generatorVersion,
exportedAt:record.exportedAt,
boardCount:record.boardCount,
estimatedRawBytes:record.estimatedRawBytes,
compression:record.compression
};
}
async function readArchive(file,{signal,onProgress,onManifest,onGlobal,onBoard}={}){
if(!(file instanceof Blob))throw archiveError('No save file was selected.');
if(file.size<=0||file.size>MAX_ARCHIVE_FILE_BYTES)throw archiveError('The save file size is outside the supported range.','ARCHIVE_TOO_LARGE');
const decoded=await decodedStream(file),reader=decoded.stream.getReader(),decoder=new TextDecoder(),report=progressReporter(onProgress),codec=createArchiveCodec();
let buffer='',decodedBytes=0,rawBytes=0,crc=0xffffffff,crcHex='00000000',phase='manifest',manifest=null,globalState=null,boardsDone=0,previousBoardNumber=-1,footer=null,pendingLines=[],pendingLineBytes=0;
const processRecord=async(record,state)=>{
throwIfAborted(signal);rawBytes=state.rawBytes;crcHex=state.crc32;
if(phase==='manifest'){
validateManifestRecord(record,decoded.compression);manifest=record;phase='global';await onManifest?.(record);return;
}
if(phase==='global'){
if(record?.type!=='global'||!record.value||typeof record.value!=='object')throw archiveError('The archive global record is missing or invalid.');
globalState=record.value;phase='boards';await onGlobal?.(record.value);return;
}
if(phase!=='boards')throw archiveError('The archive contains records after its footer.');
if(record?.type==='board'){
const number=boardNumber(record.id);if(number<0||number<=previousBoardNumber)throw archiveError('Archive boards are duplicated or not in numeric order.');
if(record.id==='B0'&&boardsDone!==0)throw archiveError('B0 must be the first board.');if(boardsDone===0&&record.id!=='B0')throw archiveError('The archive does not begin with B0.');if(boardsDone>=manifest.boardCount)throw archiveError('The archive contains more boards than declared.');
previousBoardNumber=number;await onBoard?.(record,boardsDone);boardsDone++;report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0});return;
}
if(record?.type==='end'){
footer=record;phase='done';if(boardsDone!==manifest.boardCount||record.boardCount!==boardsDone)throw archiveError('The archive board count does not match its footer.');if(record.rawBytes!==state.rawBytes||String(record.crc32).toLowerCase()!==state.crc32)throw archiveError('The archive integrity check failed.','CHECKSUM_MISMATCH');return;
}
throw archiveError('The archive contains an unknown or misplaced record.');
};
const flushLines=async()=>{
if(!pendingLines.length)return;const lines=pendingLines;pendingLines=[];pendingLineBytes=0;
if(codec){const result=await codec.parse(lines);for(const item of result.items){if(item.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');await processRecord(item.record,item)}return}
for(const sourceLine of lines){let parsed;try{parsed=parseRecordLine(sourceLine)}catch(error){throw archiveError(error.message)}const{line,record,bytes:lineBytes}=parsed;if(!line)throw archiveError('The archive contains an empty record.');if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})}
};
try{
while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encodeUtf8(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}}
buffer+=decoder.decode();if(buffer){const estimatedBytes=buffer.length*3+1;if(pendingLines.length&&pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES)await flushLines();pendingLines.push(buffer);pendingLineBytes+=estimatedBytes}await flushLines();if(phase!=='done'||!footer)throw archiveError('The archive footer is missing or incomplete.');report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0},true);return{manifest,globalState,footer,boardCount:boardsDone,decodedBytes,compression:decoded.compression,worker:codec!=null};
}finally{try{reader.releaseLock()}catch(_){}codec?.terminate()}
}
async function cleanupTemporaryExports({olderThanMs=24*60*60*1000}={}){
if(!global.navigator?.storage?.getDirectory)return 0;let removed=0;const root=await global.navigator.storage.getDirectory(),cutoff=Date.now()-olderThanMs;
for await(const[name]of root.entries()){
const match=/^\.bend-field-save-.*\.(\d+)\.partial$/.exec(name);if(!match||Number(match[1])>cutoff)continue;
try{await root.removeEntry(name);removed++}catch(_){}
}
return removed;
}
global.BendFieldPersistence=Object.freeze({
ARCHIVE_FORMAT,ARCHIVE_VERSION,MAX_ARCHIVE_FILE_BYTES,MAX_ARCHIVE_RAW_BYTES,MAX_ARCHIVE_LINE_BYTES,
MAX_ARCHIVE_EXPANSION_RATIO,MAX_BLOB_BYTES,crc32Update,crc32Hex,createArchiveDestination,
inspectArchive,writeArchive,readArchive,cleanupTemporaryExports
});
})(globalThis);