diff --git a/script.js b/script.js index 3d7e1f8..269f97c 100644 --- a/script.js +++ b/script.js @@ -262,6 +262,15 @@ class Board { this.removeOrbByIndex(index) } + /** + * Count orbs in the game board + * + * @return {number} + */ + countOrbs() { + return this.grid.reduce((acu, x) => acu + (x !== null), 0); + } + /** * Remove orb by array index * @@ -642,42 +651,117 @@ class Board { return this.grid[n]; } + /** + * Check if a cell is available for selection in-game + * (checking surroundings only). This should work the same regardless + * of whether the cell is occupied or not. + * + * @param n + * @return {boolean} + */ + isAvailable(n) { + return this.getCellInfo(n).freeSequence >= 3; + } + + /** + * Get info about a cell surroundings and position + * + * neighbours - number of occupied neighbouring cells + * centerWeight - distance from the board center + * freeSequence - number of contiguous free tiles in the direct neighbourhood + * + * @param n + * @return {{neighbours: number, centerWeight: number, freeSequence: number}} + */ + getCellInfo(n) { + let {x, y} = gridIndexToXy(n); + + let freeSpaces = [ + isXyOutside(x - 1, y) || !this.grid[n - 1], + isXyOutside(x - 1, y - 1) || !this.grid[n - 12], + isXyOutside(x, y - 1) || !this.grid[n - 11], + isXyOutside(x + 1, y) || !this.grid[n + 1], + isXyOutside(x + 1, y + 1) || !this.grid[n + 12], + isXyOutside(x, y + 1) || !this.grid[n + 11], + ]; + + let nOccupied = 0; + for (let i = 0; i < 6; i++) { + if (!freeSpaces[i]) { + nOccupied++; + } + } + + let freeSequence = 0; + let maxFreeSequence = 0; + for (let i = 0; i < 12; i++) { + if (freeSpaces[i % 6]) { + freeSequence++; + if (freeSequence >= 6) { + maxFreeSequence = freeSequence; + break; + } + if (freeSequence > maxFreeSequence) { + maxFreeSequence = freeSequence; + } + } else { + freeSequence = 0; + } + } + + let { rx, ry } = this.gridXyToCoord(x, y); + + return { + neighbours: nOccupied, + freeSequence: maxFreeSequence, + centerWeight: Math.round(Math.sqrt(Math.pow(rx, 2) + Math.pow(ry, 2))), + }; + } + + /** + * Build the game GUI (buttons, links) + */ buildGui() { + const addButton = (x, y, text, classes='') => { + let { rx, ry } = this.guiXyToCoord(x, y); + let button = Svg.fromXML(`${text}`); + this.$root.appendChild(button); + return button; + }; + const y0 = 0.05; const x0 = 0; const ysp = 0.75; const ysp2 = 0.6; - this.buttons.randomize = this.addButton(x0, y0, 'Randomize'); - this.buttons.restart = this.addButton(x0, y0 + ysp, 'Try Again', 'disabled'); - this.buttons.undo = this.addButton(x0, y0 + ysp*2, 'Undo', 'disabled'); + this.buttons.randomize = addButton(x0, y0, 'Randomize'); + this.buttons.restart = addButton(x0, y0 + ysp, 'Try Again', 'disabled'); + this.buttons.undo = addButton(x0, y0 + ysp*2, 'Undo', 'disabled'); const cfgy0 = 10; - this.buttons.optFancy = this.addButton(x0, cfgy0, 'Effects:', 'config'); - this.buttons.optBlockedEffect = this.addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config'); - this.buttons.optSloppy = this.addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); - this.buttons.toggleFullscreen = this.addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config'); + this.buttons.optFancy = addButton(x0, cfgy0, 'Effects:', 'config'); + this.buttons.optBlockedEffect = addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config'); + this.buttons.optSloppy = addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); + this.buttons.toggleFullscreen = addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config'); - this.buttons.btnAbout = this.addButton(x0, cfgy0+ysp2*-2.5, 'Help', 'config'); + this.buttons.btnAbout = addButton(x0, cfgy0+ysp2*-2.5, 'Help', 'config'); let youWin = Svg.fromXML(`Good work!`); this.$root.appendChild(youWin); this.youWin = youWin; } + /** + * Update toggles + * + * @param cfg + */ updateSettingsGUI(cfg) { this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off'); this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.dimBlocked ? 'On' : 'Off'); this.buttons.optSloppy.textContent = 'Sloppy Mode: '+(cfg.allowTemplateAugmenting ? 'On' : 'Off'); } - - addButton(x, y, text, classes='') { - let { rx, ry } = this.guiXyToCoord(x, y); - let button = Svg.fromXML(`${text}`); - this.$root.appendChild(button); - return button; - } } /** @@ -733,6 +817,33 @@ class Rng { nextInt(max) { return Math.floor((max + 1) * this.next()); } + + /** + * Shuffle an array. + * The array is shuffled in place. + * + * @return the array + */ + arrayShuffle(a) { + let j, x, i; + for (i = a.length - 1; i > 0; i--) { + j = this.nextInt(i); + x = a[i]; + a[i] = a[j]; + a[j] = x; + } + return a; + } + + /** + * Choose a random member of an array + * + * @param array + * @return {number} + */ + arrayChoose(array) { + return array[Math.floor(this.next() * array.length)]; + } } class SettingsStorage { @@ -748,6 +859,7 @@ class SettingsStorage { svgEffects: false, dimBlocked: true, logSolution: false, + highlightTemplate: false, }; this.settings = Object.assign({}, this.defaults); } @@ -1101,501 +1213,152 @@ class Game { }; } - isAvailable(n) { - return this.getNeighbours(n).freeSequence >= 3; + placeOrbs(template) { + let placer = new RadialOrbPlacer(this, template); + return placer.place(); } - getNeighbours(n) { - let {x, y} = gridIndexToXy(n); + getPairSymbols(first) { + return { + 'salt': ['salt', 'air', 'fire', 'water', 'earth'], + 'air': ['salt', 'air'], + 'fire': ['salt', 'fire'], + 'water': ['salt', 'water'], + 'earth': ['salt', 'earth'], + 'mercury': [this.nextMetal], + 'lead': ['mercury'], + 'tin': ['mercury'], + 'iron': ['mercury'], + 'copper': ['mercury'], + 'silver': ['mercury'], + 'gold': [], + 'mors': ['vitae'], + 'vitae': ['mors'], + }[first]; + } - let freeSpaces = [ - isXyOutside(x - 1, y) || !this.board.grid[n - 1], - isXyOutside(x - 1, y - 1) || !this.board.grid[n - 12], - isXyOutside(x, y - 1) || !this.board.grid[n - 11], - isXyOutside(x + 1, y) || !this.board.grid[n + 1], - isXyOutside(x + 1, y + 1) || !this.board.grid[n + 12], - isXyOutside(x, y + 1) || !this.board.grid[n + 11], - ]; + advanceMetal() { + if (this.nextMetal === 'gold') throw new Error("No metals to unlock beyond gold."); + this.nextMetal = METAL_SEQ[METAL_SEQ.indexOf(this.nextMetal) + 1]; + console.debug(`Next metal unlocked: ${this.nextMetal}`); + } - let nOccupied = 0; - for (let i = 0; i < 6; i++) { - if (!freeSpaces[i]) { - nOccupied++; + /** + * Convert the strings in the board array to actual SVG orbs (strings are a placeholder to speed up board solving) + */ + renderPreparedBoard() { + for (let n = 0; n < BOARD_SIZE; n++) { + if (this.board.grid[n] !== null) { + const symbol = this.board.grid[n]; + this.board.grid[n] = null; + this.board.placeOrbByIndex(n, symbol); } } + } - let freeSequence = 0; - let maxFreeSequence = 0; - for (let i = 0; i < 12; i++) { - if (freeSpaces[i % 6]) { - freeSequence++; - if (freeSequence >= 6) { - maxFreeSequence = freeSequence; - break; - } - if (freeSequence > maxFreeSequence) { - maxFreeSequence = freeSequence; - } - } else { - freeSequence = 0; + /** + * Check if a tile is available at play-time (checking unlocked metals) + * + * @param n + * @return {Boolean} + */ + isAvailableAtPlaytime(n) { + let ava = this.board.isAvailable(n); + + const sym = this.board.grid[n].symbol; + if (METAL_SEQ.includes(sym)) { + if (sym !== this.nextMetal) { + ava = false; } } - let { rx, ry } = this.board.gridXyToCoord(x, y); + return ava; + } - return { - neighbours: nOccupied, - freeSequence: maxFreeSequence, - centerWeight: Math.round(Math.sqrt(Math.pow(rx, 2) + Math.pow(ry, 2))), - }; + addUndoRecord(orbs) { + this.undoStack.push({ + nextMetal: this.nextMetal, + orbs, + }) } - placeOrbs(template) { - let tilesAdded = 0; + undo() { + if (!this.undoStack.length) { + console.warn("Undo stack is empty."); + return; + } - this.board.removeAllOrbs(); + let item = this.undoStack.pop(); - let allowedTable = []; - let outsideTemplate = []; - for (let i = 0; i < BOARD_SIZE; i++) { - const allo = template.includes(i); - allowedTable.push(allo); + this.nextMetal = item.nextMetal; + for (let entry of item.orbs) { + this.debug(`Undo orb ${entry.symbol} at ${entry.n}`); + this.board.placeOrbByIndex(entry.n, entry.symbol); + } - let {x, y} = gridIndexToXy(i); - if (!allo && !isXyOutside(x, y)) { - outsideTemplate.push(i); - } + this.updateGameGui(); + } - // Highlight pattern shape + /** + * Handle orb click + * + * @param n + * @param orb + */ + inGameBoardClick(n, orb) { + let removed = false; + this.debug(`Clicked orb ${n}: ${orb.symbol}`); - // if (this.board.tiles[i]) { - // if (allo) { - // this.board.tiles[i].setAttribute('opacity', 1) - // } else { - // this.board.tiles[i].setAttribute('opacity', 0.6) - // } - // } + if (!this.isAvailableAtPlaytime(n)) { + this.debug(`Orb is blocked`); + return; } - const place = (n, symbol) => { - this.trace(`Place ${n} <- ${symbol}`); - if (!allowedTable[n]) throw Error(`Position ${n} not allowed by template`); - if (this.board.grid[n]) throw Error(`Position ${n} is occupied by ${this.board.grid[n]}`); - // we use a hack to speed up generation here - SVG is not altered until we have a solution - this.board.grid[n] = symbol; - }; - - const unplace = (n) => { - this.trace(`Unplace ${n}`); - this.board.grid[n] = null; - }; + let wantRefresh = false; - const findBestCandidate = (except = null) => { - let candidates = []; - for (let n of template) { - if (except && except.includes(n)) continue; - - if (!this.board.grid[n]) { - // is free - const neigh = this.getNeighbours(n); - if (neigh.freeSequence >= 3) { - candidates.push({ - n, - cw: neigh.centerWeight - }); - } - } - } + if (orb.symbol === 'gold') { + // gold has no pairing + this.debug(`Removing gold.`); - candidates.sort((a, b) => a.cw - b.cw); + this.addUndoRecord([{ + symbol: orb.symbol, + n, + }]); - if (candidates.length) { - // return candidates[0].n; - let top = []; - let topw = candidates[0].cw; - for(let cand of candidates) { - if (cand.cw <= topw + this.cfg.difficulty) { // TODO this could vary by template - top.push(cand); - } - } + this.board.removeOrbByIndex(n); + this.selectedOrb = null; + removed = true; + wantRefresh = true; + } else if (this.selectedOrb === null) { + this.debug(`Select orb`); + // first selection + this.selectedOrb = {n, orb}; + orb.node.classList.add('selected'); + } else { + if (this.selectedOrb.n === n) { + this.debug(`Unselect orb`); + // orb clicked twice + orb.node.classList.remove('selected'); + this.selectedOrb = null; + } else { + this.debug(`Second selection, try to match`); - //the neighbor count is not used for anything anymore, oops + // second orb in a pair + const otherSymbol = this.selectedOrb.orb.symbol; - // console.log('Got a choice of '+top.length+' tiles'); + if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) { + this.debug(`Match confirmed, removing ${this.selectedOrb.n} (${this.selectedOrb.orb.symbol} + ${n} (${orb.symbol}`); - return this.arrayChoose(top).n; - } - - return false; - }; - - const findAvailableIndex = (except = null) => { - const n = findBestCandidate(except); - if (n !== false) return n; - - // this corrupts the template, but makes the likelihood of quickly finding a valid solution much higher. - if (template.length !== BOARD_SIZE && this.cfg.allowTemplateAugmenting) { - // Prefer tile with more neighbours to make the game harder - let candidates = []; - outsideTemplate.forEach((n) => { - if (!allowedTable[n] && this.isAvailable(n)) { - const neigh = this.getNeighbours(n); - if (!candidates[neigh.neighbours]) { - candidates[neigh.neighbours] = [n]; - } else { - candidates[neigh.neighbours].push(n); - } - } - }); - - for (let i = 6; i >= 0; i--) { - if (!candidates[i]) continue; - let toAdd = this.arrayChoose(candidates[i]); - allowedTable[toAdd] = true; - outsideTemplate.splice(outsideTemplate.indexOf(toAdd), 1); - - template.push(toAdd); - tilesAdded++; - - this.warn(`Adding extra tile to template: ${toAdd}`); - return toAdd; - } - } - - throw Error("Failed to find available board tile."); - }; - - place(60, 'gold'); - - const toPlace = this.buildPlacementList(); - - let solution = [ - ['gold', 60], - ]; - while (toPlace.length > 0) { - this.trace('placing a pair.'); - - let symbol1 = toPlace.pop(); - let index1 = findAvailableIndex(); - place(index1, symbol1); - solution.push([symbol1, index1]); - - let symbol2 = toPlace.pop(); - let index2 = findAvailableIndex(); - place(index2, symbol2); - - if (!this.isAvailable(index1)) { - this.debug(`Deadlock, trying to work around it - ${index1}, ${index2}`); - - unplace(index2); - let except = [index2]; - - let suc = false; - for (let i = 0; i < 5; i++) { - this.trace(`try #${i + 1}`); - let index = findAvailableIndex(except); - place(index, symbol2); - - if (this.isAvailable(index1)) { - suc = true; - index2 = index; - break; - } else { - unplace(index); - except.push(index); - } - } - - if (!suc) { - throw new Error("Solution contains a deadlock."); - } - } - - // index2 is updated in the fixing loop - solution.push([symbol2, index2]); - } - - // Show the solution for debug - - solution.reverse(); - this.info("Found a valid board!"); - - solution.forEach((a) => { - let p = gridIndexToXy(a[1]); - a[1] = `${p.x}×${p.y}`; - }); - - this.debug('Solution:', solution); - - return { - solution, - tilesAdded: tilesAdded, - }; - } - - /** - * Shuffle an array. - * The array is shuffled in place. - * - * @return the array - */ - arrayShuffle(a) { - let j, x, i; - for (i = a.length - 1; i > 0; i--) { - j = this.rng.nextInt(i); - x = a[i]; - a[i] = a[j]; - a[j] = x; - } - return a; - } - - /** - * Count orbs in the game board - * - * @return {number} - */ - countOrbs() { - return this.board.grid.reduce((acu, x) => acu + (x !== null), 0); - } - - /** - * Choose a random emember of an array - * - * @param array - * @return {number} - */ - arrayChoose(array) { - return array[Math.floor(this.rng.next() * array.length)]; - } - - buildPlacementList() { - let toPlace = [ - ['air', 'air'], - ['air', 'air'], - ['air', 'air'], - ['air', 'air'], - - ['fire', 'fire'], - ['fire', 'fire'], - ['fire', 'fire'], - ['fire', 'fire'], - - ['water', 'water'], - ['water', 'water'], - ['water', 'water'], - ['water', 'water'], - - ['earth', 'earth'], - ['earth', 'earth'], - ['earth', 'earth'], - ['earth', 'earth'], - ]; - - let newSaltedPairs = []; - const nsalted = this.rng.nextInt(2); - for (let i = 0; i < nsalted; i++) { - while (true) { - const n = this.rng.nextInt(toPlace.length - 1); - if (toPlace[n][1] !== 'salt') { - this.trace(`Pairing ${toPlace[n][1]} with salt.`); - newSaltedPairs.push([toPlace[n][1], 'salt']); - toPlace[n][1] = 'salt'; - break; - } - } - } - toPlace = toPlace.concat(newSaltedPairs); - // if we have some salt pairs left - for (let i = 0; i < 2 - nsalted; i++) { - toPlace.push(['salt', 'salt']); - } - - // these are always paired like this, and don't support salt - toPlace = toPlace.concat([ - ['mors', 'vitae'], - ['mors', 'vitae'], - ['mors', 'vitae'], - ['mors', 'vitae'], - ]); - - // shuffle the pairs that have random order (i.e. not metals) - this.arrayShuffle(toPlace); - - // the order here is actually significant, so let's pay attention... - const metals = [ - ['lead', 'mercury'], - ['tin', 'mercury'], - ['iron', 'mercury'], - ['copper', 'mercury'], - ['silver', 'mercury'], - ]; - let mPos = []; - for (let i = 0; i < metals.length; i++) { - let x; - // find a unique position - do { - x = this.rng.nextInt(toPlace.length + i); - } while (mPos.includes(x)); - mPos.push(x) - } - mPos.sort((a, b) => a - b); - this.trace('Metal positions ', mPos); - // inject them into the array - metals.forEach((pair, i) => { - toPlace.splice(mPos[i] + i, 0, pair); - }); - - this.debug('Placement order (last first):', toPlace); - - return toPlace.reduce((a, c) => { - a.push(c[0]); - a.push(c[1]); - return a; - }, []); - } - - getPairSymbols(first) { - return { - 'salt': ['salt', 'air', 'fire', 'water', 'earth'], - 'air': ['salt', 'air'], - 'fire': ['salt', 'fire'], - 'water': ['salt', 'water'], - 'earth': ['salt', 'earth'], - 'mercury': [this.nextMetal], - 'lead': ['mercury'], - 'tin': ['mercury'], - 'iron': ['mercury'], - 'copper': ['mercury'], - 'silver': ['mercury'], - 'gold': [], - 'mors': ['vitae'], - 'vitae': ['mors'], - }[first]; - } - - advanceMetal() { - if (this.nextMetal === 'gold') throw new Error("No metals to unlock beyond gold."); - this.nextMetal = METAL_SEQ[METAL_SEQ.indexOf(this.nextMetal) + 1]; - console.debug(`Next metal unlocked: ${this.nextMetal}`); - } - - /** - * Convert the strings in the board array to actual SVG orbs (strings are a placeholder to speed up board solving) - */ - renderPreparedBoard() { - for (let n = 0; n < BOARD_SIZE; n++) { - if (this.board.grid[n] !== null) { - const symbol = this.board.grid[n]; - this.board.grid[n] = null; - this.board.placeOrbByIndex(n, symbol); - } - } - } - - /** - * Check if a tile is available at play-time (checking unlocked metals) - * - * @param n - * @return {Boolean} - */ - isAvailableAtPlaytime(n) { - let ava = this.isAvailable(n); - - const sym = this.board.grid[n].symbol; - if (METAL_SEQ.includes(sym)) { - if (sym !== this.nextMetal) { - ava = false; - } - } - - return ava; - } - - addUndoRecord(orbs) { - this.undoStack.push({ - nextMetal: this.nextMetal, - orbs, - }) - } - - undo() { - if (!this.undoStack.length) { - console.warn("Undo stack is empty."); - return; - } - - let item = this.undoStack.pop(); - - this.nextMetal = item.nextMetal; - for (let entry of item.orbs) { - this.debug(`Undo orb ${entry.symbol} at ${entry.n}`); - this.board.placeOrbByIndex(entry.n, entry.symbol); - } - - this.updateGameGui(); - } - - /** - * Handle orb click - * - * @param n - * @param orb - */ - inGameBoardClick(n, orb) { - let removed = false; - this.debug(`Clicked orb ${n}: ${orb.symbol}`); - - if (!this.isAvailableAtPlaytime(n)) { - this.debug(`Orb is blocked`); - return; - } - - let wantRefresh = false; - - if (orb.symbol === 'gold') { - // gold has no pairing - this.debug(`Removing gold.`); - - this.addUndoRecord([{ - symbol: orb.symbol, - n, - }]); - - this.board.removeOrbByIndex(n); - this.selectedOrb = null; - removed = true; - wantRefresh = true; - } else if (this.selectedOrb === null) { - this.debug(`Select orb`); - // first selection - this.selectedOrb = {n, orb}; - orb.node.classList.add('selected'); - } else { - if (this.selectedOrb.n === n) { - this.debug(`Unselect orb`); - // orb clicked twice - orb.node.classList.remove('selected'); - this.selectedOrb = null; - } else { - this.debug(`Second selection, try to match`); - - // second orb in a pair - const otherSymbol = this.selectedOrb.orb.symbol; - - if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) { - this.debug(`Match confirmed, removing ${this.selectedOrb.n} (${this.selectedOrb.orb.symbol} + ${n} (${orb.symbol}`); - - this.addUndoRecord([ - { - symbol: this.selectedOrb.orb.symbol, - n: this.selectedOrb.n, - }, - { - symbol: orb.symbol, - n, - } - ]); + this.addUndoRecord([ + { + symbol: this.selectedOrb.orb.symbol, + n: this.selectedOrb.n, + }, + { + symbol: orb.symbol, + n, + } + ]); // compatible pair clicked this.board.removeOrbByIndex(n); @@ -1623,7 +1386,7 @@ class Game { } if (wantRefresh) { - if (this.countOrbs() === 0) { + if (this.board.countOrbs() === 0) { this.info("Good work!"); if (removed) { @@ -1700,7 +1463,7 @@ class Game { * Update button hiding attributes, disabled orb effects, etc */ updateGameGui() { - let nOrbs = this.countOrbs(); + let nOrbs = this.board.countOrbs(); this.board.buttons.restart .classList.toggle('disabled', nOrbs === 55); @@ -1773,11 +1536,7 @@ class Game { break; } catch (e) { retry_count++; - if (this.logging_trace) { - this.error(e); - } else { - this.warn(e.message); - } + this.warn(e.message); } } @@ -1792,10 +1551,7 @@ class Game { if (!suc) { alert(`Sorry, could not find a valid board setup after ${retry_count} retries.`); } else { - this.info( - `Board set up with ${retry_count} retries:\n` + - `teplate "${board_info.template.name}"` + - (this.cfg.allowTemplateAugmenting ? ` with ${board_info.tilesAdded} extra tiles` : '')); + this.info(`Board set up with ${retry_count} retries.`); if (this.cfg.logSolution) { this.info('Reference solution:\n ' + board_info.solution.reduce((s, entry, i) => { @@ -1816,6 +1572,334 @@ class Game { } } +class BaseOrbPlacer { + constructor(game, template) { + this.template = template; + this.rng = game.rng; + this.cfg = game.cfg; + this.game = game; + this.board = game.board; + } + + placeOrb(n, symbol) { + if (!this.templateMap[n]) { + throw Error(`Position ${n} not allowed by template`); + } + if (this.board.grid[n]) { + throw Error(`Position ${n} is occupied by ${this.board.grid[n]}`); + } + this.trace(`Place ${n} <- ${symbol}`); + this.board.grid[n] = symbol; + } + + removeOrb(n) { + let old = this.board.grid[n]; + this.board.grid[n] = null; + this.trace(`Unplace ${n} (${old})`); + return old; + } + + isAvailable(n) { + return this.board.isAvailable(n); + } + + getCellInfo(n) { + return this.board.getCellInfo(n); + } + + trace(...args) { + this.game.debug(...args); + } + + debug(...args) { + this.game.debug(...args); + } + + info(...args) { + this.game.info(...args); + } + + warn(...args) { + this.game.warn(...args); + } + + error(...args) { + this.game.error(...args); + } + + buildPlacementList() { + let toPlace = [ + ['air', 'air'], + ['air', 'air'], + ['air', 'air'], + ['air', 'air'], + + ['fire', 'fire'], + ['fire', 'fire'], + ['fire', 'fire'], + ['fire', 'fire'], + + ['water', 'water'], + ['water', 'water'], + ['water', 'water'], + ['water', 'water'], + + ['earth', 'earth'], + ['earth', 'earth'], + ['earth', 'earth'], + ['earth', 'earth'], + ]; + + let newSaltedPairs = []; + const nsalted = this.rng.nextInt(2); + for (let i = 0; i < nsalted; i++) { + while (true) { + const n = this.rng.nextInt(toPlace.length - 1); + if (toPlace[n][1] !== 'salt') { + this.trace(`Pairing ${toPlace[n][1]} with salt.`); + newSaltedPairs.push([toPlace[n][1], 'salt']); + toPlace[n][1] = 'salt'; + break; + } + } + } + toPlace = toPlace.concat(newSaltedPairs); + // if we have some salt pairs left + for (let i = 0; i < 2 - nsalted; i++) { + toPlace.push(['salt', 'salt']); + } + + // these are always paired like this, and don't support salt + toPlace = toPlace.concat([ + ['mors', 'vitae'], + ['mors', 'vitae'], + ['mors', 'vitae'], + ['mors', 'vitae'], + ]); + + // shuffle the pairs that have random order (i.e. not metals) + this.rng.arrayShuffle(toPlace); + + // the order here is actually significant, so let's pay attention... + const metals = [ + ['lead', 'mercury'], + ['tin', 'mercury'], + ['iron', 'mercury'], + ['copper', 'mercury'], + ['silver', 'mercury'], + ]; + let mPos = []; + for (let i = 0; i < metals.length; i++) { + let x; + // find a unique position + do { + x = this.rng.nextInt(toPlace.length + i); + } while (mPos.includes(x)); + mPos.push(x) + } + mPos.sort((a, b) => a - b); + this.trace('Metal positions ', mPos); + // inject them into the array + metals.forEach((pair, i) => { + toPlace.splice(mPos[i] + i, 0, pair); + }); + + this.debug('Placement order (last first):', toPlace); + + return toPlace.reduce((a, c) => { + a.push(c[0]); + a.push(c[1]); + return a; + }, []); + } + + + place() { + this.board.removeAllOrbs(); + this.solution = []; + + let allowedTable = []; + let outsideTemplate = []; + for (let i = 0; i < BOARD_SIZE; i++) { + const allo = this.template.includes(i); + allowedTable.push(allo); + + let {x, y} = gridIndexToXy(i); + if (!allo && !isXyOutside(x, y)) { + outsideTemplate.push(i); + } + + // Highlight pattern shape + + if (this.cfg.highlightTemplate) { + if (this.board.tiles[i]) { + if (allo) { + this.board.tiles[i].setAttribute('opacity', 1) + } else { + this.board.tiles[i].setAttribute('opacity', 0.6) + } + } + } + } + this.templateMap = allowedTable; + this.outsideTemplate = outsideTemplate; + + this.placeOrb(60, 'gold'); + this.solution.push(['gold', 60]); + + let rv = this.doPlace(); + + let solution = this.solution; + + solution.reverse(); + this.info("Found a valid board!"); + + solution.forEach((a) => { + let p = gridIndexToXy(a[1]); + a[1] = `${p.x}×${p.y}`; + }); + + this.debug('Solution:', solution); + + rv.solution = solution; + return rv; + } + + doPlace() { + throw new Error("Not implemented"); + } +} + +class RadialOrbPlacer extends BaseOrbPlacer { + findBestCandidate(except = null) { + let candidates = []; + for (let n of this.template) { + if (except && except.includes(n)) continue; + + if (!this.board.grid[n]) { + // is free + const neigh = this.getCellInfo(n); + if (neigh.freeSequence >= 3) { + candidates.push({ + n, + cw: neigh.centerWeight + }); + } + } + } + + candidates.sort((a, b) => a.cw - b.cw); + + if (candidates.length) { + // return candidates[0].n; + let top = []; + let topw = candidates[0].cw; + for(let cand of candidates) { + if (cand.cw <= topw + this.cfg.difficulty) { // TODO this could vary by template + top.push(cand); + } + } + + //the neighbor count is not used for anything anymore, oops + + // console.log('Got a choice of '+top.length+' tiles'); + + return this.rng.arrayChoose(top).n; + } + + return false; + } + + findAvailableIndex(except = null) { + const n = this.findBestCandidate(except); + if (n !== false) return n; + + // this corrupts the template, but makes the likelihood of quickly finding a valid solution much higher. + if (this.template.length !== BOARD_SIZE && this.cfg.allowTemplateAugmenting) { + // Prefer tile with more neighbours to make the game harder + let candidates = []; + this.outsideTemplate.forEach((n) => { + if (!this.templateMap[n] && this.isAvailable(n)) { + const neigh = this.getCellInfo(n); + if (!candidates[neigh.neighbours]) { + candidates[neigh.neighbours] = [n]; + } else { + candidates[neigh.neighbours].push(n); + } + } + }); + + for (let i = 6; i >= 0; i--) { + if (!candidates[i]) continue; + let toAdd = this.rng.arrayChoose(candidates[i]); + this.templateMap[toAdd] = true; + this.outsideTemplate.splice(this.outsideTemplate.indexOf(toAdd), 1); + + this.template.push(toAdd); + this.tilesAdded++; + + this.warn(`Adding extra tile to template: ${toAdd}`); + return toAdd; + } + } + + throw Error("Failed to find available board tile."); + } + + doPlace() { + this.tilesAdded = 0; + const toPlace = this.buildPlacementList(); + + while (toPlace.length > 0) { + this.trace('placing a pair.'); + + let symbol1 = toPlace.pop(); + let index1 = this.findAvailableIndex(); + this.placeOrb(index1, symbol1); + this.solution.push([symbol1, index1]); + + let symbol2 = toPlace.pop(); + let index2 = this.findAvailableIndex(); + this.placeOrb(index2, symbol2); + + if (!this.isAvailable(index1)) { + this.debug(`Deadlock, trying to work around it - ${index1}, ${index2}`); + + this.removeOrb(index2); + let except = [index2]; + + let suc = false; + for (let i = 0; i < 5; i++) { + this.trace(`try #${i + 1}`); + let index = this.findAvailableIndex(except); + this.placeOrb(index, symbol2); + + if (this.isAvailable(index1)) { + suc = true; + index2 = index; + break; + } else { + this.removeOrb(index); + except.push(index); + } + } + + if (!suc) { + throw new Error("Solution contains a deadlock."); + } + } + + // index2 is updated in the fixing loop + this.solution.push([symbol2, index2]); + } + + return { + tilesAdded: this.tilesAdded, + }; + } +} + + /* Start */ window.game = new Game();