Source: parser.js

import { Coordinate2D, Coordinate3D } from './coordinate.js'
import { Block, LevelObject } from './block.js'
import { Level, Level2D, Level3D } from './level.js'
import { CoordinateMatrix2D, CoordinateMatrix3D } from './matrix.js'

// Export

/**
 * Parses a LevelZ Level.
 * @param {string} level The level input.
 * @returns {Level} The parsed level.
 * @throws {SyntaxError} if the level is invalid
 * @example
 * // Read from file
 * import * as fs from 'fs'
 * 
 * fs.readFile('level.lvlz', 'utf8', (err, data) => {
 *    if (err) throw err
 *    const level = parseLevel(data)
 *    console.log(level)
 * })
 */
export function parseLevel(level) {
    const [headers0, blocks0] = split(level)
    
    const headers = readHeaders(headers0)
    const is2D = headers.get('type') == 2

    const spawn = headers.get('spawn')
    if (!spawn || spawn === 'default') 
        headers.set('spawn', is2D ? '[0, 0]' : '[0, 0, 0]')

    if (!headers.get('scroll'))
        headers.set('scroll', 'none')

    const blocks = new Set()
    for (const line of blocks0) {
        if (!line) continue
        if (line === 'end') break

        const ci = line.indexOf('#')
        const line0 = (ci > -1 ? line.slice(0, ci) : line).trim()

        if (!line0) continue
        if (line0 === 'end') break

        const [block, points] = readLine(line0, !is2D)
        if (!block || !points) continue

        for (const point of points)
            blocks.add(new LevelObject(block, point))
    }

    if (is2D) return new Level2D(headers, blocks)

    return new Level3D(headers, blocks)
}

// Internal

/**
 * @private
 * @param {string} level
 * @returns {string[][]}
 */
export function split(level) {
    const [headers, blocks] = level.split('\n---')
    return [headers.split('\n'), blocks.split('\n')]
}

/**
 * @private
 * @param {Map<any, number>} map 
 * @returns {any}
 */
export function roll(map) {
    const sum = Array.from(map.values()).reduce((a, b) => a + b, 0)
    if (sum < 1) throw new SyntaxError(`Invalid Probabilities: ${sum}`)

    let t

    const r = Math.random()
    let cumulative = 0
    for (const [key, value] of map) {
        cumulative += value
        if (r < cumulative / sum) {
            t = key
            break
        }
    }

    return t
}

/**
 * @private
 * @param {string[]} headers
 * @returns {Map<string, string>}
 */
export function readHeaders(headers) {
    const map = new Map()
    for (const s of headers) {
        if (!s.startsWith('@')) throw new SyntaxError(`'Invalid header; does not start with @: ${s}`)

        const [key, value] = s.split(/\s(.*)/s)
        map.set(key.slice(1), value.trim())
    }

    if (!map.has('type')) throw new SyntaxError('Missing @type header')
    if (map.get('type') != '2' && map.get('type') != '3') throw new SyntaxError('Invalid @type header (found "' + map.get('type') + '")')

    if (!map.has('spawn')) throw new SyntaxError('Missing @spawn header')

    return map
}

/**
 * @private
 * @param {string} input 
 * @returns {Set<Coordinate2D>}
 */
export function read2DPoints(input) {
    const points = new Set()
    
    const inputs = input.split(/\*/)

    for (const s0 of inputs) {
        const s = s0.trim()
        if (!s) return

        if (s.startsWith('(') && s.endsWith(']')) {
            for (const coord of CoordinateMatrix2D.fromString(s))
                points.add(coord)
        } else
            points.add(Coordinate2D.fromString(s))
    }

    return points
}

/**
 * @private
 * @param {string} input 
 * @returns {Set<Coordinate3D>} 
 */
export function read3DPoints(input) {
    const points = new Set()
    
    const inputs = input.split(/\*/)

    for (const s0 of inputs) {
        const s = s0.trim()
        if (!s) return

        if (s.startsWith('(') && s.endsWith(']')) {
            for (const coord of CoordinateMatrix3D.fromString(s))
                points.add(coord)
        } else
            points.add(Coordinate3D.fromString(s))
    }

    return points
}

/**
 * @private
 * @param {string} input 
 * @param {boolean} threeD
 * @returns {Array.<any>}
 */
export function readLine(input, threeD = false) {
    if (!input) return [];
    const split = input.replace(/\s/g, "").split(/:/)

    const block = readBlock(split[0])
    if (!block) return [];

    const points = !threeD ? read2DPoints(split[1]) : read3DPoints(split[1])

    return [block, points]
}

/**
 * @private
 * @param {string} line 
 * @returns {Block}
 */
export function readBlock(line) {
    if (line.startsWith('{') && line.endsWith('}')) {
        const block0 = line.replace(/[{}]/g, "")
        
        let blocks;
        if (line.includes('>,')) {
            blocks = block0.split(/>,/g)
            for (let i = 0; i < blocks.length; i++)
                if (blocks[i].includes('<'))
                    blocks[i] = `${blocks[i]}>`
        } else
            blocks = block0.split(/,/g)

        const l = blocks.length
        const blockToChance = new Map()
        for (const s of blocks) {
            const [block, chance] = s.split(/=/, 2)

            const v = parseFloat(chance)
            if (v)
                blockToChance.set(readRawBlock(block), v)
            else
                blockToChance.set(readRawBlock(block), 1 / l)
        }

        return roll(blockToChance)
    } else
        return readRawBlock(line)
}

/**
 * @private
 * @param {string} input 
 * @returns {Block}
 */
export function readRawBlock(input) {
    if (!input) return null

    const split = input.replace(/[\s\>]/, "").split(/\</)
    const name = split[0].trim()

    if (split.length < 2) return new Block(name)

    const properties = new Map()
    const rawProperties = split[1].split(/,/)

    for (const s of rawProperties) {
        const [key, value] = s.split(/=/)
        properties.set(key, value)
    }

    return new Block(name, properties)
}