const STORAGE_KEY = 'basket-utils:saved-plays:v1'; const SCHEMA_VERSION = 1; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; const TEAMS = new Set(['offense', 'defense']); const ACTION_TYPES = new Set(['move', 'pass', 'screen', 'shoot']); const LOOK_AT_TYPES = new Set(['rim', 'ball', 'player', 'location', 'movement']); function clone(value) { return JSON.parse(JSON.stringify(value)); } function normalizedName(name) { return String(name ?? '').trim() || '새 전술'; } function finiteLocation(location) { return Boolean(location && Number.isFinite(location.x) && Number.isFinite(location.z)); } function validPlay(play) { if (!play || typeof play.id !== 'string' || !SAFE_ID.test(play.id) || typeof play.name !== 'string' || !Array.isArray(play.players) || !Array.isArray(play.sequences) || !play.sequences.length || !['man-to-man', '2-3', '3-2'].includes(play.defenseType)) return false; const players = new Map(); for (const player of play.players) { if (!player || typeof player.id !== 'string' || !SAFE_ID.test(player.id) || players.has(player.id) || !TEAMS.has(player.team) || !Number.isFinite(player.number) || !finiteLocation(player.location)) return false; players.set(player.id, player); } const sequenceIds = new Set(); const actionIds = new Set(); const shootRecords = []; for (const sequence of play.sequences) { if (!sequence || typeof sequence.id !== 'string' || !SAFE_ID.test(sequence.id) || sequenceIds.has(sequence.id) || typeof sequence.name !== 'string' || !Array.isArray(sequence.tracks) || sequence.tracks.length !== players.size || typeof sequence.ballOwnerId !== 'string' || !players.has(sequence.ballOwnerId) || players.get(sequence.ballOwnerId).team !== 'offense' || typeof sequence.ballOwnerInherited !== 'boolean') return false; sequenceIds.add(sequence.id); const trackIds = new Set(); let passCount = 0; for (const track of sequence.tracks) { if (!track || typeof track.playerId !== 'string' || !players.has(track.playerId) || trackIds.has(track.playerId) || !finiteLocation(track.startLocation) || !Array.isArray(track.actions)) return false; trackIds.add(track.playerId); for (const action of track.actions) { if (!action || typeof action.id !== 'string' || !SAFE_ID.test(action.id) || actionIds.has(action.id) || !ACTION_TYPES.has(action.type) || !finiteLocation(action.location) || !(action.facing === null || Number.isFinite(action.facing)) || !(action.targetPlayerId === null || (typeof action.targetPlayerId === 'string' && players.has(action.targetPlayerId))) || !(action.lookAt === null || typeof action.lookAt === 'object' && LOOK_AT_TYPES.has(action.lookAt.type))) return false; actionIds.add(action.id); const actor = players.get(track.playerId); const target = action.targetPlayerId ? players.get(action.targetPlayerId) : null; if (action.type === 'pass') { passCount += 1; if (actor.team !== 'offense' || sequence.ballOwnerId !== actor.id || !target || target.team !== 'offense' || target.id === actor.id || sequence.tracks.find((candidate) => candidate.playerId === target.id)?.actions.some((candidate) => candidate.type === 'screen')) return false; } if (action.type === 'screen' && (actor.team !== 'offense' || !target || target.team !== 'defense')) return false; if (action.type === 'shoot') { if (actor.team !== 'offense' || action.targetPlayerId !== null || action.facing !== null || action.lookAt?.type !== 'rim') return false; shootRecords.push({ action, sequence, sequenceIndex: play.sequences.indexOf(sequence), track }); } if (action.lookAt?.type === 'location' && !finiteLocation(action.lookAt)) return false; if (action.lookAt?.type === 'player' && (!players.has(action.lookAt.targetId) || action.lookAt.targetId === track.playerId)) return false; } } if (trackIds.size !== players.size || passCount > 1) return false; const finalOwner = sequence.tracks.find((track) => track.playerId === sequence.ballOwnerId)?.actions.findLast((action) => action.type === 'pass')?.targetPlayerId || sequence.ballOwnerId; if (sequence.tracks.find((track) => track.playerId === finalOwner)?.actions.some((action) => action.type === 'screen')) return false; } if (shootRecords.length > 1) return false; if (shootRecords.length === 1) { const { action, sequence, sequenceIndex, track } = shootRecords[0]; const pass = sequence.tracks.flatMap((candidate) => candidate.actions).findLast((candidate) => candidate.type === 'pass'); const ownerId = pass?.targetPlayerId || sequence.ballOwnerId; const previousLocation = track.actions.length > 1 ? track.actions.at(-2).location : track.startLocation; if (sequenceIndex !== play.sequences.length - 1 || track.playerId !== ownerId || track.actions.at(-1) !== action || action.location.x !== previousLocation.x || action.location.z !== previousLocation.z) return false; } return true; } export function isValidPlay(play) { return validPlay(play); } export function assertValidPlay(play) { if (!validPlay(play)) throw new Error('저장할 전술 데이터가 올바르지 않습니다'); return true; } function readEnvelope(storage) { if (!storage) throw new Error('localStorage를 사용할 수 없습니다'); const raw = storage.getItem(STORAGE_KEY); if (raw === null) return { schemaVersion: SCHEMA_VERSION, records: [] }; let envelope; try { envelope = JSON.parse(raw); } catch { throw new Error('저장 데이터가 손상되었습니다'); } if (envelope?.schemaVersion !== SCHEMA_VERSION || !Array.isArray(envelope.records)) throw new Error('지원하지 않는 저장 데이터입니다'); const recordIds = new Set(); for (const record of envelope.records) if (!record || typeof record.id !== 'string' || recordIds.has(record.id) || !validPlay(record.play) || record.id !== record.play.id || typeof record.name !== 'string' || record.name !== record.play.name || typeof record.updatedAt !== 'string' || Number.isNaN(Date.parse(record.updatedAt))) throw new Error('저장 데이터 형식이 올바르지 않습니다'); else recordIds.add(record.id); return envelope; } export function createLocalPlayRepository(storage = typeof window !== 'undefined' ? window.localStorage : null) { return { async save(play) { assertValidPlay(play); const envelope = readEnvelope(storage); const nextPlay = clone(play); nextPlay.name = normalizedName(nextPlay.name); const record = { id: nextPlay.id, name: nextPlay.name, updatedAt: new Date().toISOString(), play: nextPlay }; const records = envelope.records.filter((candidate) => candidate.id !== nextPlay.id); records.push(record); records.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); storage.setItem(STORAGE_KEY, JSON.stringify({ schemaVersion: SCHEMA_VERSION, records })); return clone(record); }, async list() { return readEnvelope(storage).records.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map(({ id, name, updatedAt }) => ({ id, name, updatedAt })); }, async get(id) { const record = readEnvelope(storage).records.find((candidate) => candidate.id === id); return record ? clone(record.play) : null; }, }; } export { STORAGE_KEY };