From f80d26e7333abd8686fc3898f5ce070b17e93db4 Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Tue, 13 Feb 2024 18:31:26 -0600 Subject: [PATCH 1/7] Fix bug in beam state related to portals When there are multiple options for an exit portal, the user must decide, and the decision gets stored in beam state. That state was only being cleared when the beam was turned off, but it should be cleared whenever the step that decision is related to changes (for example, if the entry portal is rotated). This also cleans up the storage of these decisions in beam state, moving them into a portal specific key that the portal item will maintain. --- src/components/items/beam.js | 2 -- src/components/items/portal.js | 34 +++++++++++++++++++++------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/components/items/beam.js b/src/components/items/beam.js index 38b1ae9..df022d6 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -348,8 +348,6 @@ export class Beam extends Item { if (!this.isOn()) { if (this.#steps.length) { console.debug(this.toString(), 'beam has been toggled off') - // Also reset any state changes from user move decisions - this.updateState((state) => { delete state.moves }) this.remove() } return diff --git a/src/components/items/portal.js b/src/components/items/portal.js index e41e759..f347d02 100644 --- a/src/components/items/portal.js +++ b/src/components/items/portal.js @@ -103,7 +103,10 @@ export class Portal extends movable(rotatable(Item)) { // Check for destination in beam state (matches on item ID and step index) const stateId = [this.id, nextStep.index].join(':') - const destinationId = beam.getState().moves?.[stateId] + const destinationId = beam.getState().portal?.[stateId]?.destinationId + if (destinationId !== undefined) { + console.debug(this.toString(), `matching on destinationId from state: ${destinationId}`) + } // Find all valid destination portals const destinations = puzzle.getItems().filter((item) => @@ -130,7 +133,7 @@ export class Portal extends movable(rotatable(Item)) { if (destinations.length === 1) { // A single matching destination - return this.#getStep(beam, destinations[0], nextStep, portalState) + return this.#getStep(beam, destinations[0], nextStep, portalState, stateId) } else { // Multiple matching destinations. User will need to pick one manually. const destinationTiles = destinations.map((portal) => portal.parent) @@ -142,14 +145,7 @@ export class Portal extends movable(rotatable(Item)) { onTap: (puzzle, tile) => { const destination = destinations.find((portal) => portal.parent === tile) if (destination) { - beam.addStep(this.#getStep(beam, destination, nextStep, portalState)) - beam.updateState((state) => { - if (!state.moves) { - state.moves = {} - } - // Store this decision in beam state - state.moves[stateId] = destination.id - }) + beam.addStep(this.#getStep(beam, destination, nextStep, portalState, stateId)) puzzle.unmask() } }, @@ -173,15 +169,27 @@ export class Portal extends movable(rotatable(Item)) { this.#directions[direction] = data } - #getStep (beam, portal, nextStep, portalState) { + #getStep (beam, portal, nextStep, portalState, stateId) { const direction = Portal.getExitDirection(nextStep, portalState.entryPortal, portal) const stepIndex = nextStep.index return nextStep.copy({ connected: false, direction, insertAbove: portal, - onAdd: () => portal.update(direction, { stepIndex }), - onRemove: () => portal.update(direction), + onAdd: () => { + portal.update(direction, { stepIndex }) + beam.updateState((state) => { + if (!state.portal) { + state.portal = {} + } + // Store this decision in beam state + state.portal[stateId] = { destinationId: portal.id } + }) + }, + onRemove: () => { + beam.updateState((state) => { delete state.portal[stateId] }) + portal.update(direction) + }, point: portal.parent.center, state: nextStep.state.copy(new StepState.Portal(portalState.entryPortal, portal)), tile: portal.parent From 90158d5ae42b76f7c6f924ed30b1bfa23d426ccb Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Tue, 13 Feb 2024 20:22:42 -0600 Subject: [PATCH 2/7] Add layout for puzzle 012 --- src/components/items/beam.js | 2 + src/puzzles/012.json | 93 ++++++++++++++++++++++++++++++++++++ src/puzzles/index.js | 1 + src/puzzles/test/layout.js | 14 +++--- 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 src/puzzles/012.json diff --git a/src/components/items/beam.js b/src/components/items/beam.js index df022d6..52291f2 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -556,6 +556,8 @@ export class Beam extends Item { updateStep (stepIndex, settings) { const step = this.getStep(stepIndex) if (step) { + // Update is essentially: remove, update, add + step.onRemove() const updatedStep = this.#getUpdatedStep(step, settings) this.#steps[stepIndex] = updatedStep updatedStep.onAdd(updatedStep) diff --git a/src/puzzles/012.json b/src/puzzles/012.json new file mode 100644 index 0000000..bf02490 --- /dev/null +++ b/src/puzzles/012.json @@ -0,0 +1,93 @@ +{ + "layout": { + "tiles": [ + [ + null, + null, + { + "type": "Tile" + }, + { + "type": "Tile" + } + ], + [ + null, + null, + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + } + ], + [ + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + } + ], + [ + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + }, + null, + { + "type": "Tile" + }, + { + "type": "Tile" + }, + { + "type": "Tile" + } + ], + [ + { + "type": "Tile" + }, + { + "type": "Tile" + }, + null, + null, + { + "type": "Tile" + }, + { + "type": "Tile" + } + ] + ], + "type": "even-r" + }, + "solution": [ + { + "amount": 1, + "type": "Connections" + } + ] +} diff --git a/src/puzzles/index.js b/src/puzzles/index.js index 34167de..fd47a8c 100644 --- a/src/puzzles/index.js +++ b/src/puzzles/index.js @@ -10,6 +10,7 @@ const puzzles = { '009': require('./009.json'), '010': require('./010.json'), '011': require('./011.json'), + '012': require('./012.json'), test_infinite_loop: require('./test/infiniteLoop.json'), test_layout: require('./test/layout'), test_portal: require('./test/portal.json'), diff --git a/src/puzzles/test/layout.js b/src/puzzles/test/layout.js index 99f2f13..1ca6cb9 100644 --- a/src/puzzles/test/layout.js +++ b/src/puzzles/test/layout.js @@ -1,16 +1,16 @@ // TODO: this file can be removed when the puzzle editor is created const layout = [ - ['o', 'x', 'x', 'x'], - ['x', 'x', 'x', 'x'], - ['x', 'x', 'x', 'x', 'x'], - ['x', 'x', 'x', 'x'], - ['o', 'x', 'x', 'x'] + ['o', 'o', 'x', 'x'], + ['o', 'o', 'x', 'x', 'x'], + ['x', 'x', 'x', 'x', 'x', 'x'], + ['x', 'x', 'x', 'o', 'x', 'x', 'x'], + ['x', 'x', 'o', 'o', 'x', 'x'] ] export default { layout: { - tiles: layout.map((column) => column.map((item) => item === 'x' ? { type: 'Tile' } : null)) - // type: 'even-r' + tiles: layout.map((column) => column.map((item) => item === 'x' ? { type: 'Tile' } : null)), + type: 'even-r' }, solution: [ { amount: 100, type: 'Connections' } From d88de1850e1cb4cef048d2eddf8c34e28d27144c Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Wed, 14 Feb 2024 19:33:25 -0600 Subject: [PATCH 3/7] WIP: puzzle 012 After adding the ability to 'merge beams' using a single exit portal, I've noticed it introduces some unintended complexity such as allowing a single beam to merge into itself over and over. Perahaps instead of adding more complexity to check the beam to make sure it isn't self, this functionality should instead be split into a separate item which allows for the merging/splitting of beams, and portals should retain their much simpler 'only one beam can exist in a given direction at a time' rule. --- src/components/item.js | 7 +- src/components/itemFactory.js | 18 ++-- src/components/items/beam.js | 4 +- src/components/items/portal.js | 96 +++++++++--------- src/components/modifiers/move.js | 12 ++- src/puzzles/012.json | 168 +++++++++++++++++++++++++++++-- 6 files changed, 236 insertions(+), 69 deletions(-) diff --git a/src/components/item.js b/src/components/item.js index 5dc4f87..daf396a 100644 --- a/src/components/item.js +++ b/src/components/item.js @@ -16,7 +16,12 @@ export class Item extends Stateful { constructor (parent, state, configuration) { super(state) - this.type = state?.type || configuration?.type + this.type = state?.type ?? configuration?.type + if (this.type === undefined) { + console.debug(`[Item:${this.id}]`, state) + throw new Error('Item must have type defined') + } + this.data = Object.assign({ id: this.id, type: this.type }, configuration?.data || {}) this.locked = configuration?.locked !== false diff --git a/src/components/itemFactory.js b/src/components/itemFactory.js index 0a1afa0..0d4c6c0 100644 --- a/src/components/itemFactory.js +++ b/src/components/itemFactory.js @@ -5,28 +5,28 @@ import { Reflector } from './items/reflector' import { Wall } from './items/wall' import { Item } from './item' -export function itemFactory (parent, configuration) { +export function itemFactory (parent, state, configuration) { let item - switch (configuration.type) { + switch (state.type) { case Item.Types.filter: - item = new Filter(parent, configuration) + item = new Filter(...arguments) break case Item.Types.portal: - item = new Portal(parent, configuration) + item = new Portal(...arguments) break case Item.Types.terminus: - item = new Terminus(parent, configuration) + item = new Terminus(...arguments) break case Item.Types.reflector: - item = new Reflector(parent, configuration) + item = new Reflector(...arguments) break case Item.Types.wall: - item = new Wall(parent, configuration) + item = new Wall(...arguments) break default: - console.error('Ignoring item with unknown type:', configuration.type) - break + console.debug('itemFactory', state) + throw new Error(`Cannot create item with unknown type: ${state.type}`) } if (item) { diff --git a/src/components/items/beam.js b/src/components/items/beam.js index 52291f2..8f589fc 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -348,6 +348,8 @@ export class Beam extends Item { if (!this.isOn()) { if (this.#steps.length) { console.debug(this.toString(), 'beam has been toggled off') + // Delete any steps-related state. No delta necessary for this as it will be covered by toggle. + this.updateState((state) => { state.steps = {} }, false) this.remove() } return @@ -557,7 +559,7 @@ export class Beam extends Item { const step = this.getStep(stepIndex) if (step) { // Update is essentially: remove, update, add - step.onRemove() + step.onRemove(step) const updatedStep = this.#getUpdatedStep(step, settings) this.#steps[stepIndex] = updatedStep updatedStep.onAdd(updatedStep) diff --git a/src/components/items/portal.js b/src/components/items/portal.js index f347d02..9ae339d 100644 --- a/src/components/items/portal.js +++ b/src/components/items/portal.js @@ -76,10 +76,9 @@ export class Portal extends movable(rotatable(Item)) { onCollision ({ beam, currentStep, nextStep, puzzle }) { const portalState = currentStep.state.get(StepState.Portal) if (!portalState) { - const stepIndex = nextStep.index const entryDirection = getOppositeDirection(nextStep.direction) - const existing = coalesce(this.get(entryDirection), { stepIndex }) - if (existing.stepIndex < stepIndex) { + const existing = coalesce(this.get(entryDirection), nextStep) + if (existing.index < nextStep.index) { // Checking stepIndex to exclude cases where we are doing a re-evaluation of history. console.debug( this.toString(), @@ -92,7 +91,7 @@ export class Portal extends movable(rotatable(Item)) { // Handle entry collision return nextStep.copy({ insertAbove: this, - onAdd: () => this.update(entryDirection, { stepIndex }), + onAdd: (step) => this.update(entryDirection, step), onRemove: () => this.update(entryDirection), state: nextStep.state.copy(new StepState.Portal(this)) }) @@ -101,51 +100,54 @@ export class Portal extends movable(rotatable(Item)) { return nextStep.copy({ insertAbove: this }) } - // Check for destination in beam state (matches on item ID and step index) - const stateId = [this.id, nextStep.index].join(':') - const destinationId = beam.getState().portal?.[stateId]?.destinationId - if (destinationId !== undefined) { - console.debug(this.toString(), `matching on destinationId from state: ${destinationId}`) - } - - // Find all valid destination portals - const destinations = puzzle.getItems().filter((item) => - item.type === Item.Types.portal && - !item.equals(this) && - // Portal must not already have a beam occupying the desired direction - !item.get(Portal.getExitDirection(nextStep, portalState.entryPortal, item)) && - (destinationId === undefined || item.id === destinationId) && - ( + // Find all valid exit portals + const validExitPortals = puzzle.getItems().filter((item) => + item.type === Item.Types.portal && !item.equals(this) && ( // Entry portals without defined direction can exit from any other portal. this.getDirection() === undefined || // Exit portals without defined direction can be used by any entry portal. item.getDirection() === undefined || // Exit portals with a defined direction can only be used by entry portals with the same defined direction. item.getDirection() === this.getDirection() - ) + ) && + // Don't allow the user to select an exit portal that already has a beam entering at the same direction. + // FIXME: this is currently allowing a beam to merge into itself infinite times. + // Maybe just simplify portals to now allow merging of beams, and move that functionality into a separate item. + !item.get(Portal.getExitDirection(nextStep, this, item))?.state.get(StepState.Portal)?.entryPortal?.equals(item) ) - if (destinations.length === 0) { - console.debug(this.toString(), 'no valid destinations found') + if (validExitPortals.length === 0) { + console.debug(this.toString(), 'no valid exit portals found') // This will cause the beam to stop return currentStep } - if (destinations.length === 1) { + // Check for existing exitPortalId in beam state for this step + const exitPortalId = beam.getState().steps?.[nextStep.index]?.portal?.[this.id] + if (exitPortalId !== undefined) { + console.debug(this.toString(), `matching on exitPortalId from beam step state: ${exitPortalId}`) + } + + const exitPortal = validExitPortals.length === 1 + ? validExitPortals[0] + : validExitPortals.find((item) => item.id === exitPortalId) + + console.log(validExitPortals) + if (exitPortal) { // A single matching destination - return this.#getStep(beam, destinations[0], nextStep, portalState, stateId) + return this.#getStep(beam, nextStep, exitPortal) } else { // Multiple matching destinations. User will need to pick one manually. - const destinationTiles = destinations.map((portal) => portal.parent) + const destinationTiles = validExitPortals.map((portal) => portal.parent) const mask = new Puzzle.Mask( { beam, - id: stateId, + id: this.id, onMask: () => currentStep.tile.beforeModify(), onTap: (puzzle, tile) => { - const destination = destinations.find((portal) => portal.parent === tile) - if (destination) { - beam.addStep(this.#getStep(beam, destination, nextStep, portalState, stateId)) + const exitPortal = validExitPortals.find((portal) => portal.parent === tile) + if (exitPortal) { + beam.addStep(this.#getStep(beam, nextStep, exitPortal)) puzzle.unmask() } }, @@ -157,7 +159,7 @@ export class Portal extends movable(rotatable(Item)) { } ) - puzzle.updateSelectedTile(currentStep.tile) + puzzle.updateSelectedTile(null) puzzle.mask(mask) // This will cause the beam to stop @@ -169,30 +171,26 @@ export class Portal extends movable(rotatable(Item)) { this.#directions[direction] = data } - #getStep (beam, portal, nextStep, portalState, stateId) { - const direction = Portal.getExitDirection(nextStep, portalState.entryPortal, portal) - const stepIndex = nextStep.index + #getStep (beam, nextStep, exitPortal) { + const direction = Portal.getExitDirection(nextStep, this, exitPortal) return nextStep.copy({ connected: false, direction, - insertAbove: portal, - onAdd: () => { - portal.update(direction, { stepIndex }) - beam.updateState((state) => { - if (!state.portal) { - state.portal = {} - } - // Store this decision in beam state - state.portal[stateId] = { destinationId: portal.id } - }) + insertAbove: exitPortal, + onAdd: (step) => { + exitPortal.update(direction, step) + // Store this decision in beam state and generate a matching delta + beam.updateState((state) => (((state.steps ??= {})[step.index] ??= {}).portal = { [this.id]: exitPortal.id })) }, - onRemove: () => { - beam.updateState((state) => { delete state.portal[stateId] }) - portal.update(direction) + onRemove: (step) => { + // Remove any associated beam state, but don't generate a delta. + // If the step is being removed, a delta for that action was most likely created elsewhere already. + beam.updateState((state) => { delete state.steps[step.index].portal }, false) + exitPortal.update(direction) }, - point: portal.parent.center, - state: nextStep.state.copy(new StepState.Portal(portalState.entryPortal, portal)), - tile: portal.parent + point: exitPortal.parent.center, + state: nextStep.state.copy(new StepState.Portal(this, exitPortal)), + tile: exitPortal.parent }) } diff --git a/src/components/modifiers/move.js b/src/components/modifiers/move.js index de5e75e..5e8547c 100644 --- a/src/components/modifiers/move.js +++ b/src/components/modifiers/move.js @@ -45,9 +45,13 @@ export class Move extends Modifier { } tileFilter (tile) { - // Filter out immutable tiles and tiles with items, except for the current tile - return tile.modifiers.some(Modifier.immutable) || - (tile.items.filter((item) => item.type !== Item.Types.beam).length > 0 && !(tile === this.tile)) + // Never mask current tile + return !tile.equals(this.tile) && ( + // Mask immutable tiles + tile.modifiers.some(Modifier.immutable) || + // Mask tiles that contain any items we don't ignore + tile.items.some((item) => !Move.ignoreItemTypes.includes(item.type)) + ) } #maskOnTap (puzzle, tile) { @@ -73,6 +77,8 @@ export class Move extends Modifier { static movable (item) { return item.movable } + + static ignoreItemTypes = [Item.Types.beam, Item.Types.wall] } /** diff --git a/src/puzzles/012.json b/src/puzzles/012.json index bf02490..a145d7c 100644 --- a/src/puzzles/012.json +++ b/src/puzzles/012.json @@ -5,9 +5,21 @@ null, null, { + "items": [ + { + "direction": 3, + "type": "Portal" + } + ], "type": "Tile" }, { + "items": [ + { + "direction": 4, + "type": "Portal" + } + ], "type": "Tile" } ], @@ -15,32 +27,117 @@ null, null, { - "type": "Tile" - }, - { - "type": "Tile" - }, - { + "items": [ + { + "direction": 2, + "type": "Portal" + }, + { + "directions": [4], + "rotatable": false, + "type": "Wall" + } + ], + "type": "Tile" + }, + { + "items": [ + { + "color": ["blue", "red"], + "openings": [ + { + "type": "Beam" + }, + { + "type": "Beam" + }, + { + "type": "Beam" + }, + { + "type": "Beam" + }, + { + "type": "Beam" + }, + { + "type": "Beam" + } + ], + "type": "Terminus" + } + ], + "type": "Tile" + }, + { + "items": [ + { + "direction": 5, + "type": "Portal" + }, + { + "directions": [3], + "rotatable": false, + "type": "Wall" + } + ], "type": "Tile" } ], [ { + "items": [ + { + "type": "Portal" + } + ], "type": "Tile" }, { "type": "Tile" }, { + "items": [ + { + "direction": 1, + "type": "Portal" + }, + { + "directions": [3, 4, 5], + "rotatable": false, + "type": "Wall" + } + ], "type": "Tile" }, { + "items": [ + { + "direction": 0, + "type": "Portal" + }, + { + "directions": [2, 3, 4], + "rotatable": false, + "type": "Wall" + } + ], "type": "Tile" }, { "type": "Tile" }, { + "items": [ + { + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Move" + } + ], "type": "Tile" } ], @@ -49,6 +146,17 @@ "type": "Tile" }, { + "items": [ + { + "rotation": 2, + "type": "Reflector" + } + ], + "modifiers": [ + { + "type": "Rotate" + } + ], "type": "Tile" }, { @@ -70,11 +178,59 @@ "type": "Tile" }, { + "items": [ + { + "openings": [ + { + "color": "blue", + "type": "Beam" + }, + null, + null, + null, + null, + null + ], + "type": "Terminus" + } + ], + "modifiers": [ + { + "type": "Lock" + }, + { + "type": "Toggle" + } + ], "type": "Tile" }, null, null, { + "items": [ + { + "openings": [ + null, + { + "color": "red", + "type": "Beam" + }, + null, + null, + null, + null + ], + "type": "Terminus" + } + ], + "modifiers": [ + { + "type": "Lock" + }, + { + "type": "Toggle" + } + ], "type": "Tile" }, { From d80929e1452bfb2283b27567e2352cc8646b4711 Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Thu, 15 Feb 2024 14:14:08 -0600 Subject: [PATCH 4/7] Various fixes related to portals Items can now specify 'immutable' in the state configuration, which should basically act the same as setting each individual modifier key to false (e.g. movable, toggleable, rotatable, etc). Beam and wall items will have immutable set by default. Item IDs are now retained in cache, making them stable IDs. Previously, item IDs were re-generated when the page was refreshed, which meant that if an item had been moved its ID could change. The generation and storage of item IDs on the loading of a puzzle is not stored as a delta, so it won't show up as a move and won't be undo-able. Portal logic has been reverted back to just checking if a direction is already occupied. This means beams cannot be merged through an exit portal. That functionality will be added later as a separate item. Additionally, portal directions cache is now cleared if a portal is moved, since the beams connected to that portal will need to re-evaluate their histories. Swap now requires tiles to contain movable items, instead of any items. --- src/components/item.js | 12 ++++++- src/components/items/beam.js | 5 +-- src/components/items/portal.js | 41 ++++++++++++++---------- src/components/items/wall.js | 4 +++ src/components/modifier.js | 19 +++++++---- src/components/modifiers/move.js | 7 ++-- src/components/modifiers/rotate.js | 10 +++--- src/components/modifiers/swap.js | 10 ++++-- src/components/modifiers/toggle.js | 2 +- src/components/puzzle.js | 13 +++++--- src/components/state.js | 15 +++++---- src/puzzles/012.json | 51 +++++++++++++++++++----------- 12 files changed, 124 insertions(+), 65 deletions(-) diff --git a/src/components/item.js b/src/components/item.js index daf396a..01e891f 100644 --- a/src/components/item.js +++ b/src/components/item.js @@ -6,7 +6,8 @@ export class Item extends Stateful { center data group - id = Item.uniqueId++ + id + immutable // Whether the item can be clicked on locked parent @@ -14,8 +15,13 @@ export class Item extends Stateful { type constructor (parent, state, configuration) { + // Retain ID from state if it exists, otherwise generate a new one + state.id ??= Item.uniqueId++ + super(state) + this.id = state.id + this.immutable ??= state?.immutable ?? false this.type = state?.type ?? configuration?.type if (this.type === undefined) { console.debug(`[Item:${this.id}]`, state) @@ -80,6 +86,10 @@ export class Item extends Stateful { update () {} + static immutable (item) { + return item.immutable + } + static Types = Object.freeze(Object.fromEntries([ 'beam', 'collision', diff --git a/src/components/items/beam.js b/src/components/items/beam.js index 8f589fc..e549dd0 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -26,6 +26,9 @@ export class Beam extends Item { #steps = [] constructor (terminus, state, configuration) { + // Exclude from modification + state.immutable = true + super(...arguments) this.group = null @@ -348,8 +351,6 @@ export class Beam extends Item { if (!this.isOn()) { if (this.#steps.length) { console.debug(this.toString(), 'beam has been toggled off') - // Delete any steps-related state. No delta necessary for this as it will be covered by toggle. - this.updateState((state) => { state.steps = {} }, false) this.remove() } return diff --git a/src/components/items/portal.js b/src/components/items/portal.js index 9ae339d..4a15907 100644 --- a/src/components/items/portal.js +++ b/src/components/items/portal.js @@ -44,7 +44,7 @@ export class Portal extends movable(rotatable(Item)) { children.push(ring) - if (this.rotatable) { + if (this.direction !== undefined) { const pointer = new Path({ closed: true, opacity: 0.25, @@ -63,7 +63,7 @@ export class Portal extends movable(rotatable(Item)) { this.group.addChildren(children) - if (this.rotatable) { + if (this.direction !== undefined) { // Properly align items with hexagonal rotation this.rotateGroup(1) } @@ -102,18 +102,19 @@ export class Portal extends movable(rotatable(Item)) { // Find all valid exit portals const validExitPortals = puzzle.getItems().filter((item) => - item.type === Item.Types.portal && !item.equals(this) && ( + // Is a portal + item.type === Item.Types.portal && + // But not the entry portal + !item.equals(this) && + // There is no other beam occupying the portal at the exit direction + !item.get(Portal.getExitDirection(nextStep, this, item)) && ( // Entry portals without defined direction can exit from any other portal. this.getDirection() === undefined || // Exit portals without defined direction can be used by any entry portal. item.getDirection() === undefined || // Exit portals with a defined direction can only be used by entry portals with the same defined direction. item.getDirection() === this.getDirection() - ) && - // Don't allow the user to select an exit portal that already has a beam entering at the same direction. - // FIXME: this is currently allowing a beam to merge into itself infinite times. - // Maybe just simplify portals to now allow merging of beams, and move that functionality into a separate item. - !item.get(Portal.getExitDirection(nextStep, this, item))?.state.get(StepState.Portal)?.entryPortal?.equals(item) + ) ) if (validExitPortals.length === 0) { @@ -123,22 +124,23 @@ export class Portal extends movable(rotatable(Item)) { } // Check for existing exitPortalId in beam state for this step - const exitPortalId = beam.getState().steps?.[nextStep.index]?.portal?.[this.id] + const exitPortalId = beam.getState().steps?.[nextStep.index]?.[this.id] if (exitPortalId !== undefined) { - console.debug(this.toString(), `matching on exitPortalId from beam step state: ${exitPortalId}`) + console.debug(this.toString(), `found exitPortalId ${exitPortalId} in beam step ${nextStep.index} state`) } const exitPortal = validExitPortals.length === 1 ? validExitPortals[0] : validExitPortals.find((item) => item.id === exitPortalId) - console.log(validExitPortals) if (exitPortal) { + console.debug(this.toString(), 'exit portal:', exitPortal) // A single matching destination return this.#getStep(beam, nextStep, exitPortal) } else { + console.debug(this.toString(), 'found multiple valid exit portals:', validExitPortals) // Multiple matching destinations. User will need to pick one manually. - const destinationTiles = validExitPortals.map((portal) => portal.parent) + const validTiles = validExitPortals.map((portal) => portal.parent) const mask = new Puzzle.Mask( { beam, @@ -153,8 +155,8 @@ export class Portal extends movable(rotatable(Item)) { }, onUnmask: () => currentStep.tile.afterModify(), tileFilter: (tile) => { - // Include the portal tile and tiles which contain a matching destination - return !(this.parent === tile || destinationTiles.some((destinationTile) => destinationTile === tile)) + // Mask any invalid tiles. Exclude the entry portal tile + return !(tile.equals(this.parent) || validTiles.some((validTile) => validTile.equals(tile))) } } ) @@ -167,6 +169,13 @@ export class Portal extends movable(rotatable(Item)) { } } + onMove () { + super.onMove() + + // Invalidate directions cache + this.#directions = {} + } + update (direction, data) { this.#directions[direction] = data } @@ -180,12 +189,12 @@ export class Portal extends movable(rotatable(Item)) { onAdd: (step) => { exitPortal.update(direction, step) // Store this decision in beam state and generate a matching delta - beam.updateState((state) => (((state.steps ??= {})[step.index] ??= {}).portal = { [this.id]: exitPortal.id })) + beam.updateState((state) => ((state.steps ??= {})[step.index] = { [this.id]: exitPortal.id })) }, onRemove: (step) => { // Remove any associated beam state, but don't generate a delta. // If the step is being removed, a delta for that action was most likely created elsewhere already. - beam.updateState((state) => { delete state.steps[step.index].portal }, false) + beam.updateState((state) => { delete state.steps[step.index] }, false) exitPortal.update(direction) }, point: exitPortal.parent.center, diff --git a/src/components/items/wall.js b/src/components/items/wall.js index 2ff82a4..3b0f30a 100644 --- a/src/components/items/wall.js +++ b/src/components/items/wall.js @@ -8,7 +8,11 @@ export class Wall extends movable(rotatable(Item)) { sortOrder = 1 constructor (tile, state) { + // Exclude from modification by default + state.immutable ??= true + super(tile, state, { rotationDegrees: 60 }) + const walls = Wall.item(tile, state) this.group.addChildren(walls) } diff --git a/src/components/modifier.js b/src/components/modifier.js index e390c1c..ca47d5e 100644 --- a/src/components/modifier.js +++ b/src/components/modifier.js @@ -3,6 +3,7 @@ import { Puzzle } from './puzzle' import { Stateful } from './stateful' import { EventListeners } from './eventListeners' import { Interact } from './interact' +import { Item } from './item' const modifiersImmutable = document.getElementById('modifiers-immutable') const modifiersMutable = document.getElementById('modifiers-mutable') @@ -99,8 +100,10 @@ export class Modifier extends Stateful { } moveFilter (tile) { - // Filter out immutable tiles - return tile.modifiers.some((modifier) => modifier.type === Modifier.Types.immutable) + // Mask immutable tiles + return tile.modifiers.some(Modifier.immutable) || + // Mask tiles that only contain immutable items + tile.items.every(Item.immutable) } onDeselected () { @@ -115,10 +118,8 @@ export class Modifier extends Stateful { this.onToggle(event) } else { this.#down = true - if ( - !this.#mask && - !this.tile.modifiers.some((modifier) => [Modifier.Types.immutable, Modifier.Types.lock].includes(modifier.type)) - ) { + if (!this.#mask && !this.tile.modifiers.some(Modifier.immovable)) { + // No active mask and modifiers are not immovable this.#timeoutId = setTimeout(this.onSelected.bind(this), this.#selectionTime) } } @@ -233,6 +234,10 @@ export class Modifier extends Stateful { } } + static immovable (modifier) { + return Modifier.immovableTypes.includes(modifier.type) + } + static immutable (modifier) { return modifier.type === Modifier.Types.immutable } @@ -251,4 +256,6 @@ export class Modifier extends Stateful { 'swap', 'toggle' ].map((type) => [type, capitalize(type)]))) + + static immovableTypes = [Modifier.Types.immutable, Modifier.Types.lock] } diff --git a/src/components/modifiers/move.js b/src/components/modifiers/move.js index 5e8547c..9f63cd8 100644 --- a/src/components/modifiers/move.js +++ b/src/components/modifiers/move.js @@ -32,7 +32,7 @@ export class Move extends Modifier { moveFilter (tile) { // Filter out tiles that contain no movable items - return super.moveFilter(tile) || !tile.items.some((item) => item.movable) + return super.moveFilter(tile) || !tile.items.some(Move.movable) } moveItems (tile) { @@ -89,10 +89,9 @@ export class Move extends Modifier { export const movable = (SuperClass) => class MovableItem extends SuperClass { movable - constructor (parent, configuration) { + constructor (parent, state) { super(...arguments) - - this.movable = configuration.movable !== false + this.movable = !this.immutable && state.movable !== false } move (tile) { diff --git a/src/components/modifiers/rotate.js b/src/components/modifiers/rotate.js index 49ac63e..ec7dc9b 100644 --- a/src/components/modifiers/rotate.js +++ b/src/components/modifiers/rotate.js @@ -51,7 +51,7 @@ export const rotatable = (SuperClass) => class RotatableItem extends SuperClass super(...arguments) this.direction = coalesce(state.direction, configuration.direction) - this.rotatable = coalesce(true, state.rotatable, configuration.rotatable) + this.rotatable = !this.immutable && coalesce(true, state.rotatable, configuration.rotatable) this.rotationDegrees = coalesce(60, state.rotationDegrees, configuration.rotationDegrees) this.rotation = coalesce(0, state.rotation, configuration.rotation) % this.getMaxRotation() } @@ -80,12 +80,14 @@ export const rotatable = (SuperClass) => class RotatableItem extends SuperClass } rotateGroup (rotation) { - if (this.rotatable) { - this.group.rotate(rotation * this.rotationDegrees, this.center) - } + this.group.rotate(rotation * this.rotationDegrees, this.center) } rotate (clockwise) { + if (!this.rotatable) { + return + } + const rotation = clockwise === false ? -1 : 1 this.rotation = (rotation + this.rotation) % this.getMaxRotation() diff --git a/src/components/modifiers/swap.js b/src/components/modifiers/swap.js index 71a3a8e..6aa71e6 100644 --- a/src/components/modifiers/swap.js +++ b/src/components/modifiers/swap.js @@ -1,6 +1,5 @@ import { Move } from './move' import { Modifier } from '../modifier' -import { Item } from '../item' export class Swap extends Move { name = 'swap_horiz' @@ -20,7 +19,12 @@ export class Swap extends Move { } tileFilter (tile) { - // Filter out immutable tiles and tiles without items - return tile.modifiers.some(Modifier.immutable) || !tile.items.filter((item) => item.type !== Item.Types.beam).length + // Never mask current tile + return !tile.equals(this.tile) && ( + // Mask immutable tiles + tile.modifiers.some(Modifier.immutable) || + // Mask tiles that don't contain any movable items + !tile.items.some(Move.movable) + ) } } diff --git a/src/components/modifiers/toggle.js b/src/components/modifiers/toggle.js index 9d900de..67ebb60 100644 --- a/src/components/modifiers/toggle.js +++ b/src/components/modifiers/toggle.js @@ -54,7 +54,7 @@ export const toggleable = (SuperClass) => class ToggleableItem extends SuperClas constructor (parent, configuration) { super(...arguments) - this.toggleable = configuration.toggleable !== false + this.toggleable = !this.immutable && configuration.toggleable !== false } onToggle () {} diff --git a/src/components/puzzle.js b/src/components/puzzle.js index 0f9a453..a10836e 100644 --- a/src/components/puzzle.js +++ b/src/components/puzzle.js @@ -134,6 +134,7 @@ export class Puzzle { throw new Error(`Duplicate mask detected: ${mask.id}`) } + console.debug('adding mask to queue', mask) this.#maskQueue.push(mask) return } @@ -176,6 +177,7 @@ export class Puzzle { } unmask () { + console.debug('unmask', this.#mask) this.layers.mask.removeChildren() this.#updateMessage(this.selectedTile) this.#mask.onUnmask(this) @@ -185,6 +187,7 @@ export class Puzzle { const mask = this.#maskQueue.pop() if (mask) { + console.debug('processing next mask in queue', mask) // Evaluate after any current events have processed (e.g. beam updates from last mask) setTimeout(() => this.mask(mask), 0) } @@ -215,10 +218,12 @@ export class Puzzle { return previouslySelectedTile } - updateState () { - this.#state.update(Object.assign(this.#state.getCurrent(), { layout: this.layout.getState() })) + updateState (keepDelta = true) { + this.#state.update(Object.assign(this.#state.getCurrent(), { layout: this.layout.getState() }), keepDelta) this.#updateActions() - emitEvent(Puzzle.Events.Updated, { state: this.#state }) + if (keepDelta) { + emitEvent(Puzzle.Events.Updated, { state: this.#state }) + } } #addLayers () { @@ -439,8 +444,8 @@ export class Puzzle { : undefined this.updateSelectedTile(selectedTile) + this.updateState(false) this.update() - this.#updateActions() } #teardown () { diff --git a/src/components/state.js b/src/components/state.js index 79cb474..9547b5c 100644 --- a/src/components/state.js +++ b/src/components/state.js @@ -120,7 +120,7 @@ export class State { } } - update (newState) { + update (newState, keepDelta = true) { const delta = jsonDiffPatch.diff(this.#current, newState) console.debug('delta', delta) @@ -130,17 +130,20 @@ export class State { } // Handle updating after undoing - if (this.#index < this.#lastIndex()) { + if (keepDelta && this.#index < this.#lastIndex()) { // Remove all deltas after the current one this.#deltas.splice(this.#index + 1) } this.apply(delta) - // It seems that the jsondiffpatch library modifies deltas on patch. To prevent that, they will be stored as - // their stringified JSON representation and parsed before being applied. - // See:https://github.com/benjamine/jsondiffpatch/issues/34 - this.#deltas.push(JSON.stringify(delta)) + if (keepDelta) { + // It seems that the jsondiffpatch library modifies deltas on patch. To prevent that, they will be stored as + // their stringified JSON representation and parsed before being applied. + // See:https://github.com/benjamine/jsondiffpatch/issues/34 + this.#deltas.push(JSON.stringify(delta)) + } + this.#index = this.#lastIndex() this.#updateCache() diff --git a/src/puzzles/012.json b/src/puzzles/012.json index a145d7c..bd39623 100644 --- a/src/puzzles/012.json +++ b/src/puzzles/012.json @@ -34,7 +34,6 @@ }, { "directions": [4], - "rotatable": false, "type": "Wall" } ], @@ -43,30 +42,33 @@ { "items": [ { - "color": ["blue", "red"], "openings": [ + null, + null, { + "color": "blue", + "on": true, "type": "Beam" }, + null, + null, { - "type": "Beam" - }, - { - "type": "Beam" - }, - { - "type": "Beam" - }, - { - "type": "Beam" - }, - { + "color": "red", + "on": true, "type": "Beam" } ], "type": "Terminus" } ], + "modifiers": [ + { + "type": "Rotate" + }, + { + "type": "Toggle" + } + ], "type": "Tile" }, { @@ -77,7 +79,6 @@ }, { "directions": [3], - "rotatable": false, "type": "Wall" } ], @@ -104,10 +105,17 @@ }, { "directions": [3, 4, 5], - "rotatable": false, "type": "Wall" } ], + "modifiers": [ + { + "type": "Rotate" + }, + { + "type": "Swap" + } + ], "type": "Tile" }, { @@ -118,10 +126,17 @@ }, { "directions": [2, 3, 4], - "rotatable": false, "type": "Wall" } ], + "modifiers": [ + { + "type": "Rotate" + }, + { + "type": "Move" + } + ], "type": "Tile" }, { @@ -242,7 +257,7 @@ }, "solution": [ { - "amount": 1, + "amount": 2, "type": "Connections" } ] From a7f348599d1351cd33b7ebbaa8c3ae2c58263188 Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Fri, 16 Feb 2024 19:12:58 -0600 Subject: [PATCH 5/7] Add mask.onUpdate This allows the mask to be re-evaluated when it gets dequeued, with the potential to cancel the mask. This is because state can change in between when the mask is queued and when it gets processed. For example, if multiple portals are reached at the same time, the resolution of one of the portals may cause a subsequent portal mask to no longer be valid. --- src/components/items/portal.js | 99 +++++++++++++++++++++------------- src/components/puzzle.js | 9 +++- src/puzzles/012.json | 80 ++++++++------------------- 3 files changed, 92 insertions(+), 96 deletions(-) diff --git a/src/components/items/portal.js b/src/components/items/portal.js index 4a15907..9067579 100644 --- a/src/components/items/portal.js +++ b/src/components/items/portal.js @@ -100,63 +100,56 @@ export class Portal extends movable(rotatable(Item)) { return nextStep.copy({ insertAbove: this }) } - // Find all valid exit portals - const validExitPortals = puzzle.getItems().filter((item) => - // Is a portal - item.type === Item.Types.portal && - // But not the entry portal - !item.equals(this) && - // There is no other beam occupying the portal at the exit direction - !item.get(Portal.getExitDirection(nextStep, this, item)) && ( - // Entry portals without defined direction can exit from any other portal. - this.getDirection() === undefined || - // Exit portals without defined direction can be used by any entry portal. - item.getDirection() === undefined || - // Exit portals with a defined direction can only be used by entry portals with the same defined direction. - item.getDirection() === this.getDirection() - ) - ) + const exitPortals = this.#getExitPortals(puzzle, beam, nextStep) - if (validExitPortals.length === 0) { + if (exitPortals.length === 0) { console.debug(this.toString(), 'no valid exit portals found') // This will cause the beam to stop return currentStep - } - - // Check for existing exitPortalId in beam state for this step - const exitPortalId = beam.getState().steps?.[nextStep.index]?.[this.id] - if (exitPortalId !== undefined) { - console.debug(this.toString(), `found exitPortalId ${exitPortalId} in beam step ${nextStep.index} state`) - } - - const exitPortal = validExitPortals.length === 1 - ? validExitPortals[0] - : validExitPortals.find((item) => item.id === exitPortalId) - - if (exitPortal) { - console.debug(this.toString(), 'exit portal:', exitPortal) - // A single matching destination + } else if (exitPortals.length === 1) { + const exitPortal = exitPortals[0] + console.debug(this.toString(), 'single exit portal matched:', exitPortal) return this.#getStep(beam, nextStep, exitPortal) } else { - console.debug(this.toString(), 'found multiple valid exit portals:', validExitPortals) // Multiple matching destinations. User will need to pick one manually. - const validTiles = validExitPortals.map((portal) => portal.parent) + console.debug(this.toString(), 'found multiple valid exit portals:', exitPortals) + // Cache exit portals for use in mask + const data = { exitPortals } const mask = new Puzzle.Mask( { - beam, id: this.id, onMask: () => currentStep.tile.beforeModify(), onTap: (puzzle, tile) => { - const exitPortal = validExitPortals.find((portal) => portal.parent === tile) + const exitPortal = data.exitPortals.find((portal) => portal.parent === tile) if (exitPortal) { beam.addStep(this.#getStep(beam, nextStep, exitPortal)) puzzle.unmask() } }, onUnmask: () => currentStep.tile.afterModify(), + onUpdate: () => { + // State may have changed, fetch portals again + const exitPortals = this.#getExitPortals(puzzle, beam, nextStep) + if (exitPortals.length === 0) { + console.debug(this.toString(), 'mask onUpdate: no valid exit portals found') + // Cancel the mask + // This will also cause the beam to stop + return false + } else if (exitPortals.length === 1) { + const exitPortal = exitPortals[0] + console.debug(this.toString(), 'mask onUpdate: single portal matched:', exitPortal) + beam.addStep(this.#getStep(beam, nextStep, exitPortal)) + // Cancel the mask + return false + } else { + console.debug(this.toString(), 'mask onUpdate: exit portals:', exitPortals) + data.exitPortals = exitPortals + } + }, tileFilter: (tile) => { // Mask any invalid tiles. Exclude the entry portal tile - return !(tile.equals(this.parent) || validTiles.some((validTile) => validTile.equals(tile))) + return !(tile.equals(this.parent) || + data.exitPortals.map((portal) => portal.parent).some((validTile) => validTile.equals(tile))) } } ) @@ -180,6 +173,38 @@ export class Portal extends movable(rotatable(Item)) { this.#directions[direction] = data } + #getExitPortals (puzzle, beam, nextStep) { + const exitPortals = puzzle.getItems().filter((item) => + // Is a portal + item.type === Item.Types.portal && + // But not the entry portal + !item.equals(this) && + // There is no other beam occupying the portal at the exit direction + !item.get(Portal.getExitDirection(nextStep, this, item)) && ( + // Entry portals without defined direction can exit from any other portal. + this.getDirection() === undefined || + // Exit portals without defined direction can be used by any entry portal. + item.getDirection() === undefined || + // Exit portals with a defined direction can only be used by entry portals with the same defined direction. + item.getDirection() === this.getDirection() + ) + ) + + if (exitPortals.length > 1) { + // Check for existing exitPortalId in beam state for this step + const exitPortalId = beam.getState().steps?.[nextStep.index]?.[this.id] + if (exitPortalId !== undefined) { + console.debug(this.toString(), `found exitPortalId ${exitPortalId} in beam step ${nextStep.index} state`) + const existing = exitPortals.find((item) => item.id === exitPortalId) + if (existing) { + return [existing] + } + } + } + + return exitPortals + } + #getStep (beam, nextStep, exitPortal) { const direction = Portal.getExitDirection(nextStep, this, exitPortal) return nextStep.copy({ diff --git a/src/components/puzzle.js b/src/components/puzzle.js index a10836e..40c5f58 100644 --- a/src/components/puzzle.js +++ b/src/components/puzzle.js @@ -189,7 +189,13 @@ export class Puzzle { if (mask) { console.debug('processing next mask in queue', mask) // Evaluate after any current events have processed (e.g. beam updates from last mask) - setTimeout(() => this.mask(mask), 0) + setTimeout(() => { + // Allow mask to update since state may have changed since it was queued + // If onUpdate returns false the mask will not be applied + if (mask.onUpdate() !== false) { + this.mask(mask) + } + }) } } @@ -637,6 +643,7 @@ export class Puzzle { this.onMask = configuration.onMask ?? noop this.onTap = configuration.onTap ?? noop this.onUnmask = configuration.onUnmask ?? noop + this.onUpdate = configuration.onUpdate ?? noop } equals (other) { diff --git a/src/puzzles/012.json b/src/puzzles/012.json index bd39623..d702f23 100644 --- a/src/puzzles/012.json +++ b/src/puzzles/012.json @@ -29,7 +29,6 @@ { "items": [ { - "direction": 2, "type": "Portal" }, { @@ -47,14 +46,12 @@ null, { "color": "blue", - "on": true, "type": "Beam" }, null, null, { "color": "red", - "on": true, "type": "Beam" } ], @@ -74,7 +71,6 @@ { "items": [ { - "direction": 5, "type": "Portal" }, { @@ -87,11 +83,6 @@ ], [ { - "items": [ - { - "type": "Portal" - } - ], "type": "Tile" }, { @@ -142,48 +133,6 @@ { "type": "Tile" }, - { - "items": [ - { - "type": "Portal" - } - ], - "modifiers": [ - { - "type": "Move" - } - ], - "type": "Tile" - } - ], - [ - { - "type": "Tile" - }, - { - "items": [ - { - "rotation": 2, - "type": "Reflector" - } - ], - "modifiers": [ - { - "type": "Rotate" - } - ], - "type": "Tile" - }, - { - "type": "Tile" - }, - null, - { - "type": "Tile" - }, - { - "type": "Tile" - }, { "type": "Tile" } @@ -212,15 +161,17 @@ "modifiers": [ { "type": "Lock" - }, - { - "type": "Toggle" } ], "type": "Tile" }, - null, + { + "type": "Tile" + }, null, + { + "type": "Tile" + }, { "items": [ { @@ -241,9 +192,6 @@ "modifiers": [ { "type": "Lock" - }, - { - "type": "Toggle" } ], "type": "Tile" @@ -251,6 +199,22 @@ { "type": "Tile" } + ], + [ + { + "type": "Tile" + }, + { + "type": "Tile" + }, + null, + null, + { + "type": "Tile" + }, + { + "type": "Tile" + } ] ], "type": "even-r" From 060c5dfeb931332670d8519c2221ae97e4e86a83 Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Thu, 22 Feb 2024 20:05:26 -0600 Subject: [PATCH 6/7] Complete initial version of puzzle 012 Still needs test coverage, but I have confirmed that it is solvable as is. An additional change in this commit is the addition of an "is not immutable" filter for modifiers when displaying the indicator badge on tiles. I think the primary purpose of the indicator is to show the user they can interact with something in that tile, and immutable modifiers are not interactable, so it just creates noise. The lock and immutable modifiers mostly apply when moving something anyways. --- src/components/items/terminus.js | 12 +- src/components/items/tile.js | 3 +- src/puzzles/012.json | 265 ++++++++++++++++++++++++------- test/puzzles/012.js | 17 ++ 4 files changed, 234 insertions(+), 63 deletions(-) create mode 100644 test/puzzles/012.js diff --git a/src/components/items/terminus.js b/src/components/items/terminus.js index 04d02ba..68af70a 100644 --- a/src/components/items/terminus.js +++ b/src/components/items/terminus.js @@ -22,15 +22,15 @@ export class Terminus extends movable(rotatable(toggleable(Item))) { colors.length ? colors : (Array.isArray(state.color) ? state.color : [state.color]) ).hex() - const openings = state.openings.map((state, direction) => - state + const openings = state.openings.map((opening, direction) => + opening ? new Terminus.#Opening( - state.color || color, + opening.color ?? color, direction, - state.connected, - state.on + opening.connected, + opening.on ?? state.on ) - : state + : opening ).filter((opening) => opening) this.#ui = Terminus.ui(tile, color, openings) diff --git a/src/components/items/tile.js b/src/components/items/tile.js index 76aa888..a09f2c6 100644 --- a/src/components/items/tile.js +++ b/src/components/items/tile.js @@ -124,7 +124,8 @@ export class Tile extends Item { update () { super.update() - this.#ui.indicator.opacity = this.modifiers.length ? 1 : 0 + // Display the indicator if the tile contains non-immutable modifiers + this.#ui.indicator.opacity = this.modifiers.filter((modifier) => !modifier.immutable).length ? 1 : 0 } static parameters (height) { diff --git a/src/puzzles/012.json b/src/puzzles/012.json index d702f23..84ee5cd 100644 --- a/src/puzzles/012.json +++ b/src/puzzles/012.json @@ -5,21 +5,9 @@ null, null, { - "items": [ - { - "direction": 3, - "type": "Portal" - } - ], "type": "Tile" }, { - "items": [ - { - "direction": 4, - "type": "Portal" - } - ], "type": "Tile" } ], @@ -29,10 +17,9 @@ { "items": [ { - "type": "Portal" - }, - { - "directions": [4], + "directions": [ + 4 + ], "type": "Wall" } ], @@ -41,18 +28,31 @@ { "items": [ { + "on": true, "openings": [ - null, - null, + { + "color": "red", + "type": "Beam" + }, + { + "color": "blue", + "type": "Beam" + }, + { + "color": "red", + "type": "Beam" + }, { "color": "blue", "type": "Beam" }, - null, - null, { "color": "red", "type": "Beam" + }, + { + "color": "blue", + "type": "Beam" } ], "type": "Terminus" @@ -60,10 +60,10 @@ ], "modifiers": [ { - "type": "Rotate" + "type": "Lock" }, { - "type": "Toggle" + "type": "Rotate" } ], "type": "Tile" @@ -71,10 +71,9 @@ { "items": [ { - "type": "Portal" - }, - { - "directions": [3], + "directions": [ + 3 + ], "type": "Wall" } ], @@ -83,9 +82,27 @@ ], [ { - "type": "Tile" - }, - { + "items": [ + { + "openings": [ + null, + { + "color": "blue", + "type": "Beam" + }, + null, + null, + null, + null + ], + "type": "Terminus" + } + ], + "modifiers": [ + { + "type": "Lock" + } + ], "type": "Tile" }, { @@ -93,18 +110,11 @@ { "direction": 1, "type": "Portal" - }, - { - "directions": [3, 4, 5], - "type": "Wall" } ], "modifiers": [ { - "type": "Rotate" - }, - { - "type": "Swap" + "type": "Lock" } ], "type": "Tile" @@ -112,39 +122,101 @@ { "items": [ { - "direction": 0, - "type": "Portal" - }, - { - "directions": [2, 3, 4], + "directions": [ + 3, + 4, + 5 + ], "type": "Wall" } ], - "modifiers": [ - { - "type": "Rotate" - }, + "type": "Tile" + }, + { + "items": [ { - "type": "Move" + "directions": [ + 2, + 3, + 4 + ], + "type": "Wall" } ], "type": "Tile" }, { + "items": [ + { + "direction": 0, + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Lock" + } + ], "type": "Tile" }, { + "items": [ + { + "openings": [ + { + "color": "red", + "type": "Beam" + }, + null, + null, + null, + null, + null + ], + "type": "Terminus" + } + ], + "modifiers": [ + { + "type": "Lock" + } + ], "type": "Tile" } ], [ { + "items": [ + { + "direction": 1, + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Move" + } + ], + "type": "Tile" + }, + { + "items": [ + { + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Immutable" + } + ], "type": "Tile" }, { "items": [ { "openings": [ + null, { "color": "blue", "type": "Beam" @@ -152,7 +224,6 @@ null, null, null, - null, null ], "type": "Terminus" @@ -165,18 +236,11 @@ ], "type": "Tile" }, - { - "type": "Tile" - }, null, - { - "type": "Tile" - }, { "items": [ { "openings": [ - null, { "color": "red", "type": "Beam" @@ -184,6 +248,7 @@ null, null, null, + null, null ], "type": "Terminus" @@ -197,22 +262,110 @@ "type": "Tile" }, { + "items": [ + { + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Immutable" + } + ], + "type": "Tile" + }, + { + "items": [ + { + "direction": 0, + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Move" + } + ], "type": "Tile" } ], [ { + "items": [ + { + "openings": [ + null, + { + "color": "blue", + "type": "Beam" + }, + null, + null, + null, + null + ], + "type": "Terminus" + } + ], + "modifiers": [ + { + "type": "Lock" + } + ], "type": "Tile" }, { + "items": [ + { + "direction": 1, + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Rotate" + } + ], "type": "Tile" }, null, null, { + "items": [ + { + "direction": 0, + "type": "Portal" + } + ], + "modifiers": [ + { + "type": "Rotate" + } + ], "type": "Tile" }, { + "items": [ + { + "openings": [ + { + "color": "red", + "type": "Beam" + }, + null, + null, + null, + null, + null + ], + "type": "Terminus" + } + ], + "modifiers": [ + { + "type": "Lock" + } + ], "type": "Tile" } ] @@ -221,7 +374,7 @@ }, "solution": [ { - "amount": 2, + "amount": 6, "type": "Connections" } ] diff --git a/test/puzzles/012.js b/test/puzzles/012.js new file mode 100644 index 0000000..eea6386 --- /dev/null +++ b/test/puzzles/012.js @@ -0,0 +1,17 @@ +/* eslint-env mocha */ +const { PuzzleFixture } = require('../fixtures.js') +const assert = require('assert') + +describe('Puzzle 012', function () { + const puzzle = new PuzzleFixture('012') + + after(puzzle.after) + before(puzzle.before) + + it('should be solved', async function () { + await puzzle.clickTile(1, 3) + await puzzle.clickModifier('rotate') + + assert(await puzzle.isSolved()) + }) +}) From 40fa2bb8c037c5892663716e4cfa374b21a0be7c Mon Sep 17 00:00:00 2001 From: Kyle Florence Date: Fri, 23 Feb 2024 18:41:00 -0600 Subject: [PATCH 7/7] Complete tests for puzzle 012 This also fixes a few bugs: - when getting color elements for a newly selected tile, if it contained merge beams it was throwing an error using step.color instead of step.colors. - the portal collision logic in beam.onCollision was not handling every case. - state index wasn't being updated correctly if keepDelta was false on update. - state actions like undo and redo weren't properly prevented even if they were disabled visually. The reset action was also never disabled in cases where it should be. - the puzzle mask queue was not being emptied when the puzzle was reloaded, which caused some strange undo/redo behaviors. --- src/components/items/beam.js | 14 ++++----- src/components/puzzle.js | 20 +++++++++---- src/components/state.js | 57 +++++++++++++++++++++++------------- test/puzzles/012.js | 56 +++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/src/components/items/beam.js b/src/components/items/beam.js index e549dd0..c6dfe45 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -128,7 +128,7 @@ export class Beam extends Item { getColorElements (tile) { // Show color elements for merged beams const step = this.getSteps(tile).find((step) => step.state.has(StepState.MergeWith)) - return step ? getColorElements(step.color) : [] + return step ? getColorElements(step.colors) : [] } getCompoundPath () { @@ -287,16 +287,14 @@ export class Beam extends Item { } } - const isSameDirection = step.direction === nextStep.direction - if (currentStep.state.get(StepState.Portal)?.exitPortal && !isSameDirection) { - console.debug( - this.toString(), - 'ignoring collision with beam using same portal with different exit direction', - beam.toString() - ) + // Check for a portal on either beam + const portal = currentStep.state.get(StepState.Portal) ?? step.state.get(StepState.Portal) + if (portal) { + console.debug(this.toString(), 'ignoring collision with beam using same portal', beam.toString()) return } + const isSameDirection = step.direction === nextStep.direction if (!isSameDirection || isSelf) { // Beams are traveling in different directions (collision), or a beam is trying to merge into itself console.debug(beam.toString(), 'has collided with', (isSelf ? 'self' : this.toString()), collision) diff --git a/src/components/puzzle.js b/src/components/puzzle.js index 40c5f58..0fd123d 100644 --- a/src/components/puzzle.js +++ b/src/components/puzzle.js @@ -393,8 +393,9 @@ export class Puzzle { } #redo () { - this.#state.redo() - this.#reload() + if (this.#state.redo()) { + this.#reload() + } } #reload () { @@ -415,8 +416,9 @@ export class Puzzle { } #reset () { - this.#state.reset() - this.#reload() + if (this.#state.reset()) { + this.#reload() + } } #resize () { @@ -471,12 +473,14 @@ export class Puzzle { this.#collisions = {} this.#isUpdatingBeams = false this.#mask = undefined + this.#maskQueue = [] this.#termini = [] } #undo () { - this.#state.undo() - this.#reload() + if (this.#state.undo()) { + this.#reload() + } } #updateActions () { @@ -498,6 +502,10 @@ export class Puzzle { disable.push(elements.redo) } + if (!this.#state.canReset()) { + disable.push(elements.reset) + } + if (!Puzzles.visible.has(id)) { // Custom puzzle elements.puzzleId.value = '' diff --git a/src/components/state.js b/src/components/state.js index 9547b5c..4b70135 100644 --- a/src/components/state.js +++ b/src/components/state.js @@ -41,6 +41,10 @@ export class State { return this.#index < this.#lastIndex() } + canReset () { + return this.#deltas.length > 0 + } + canUndo () { return this.#index >= 0 } @@ -82,16 +86,24 @@ export class State { } redo () { - const nextIndex = this.#index + 1 - if (nextIndex <= this.#lastIndex()) { - this.#current = structuredClone(this.#original) - this.#deltas.filter((delta, index) => index <= nextIndex).forEach((delta) => this.apply(delta)) - this.#index = nextIndex - this.#updateCache() + if (!this.canRedo()) { + return } + + this.#index++ + this.#current = structuredClone(this.#original) + this.#deltas.filter((delta, index) => index <= this.#index).forEach((delta) => this.apply(delta)) + + this.#updateCache() + + return true } reset () { + if (!this.canReset()) { + return + } + this.#current = structuredClone(this.#original) this.#deltas = [] this.#index = this.#lastIndex() @@ -100,6 +112,8 @@ export class State { State.clearCache(this.getId()) this.#updateCache() + + return true } setSelectedTile (tile) { @@ -111,13 +125,17 @@ export class State { } undo () { - const previousIndex = this.#index - 1 - if (previousIndex >= -1) { - this.#current = structuredClone(this.#original) - this.#deltas.filter((delta, index) => index <= previousIndex).forEach((delta) => this.apply(delta)) - this.#index = previousIndex - this.#updateCache() + if (!this.canUndo()) { + return } + + this.#index-- + this.#current = structuredClone(this.#original) + this.#deltas.filter((delta, index) => index <= this.#index).forEach((delta) => this.apply(delta)) + + this.#updateCache() + + return true } update (newState, keepDelta = true) { @@ -129,23 +147,22 @@ export class State { return } - // Handle updating after undoing - if (keepDelta && this.#index < this.#lastIndex()) { - // Remove all deltas after the current one - this.#deltas.splice(this.#index + 1) - } - this.apply(delta) if (keepDelta) { + // Handle updating after undoing + if (this.#index < this.#lastIndex()) { + // Remove all deltas after the current one + this.#deltas.splice(this.#index + 1) + } + // It seems that the jsondiffpatch library modifies deltas on patch. To prevent that, they will be stored as // their stringified JSON representation and parsed before being applied. // See:https://github.com/benjamine/jsondiffpatch/issues/34 this.#deltas.push(JSON.stringify(delta)) + this.#index = this.#lastIndex() } - this.#index = this.#lastIndex() - this.#updateCache() } diff --git a/test/puzzles/012.js b/test/puzzles/012.js index eea6386..9eca8c6 100644 --- a/test/puzzles/012.js +++ b/test/puzzles/012.js @@ -12,6 +12,62 @@ describe('Puzzle 012', function () { await puzzle.clickTile(1, 3) await puzzle.clickModifier('rotate') + await puzzle.clickTile(3, 0) + await puzzle.clickModifier('move') + await puzzle.clickTile(0, 3) + await puzzle.isMasked() + await puzzle.clickTile(3, 5) + + await puzzle.clickTile(3, 6) + await puzzle.clickModifier('move') + await puzzle.clickTile(0, 2) + await puzzle.isMasked() + await puzzle.clickTile(3, 1) + + await puzzle.clickTile(4, 1) + await puzzle.clickModifier('rotate', { times: 3 }) + + await puzzle.clickTile(4, 4) + await puzzle.clickModifier('rotate', { times: 3 }) + + await puzzle.clickTile(3, 0) + await puzzle.selectModifier('move') + await puzzle.clickTile(4, 1) + await puzzle.clickModifier('move') + await puzzle.clickTile(1, 4) + await puzzle.isMasked() + await puzzle.clickTile(3, 1) + + await puzzle.clickTile(3, 6) + await puzzle.selectModifier('move') + await puzzle.clickTile(4, 4) + await puzzle.clickModifier('move') + await puzzle.clickTile(1, 2) + await puzzle.isMasked() + await puzzle.clickTile(3, 5) + + await puzzle.clickTile(4, 1) + await puzzle.selectModifier('move') + await puzzle.clickTile(3, 2) + await puzzle.clickModifier('move') + await puzzle.clickTile(2, 2) + + await puzzle.clickTile(4, 4) + await puzzle.selectModifier('move') + await puzzle.clickTile(3, 4) + await puzzle.clickModifier('move') + await puzzle.clickTile(2, 3) + + await puzzle.clickTile(4, 1) + await puzzle.selectModifier('rotate') + await puzzle.clickTile(2, 0) + await puzzle.clickModifier('rotate', { times: 2 }) + + await puzzle.clickTile(4, 4) + await puzzle.selectModifier('rotate') + await puzzle.clickTile(2, 5) + await puzzle.clickModifier('rotate', { times: 4 }) + assert(await puzzle.isSolved()) }) })