import { BitSet } from 'bitset' import { Cell, Level } from '../engine' import { LOG_LEVEL, logger } from '../logger' import LruCache from '../lruCache' import { Command, COMMAND_TYPE, SoundItem } from '../parser/astTypes' import { SpriteBitSet } from '../spriteBitSet' // import TerminalUI from '../ui/terminal' import { _flatten, DEBUG_FLAG, ICacheable, nextRandom, opposite, Optional, RULE_DIRECTION, setIntersection } from '../util' import { BaseForLines, IGameCode } from './BaseForLines' import { CollisionLayer } from './collisionLayer' import { IGameNode } from './game' import { GameSprite, IGameTile } from './tile' const BitSet2 = require('bitset') // tslint:disable-line:no-var-requires const MAX_ITERATIONS_IN_LOOP = 350 // Set by the Random World Generation game const LRU_CACHE_SIZE = 100 // 1000 export const SIMPLE_DIRECTION_DIRECTIONS = [ RULE_DIRECTION.RIGHT, RULE_DIRECTION.DOWN, RULE_DIRECTION.LEFT, RULE_DIRECTION.UP ] class BracketPair { public readonly condition: Optional public action: Optional constructor(condition: Optional, action: Optional) { this.condition = condition this.action = action } } class ExtraPair extends BracketPair { public readonly extra: boolean constructor(condition: Optional, action: Optional, extra: boolean) { super(condition, action) this.extra = extra } } export interface IRule extends IGameNode { hasMatches: (level: Level) => boolean evaluate: (level: Level, onlyEvaluateFirstMatch: boolean) => IMutation[] getChildRules: () => IRule[] isLate: () => boolean hasRigid: () => boolean clearCaches: () => void addCellsToEmptyRules: (cells: Iterable) => void totalTimeMs?: number timesRan?: number a11yGetConditionSprites(): Array> toKey(): string } export interface IMutation { messages: Array> hasCell: () => boolean getCell: () => Cell getCommand: () => Command> } class CellMutation implements IMutation { public readonly messages: Array> private cell: Cell constructor(cell: Cell, messages: Array>) { this.cell = cell this.messages = messages } public hasCell() { return true } public getCell() { return this.cell } public getCommand(): Command> { throw new Error(`BUG: check hasCommand first`) } } class CommandMutation implements IMutation { public readonly messages: Array> private command: Command> constructor(command: Command>) { this.command = command this.messages = [] // TODO: Decide if something should be here } public getCommand() { return this.command } public hasCell() { return false } public getCell(): Cell { throw new Error(`BUG: check hasCell first`) } } // Converts `[ [1,2], [a,b] ]` to: // `[ [1,a], [2,a], [1,b], [2,b] ]` function buildPermutations(cells: T[][]) { let tuples: T[][] = [[]] for (const row of cells) { const newtuples = [] for (const valtoappend of row) { for (const tuple of tuples) { const newtuple = tuple.concat([valtoappend]) newtuples.push(newtuple) } } tuples = newtuples } return tuples } function commandToKey(c: Command>) { switch (c.type) { case COMMAND_TYPE.AGAIN: case COMMAND_TYPE.CANCEL: case COMMAND_TYPE.CHECKPOINT: case COMMAND_TYPE.RESTART: case COMMAND_TYPE.WIN: return c.type case COMMAND_TYPE.MESSAGE: return `${c.type}:${c.message}` case COMMAND_TYPE.SFX: return `${c.type}:${c.sound.type}:${c.sound.soundCode}` } } export class SimpleRuleGroup extends BaseForLines implements IRule { public isRandom: boolean private rules: IRule[] constructor(source: IGameCode, isRandom: boolean, rules: IRule[]) { super(source) this.rules = rules this.isRandom = isRandom } public hasMatches(level: Level) { for (const rule of this.rules) { if (rule.hasMatches(level)) { return true } } return false } public evaluate(level: Level, onlyEvaluateFirstMatch: boolean) { let start if (logger.isLevel(LOG_LEVEL.DEBUG)) { start = Date.now() } // Keep looping as long as one of the rules evaluated something const allMutations: IMutation[][] = [] let iteration for (iteration = 0; iteration < MAX_ITERATIONS_IN_LOOP; iteration++) { if (process.env.NODE_ENV === 'development' && iteration === MAX_ITERATIONS_IN_LOOP - 10) { // Provide a breakpoint just before we run out of MAX_ITERATIONS_IN_LOOP // so that we can step through the evaluations. logger.warn(this.toString()) logger.warn('BUG: Iterated too many times in startloop or + (rule group)') // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } if (iteration === MAX_ITERATIONS_IN_LOOP - 1) { throw new Error(`BUG: Iterated too many times in startloop or + (rule group)\n${this.toString()}`) } if (this.isRandom) { // Randomly pick one of the rules. I wonder if it needs to be smart // It is important that it only be evaluated once (hence the returns) const evaluatableRules = this.rules.filter((r) => r.hasMatches(level)) if (evaluatableRules.length === 0) { return [] } else if (evaluatableRules.length === 1) { const ret = evaluatableRules[0].evaluate(level, true/*only evaluate the 1st match because we are RANDOM and in a loop*/) return ret } else { const randomIndex = nextRandom(evaluatableRules.length) const rule = evaluatableRules[randomIndex] const ret = rule.evaluate(level, true/*only evaluate the 1st match because we are RANDOM and in a loop*/) return ret } } else { let evaluatedSomething = false for (const rule of this.rules) { // Keep evaluating the rule until nothing changes const ret = rule.evaluate(level, onlyEvaluateFirstMatch) if (ret.length > 0) { // filter because a Rule may have caused only command mutations if (ret.filter((m) => m.hasCell()).length > 0) { evaluatedSomething = true } if (onlyEvaluateFirstMatch) { return ret } allMutations.push(ret) } } if (!evaluatedSomething) { break } } } if (logger.isLevel(LOG_LEVEL.DEBUG)) { if (allMutations.length > 0) { if (start && (Date.now() - start) > 30 /*only show times for rules that took a long time*/) { logger.debug(`Rule ${this.__getSourceLineAndColumn().lineNum} applied. ${iteration === 1 ? '' : `(x${iteration})`} [[${Date.now() - start}ms]]`) } else { logger.debug(`Rule ${this.__getSourceLineAndColumn().lineNum} applied. ${iteration === 1 ? '' : `(x${iteration})`}`) } } } return _flatten(allMutations) // let mutations = [] // for (const rule of this._rules) { // const ret = rule.evaluate() // if (ret.length > 0) { // mutations = mutations.concat(ret) // } // } // return mutations } public clearCaches() { for (const rule of this.rules) { rule.clearCaches() } } public getChildRules() { return this.rules } public isLate() { // All rules in a group should be parked as late if any is marked as late return this.rules[0].isLate() } public hasRigid() { for (const rule of this.rules) { if (rule.hasRigid()) { return true } } return false } public addCellsToEmptyRules(cells: Iterable) { for (const rule of this.rules) { rule.addCellsToEmptyRules(cells) } } public toKey() { return this.rules.map((r) => r.toKey()).join('\n') } public a11yGetConditionSprites() { let sprites: Array> = [] for (const rule of this.rules) { sprites = [...sprites, ...rule.a11yGetConditionSprites()] } return sprites } } export class SimpleRuleLoop extends SimpleRuleGroup { } // This is a rule that has been expanded from `DOWN [ > player < cat RIGHT dog ] -> [ ^ crate ]` to: // DOWN [ DOWN player UP cat RIGHT dog ] -> [ RIGHT crate ] // // And a more complicated example: // DOWN [ > player LEFT cat HORIZONTAL dog < crate VERTICAL wall ] -> [ ^ crate HORIZONTAL dog ] // // DOWN [ DOWN player LEFT cat LEFT dog UP crate UP wall ] -> [ right crate LEFT dog ] // DOWN [ DOWN player LEFT cat LEFT dog UP crate DOWN wall ] -> [ right crate LEFT dog ] // DOWN [ DOWN player LEFT cat RIGHT dog UP crate UP wall ] -> [ RIGHT crate RIGHT dog ] // DOWN [ DOWN player LEFT cat RIGHT dog UP crate DOWN wall ] -> [ RIGHT crate RIGHT dog ] export class SimpleRule extends BaseForLines implements ICacheable, IRule { public conditionBrackets: ISimpleBracket[] public actionBrackets: ISimpleBracket[] public commands: Array>> public debugFlag: Optional // private evaluationDirection: RULE_DIRECTION private _isLate: boolean private readonly isRigid: boolean private isSubscribedToCellChanges: boolean constructor(source: IGameCode, conditionBrackets: ISimpleBracket[], actionBrackets: ISimpleBracket[], commands: Array>>, isLate: boolean, isRigid: boolean, debugFlag: Optional) { super(source) // this.evaluationDirection = evaluationDirection this.conditionBrackets = conditionBrackets this.actionBrackets = actionBrackets this.commands = commands this._isLate = isLate this.isRigid = isRigid this.debugFlag = debugFlag this.isSubscribedToCellChanges = false if (actionBrackets.length > 0) { for (let index = 0; index < conditionBrackets.length; index++) { conditionBrackets[index].prepareAction(actionBrackets[index]) } } } public toKey() { // const dir = this.dependsOnDirection() ? this.evaluationDirection : '' const conditions = this.conditionBrackets.map((x) => x.toKey()) const actions = this.actionBrackets.map((x) => x.toKey()) const commands = this.commands.map((c) => commandToKey(c)) return `{Late?${this._isLate}} {Rigid?${this.isRigid}} ${conditions} -> ${actions} ${commands.join(' ')} {debugger?${this.debugFlag}}` } public getChildRules(): IRule[] { return [] } public subscribeToCellChanges() { if (!this.isSubscribedToCellChanges) { // Subscribe the bracket and neighbors to cell Changes (only the condition side) for (const bracket of this.conditionBrackets) { bracket.subscribeToNeighborChanges() } this.isSubscribedToCellChanges = true } } public clearCaches() { for (const bracket of this.conditionBrackets) { bracket.clearCaches() } } public getMatches(level: Level) { const allBracketsToProcess: MatchedCellsForRule[][] = [] for (let index = 0; index < this.conditionBrackets.length; index++) { const condition = this.conditionBrackets[index] const action = this.actionBrackets[index] const bracketMatches = condition.getMatches(level, action) if (bracketMatches.length === 0) { return [] } allBracketsToProcess.push(bracketMatches) } return allBracketsToProcess } public evaluate(level: Level, onlyEvaluateFirstMatch: boolean) { // Verify that each condition bracket has matches // for (const condition of this.conditionBrackets) { // if (!condition.hasFirstCells()) { // if (process.env['NODE_ENV'] === 'development' && this.debugFlag === DEBUG_FLAG.BREAKPOINT_REMOVE) { // // A "DEBUGGER_REMOVE" flag was set in the game so we are pausing here // // if (process.stdout) { TerminalUI.debugRenderScreen(); } debugger // } // return [] // Rule did not match, so nothing ran // } // } // If a Rule cannot impact itself then the evaluation order does not matter. // We can vastly simplify the evaluation in that case let ret: IMutation[] = [] if (process.env.NODE_ENV === 'development') { // A "DEBUGGER" flag was set in the game so we are pausing here // if ((logger.isLevel(LOG_LEVEL.TRACE) && process.stdout) || (logger.isLevel(LOG_LEVEL.DEBUG) && this.debugFlag === DEBUG_FLAG.BREAKPOINT)) { // // TerminalUI.renderScreen(false) // } if (this.debugFlag === DEBUG_FLAG.BREAKPOINT) { debugger // tslint:disable-line:no-debugger } } const allBracketsToProcess = this.getMatches(level) if (allBracketsToProcess.length === 0) { return [] } // Some rules only contain commands. // If there are actionBrackets then evaluate them. // Get ready to Evaluate if (this.actionBrackets.length > 0) { const cellPermutations = buildPermutations(allBracketsToProcess) const allMutations: IMutation[][] = [] for (const permutation of cellPermutations) { let didAllBracketsStillMatch = true const magicOrTiles = new Map() // Populate the magicOrTiles. This needs to be done first because of things like: // [ Player ] [ Color ] -> [ Player Color ] [ ] // Check that all Cells still match for (const x of permutation) { if (!x.doesStillMatch()) { didAllBracketsStillMatch = false break } x.populateMagicOrTiles(magicOrTiles) } // break if the cells no longer match if (!didAllBracketsStillMatch) { continue } for (const x of permutation) { if (!x.doesStillMatch()) { // part of the rule not longer matches so stop. (Test if that is the correct behavior) // E.g. [ Player ] [ Player ] [ ] -> [ ] [ ] [ SeeIfThisIsExecuted ] continue } allMutations.push(x.evaluate(magicOrTiles)) if (process.env.NODE_ENV === 'development') { this.__incrementCoverage() } } // Only evaluate once. This is a HACK since it always picks the 1st cell that matched rather than a RANDOM cell if (onlyEvaluateFirstMatch) { break // evaluate the subsequent brackets but do not continue evaluating cells } } ret = _flatten(allMutations) } // Append any Commands that need to be evaluated (only if the rule was evaluated at least once) for (const command of this.commands) { ret.push(new CommandMutation(command)) } return ret } public hasMatches(level: Level) { for (let index = 0; index < this.conditionBrackets.length; index++) { const condition = this.conditionBrackets[index] const action = this.actionBrackets[index] if (!condition.hasMatches(level, action)) { return false } } return true } public isLate() { return this._isLate } public hasRigid() { return this.isRigid } public canCollapseBecauseBracketsMatch(rule: SimpleRule) { for (let index = 0; index < this.conditionBrackets.length; index++) { if (this.conditionBrackets[index] !== rule.conditionBrackets[index]) { return false } // also ensure there is only one neighbor. // that way we can de-duplicate the rule if (this.conditionBrackets[index]._getAllNeighbors().length > 1) { return false } } for (let index = 0; index < this.actionBrackets.length; index++) { if (this.actionBrackets[index] !== rule.actionBrackets[index]) { return false } } return true } public addCellsToEmptyRules(cells: Iterable) { for (const bracket of this.conditionBrackets) { bracket.addCellsToEmptyRules(cells) } } public a11yGetConditionSprites() { let sprites: Array> = [] for (const b of this.conditionBrackets) { sprites = [...sprites, ...b.a11yGetSprites()] } return sprites } } export class SimpleTileWithModifier extends BaseForLines implements ICacheable { public readonly _isNegated: boolean public readonly _isRandom: boolean public readonly _direction: Optional public readonly _tile: IGameTile public readonly _debugFlag: Optional private neighbors: Set private trickleCells: Set constructor(source: IGameCode, isNegated: boolean, isRandom: boolean, direction: Optional, tile: IGameTile, debugFlag: Optional) { super(source) this._isNegated = isNegated this._isRandom = isRandom this._direction = direction this._tile = tile this.neighbors = new Set() this._debugFlag = debugFlag this.trickleCells = new Set() } public toKey(ignoreDebugFlag?: boolean) { const sprites = this._tile.getSprites().map((sprite) => sprite.getName()).sort() if (ignoreDebugFlag) { return `{-?${this._isNegated}} {#?${this._isRandom}} dir="${this._direction}" [${sprites.join(' ')}]` } else { return `{-?${this._isNegated}} {#?${this._isRandom}} dir="${this._direction}" [${sprites.join(' ')}]{debugging?${!!this._debugFlag}}` } } public equals(t: SimpleTileWithModifier) { return this._isNegated === t._isNegated && this._tile.equals(t._tile) && this._direction === t._direction && this._isRandom === t._isRandom } public clearCaches() { // this._localCache.clear() } public isNo() { return this._isNegated } public isRandom() { return this._isRandom } public getCollisionLayers() { const collisionLayers = new Set() for (const sprite of this._tile.getSprites()) { collisionLayers.add(sprite.getCollisionLayer()) } return collisionLayers } // This should only be called on Condition Brackets public subscribeToCellChanges(neighbor: SimpleNeighbor) { this.neighbors.add(neighbor) this._tile.subscribeToCellChanges(this) } public addCells(tile: IGameTile, sprite: GameSprite, cells: Cell[], wantsToMove: Optional) { if (process.env.NODE_ENV === 'development' && this._debugFlag === DEBUG_FLAG.BREAKPOINT) { // Pause here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } // Cells all have the same sprites, so if the 1st matches, they all do. // Also, we only need to check that the direction matches (optimization), // we do not need to re-check that the Tile matches let shouldAdd = true if (!this._direction || wantsToMove === this._direction) { shouldAdd = !this.isNo() } else if (this._tile.isOr() && this.matchesFirstCell(cells, wantsToMove)) { // In OR tiles, one of the sprites may add/remove a direction but // another sprite may still have the direction // so we check by doing the long and expensive comparison above shouldAdd = !this.isNo() } else { shouldAdd = this.isNo() } if (shouldAdd) { for (const cell of cells) { this.trickleCells.add(cell) } // const cellsNotInCache = setDifference(new Set(cells), new Set(this._localCache.keys())) for (const neighbor of this.neighbors) { // neighbor.addCells(this, sprite, cellsNotInCache, wantsToMove) neighbor.addCells(this, sprite, cells, wantsToMove) } } else { for (const cell of cells) { this.trickleCells.delete(cell) } // const cellsInCache = setIntersection(new Set(cells), new Set(this._localCache.keys())) for (const neighbor of this.neighbors) { // neighbor.removeCells(this, sprite, cellsInCache) neighbor.removeCells(this, sprite, cells) } } } public updateCells(sprite: GameSprite, cells: Cell[], wantsToMove: Optional) { if (process.env.NODE_ENV === 'development' && this._debugFlag === DEBUG_FLAG.BREAKPOINT) { // Pause here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } // Cells all have the same sprites, so if the 1st matches, they all do if (this.matchesFirstCell(cells, wantsToMove)) { for (const cell of cells) { this.trickleCells.add(cell) } if (wantsToMove) { for (const neighbor of this.neighbors) { neighbor.updateCells(this, sprite, cells, wantsToMove) } } } else { for (const cell of cells) { this.trickleCells.delete(cell) } for (const neighbor of this.neighbors) { neighbor.removeCells(this, sprite, cells) } } } public removeCells(tile: IGameTile, sprite: GameSprite, cells: Cell[]) { if (process.env.NODE_ENV === 'development' && this._debugFlag === DEBUG_FLAG.BREAKPOINT_REMOVE) { // Pause here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } // Cells all have the same sprites, so if the 1st matches, they all do // OR Tiles need to be checked to see if the tile still matches. // Non-OR tiles can be safely removed let shouldAdd = false if (this._tile.isOr()) { shouldAdd = this.matchesFirstCell(cells, null) } else { shouldAdd = this.isNo() } if (shouldAdd) { for (const cell of cells) { this.trickleCells.add(cell) } for (const neighbor of this.neighbors) { neighbor.addCells(this, sprite, cells, RULE_DIRECTION.STATIONARY) } } else { for (const cell of cells) { this.trickleCells.delete(cell) } for (const neighbor of this.neighbors) { neighbor.removeCells(this, sprite, cells) } } } public hasCell(cell: Cell) { return this.trickleCells.has(cell) } private matchesCellWantsToMove(cell: Cell, wantsToMove: Optional) { const hasTile = this._tile.hasCell(cell) const didMatch = this._isNegated !== (hasTile && (this._direction === wantsToMove || !this._direction)) if (didMatch) { return true } else if (!this._direction) { return false } else { // do the more expensive match for (const sprite of this._tile.getSpritesThatMatch(cell)) { if (this._direction === cell.getWantsToMove(sprite)) { return true } } return false } } private matchesFirstCell(cells: Cell[], wantsToMove: Optional) { return this.matchesCellWantsToMove(cells[0], wantsToMove) } } export abstract class ISimpleBracket extends BaseForLines implements ICacheable { public readonly debugFlag: Optional public readonly direction: RULE_DIRECTION protected firstCells: Set private allNeighbors: SimpleNeighbor[] constructor(source: IGameCode, direction: RULE_DIRECTION, allNeighbors: SimpleNeighbor[], debugFlag: Optional) { super(source) this.direction = direction this.debugFlag = debugFlag this.allNeighbors = allNeighbors this.firstCells = new Set() } public abstract subscribeToNeighborChanges(): void public abstract toKey(ignoreDebugFlag?: boolean): string public abstract clearCaches(): void public abstract prepareAction(action: ISimpleBracket): void public abstract addCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell, wantsToMove: Optional): void public abstract removeCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell): void public abstract addCellsToEmptyRules(cells: Iterable): void public abstract getMatches(level: Level, actionBracket: Optional): MatchedCellsForRule[] public abstract a11yGetSprites(): Array> public _getAllNeighbors() { return this.allNeighbors } public hasMatches(level: Level, actionBracket: Optional) { return this.getMatches(level, actionBracket).length > 0 } } interface IMatchedCellAndCorrespondingNeighbors { cell: Cell, condition: SimpleNeighbor, action: Optional } class MatchedCellsForRule { public readonly cellsAndNeighbors: IMatchedCellAndCorrespondingNeighbors[] private cellKeys: Map constructor(cellsAndNeighbors: IMatchedCellAndCorrespondingNeighbors[]) { this.cellsAndNeighbors = cellsAndNeighbors this.cellKeys = new Map() for (const { cell } of this.cellsAndNeighbors) { this.cellKeys.set(cell, cell.toKey()) } } public firstCell() { for (const { cell } of this.cellsAndNeighbors) { return cell } throw new Error(`BUG: ? No cells were included in the match`) } public lastCell() { let ret for (const { cell } of this.cellsAndNeighbors) { ret = cell } if (ret) { return ret } throw new Error(`BUG: ? No cells were included in the match`) } public doesStillMatch() { for (const { cell, condition } of this.cellsAndNeighbors) { // Check the cell key (cheap) to see if the cell still matches if (cell.toKey() === this.cellKeys.get(cell)) { continue } if (!condition.matchesCellSimple(cell)) { return false } // if the cell updated but still matches then update the cellKey this.cellKeys.set(cell, cell.toKey()) } return true } public populateMagicOrTiles(magicOrTiles: Map>) { // populate the OR tiles in all neighbors first. Example: // [ | Player ] -> [ Player | ] for (const { cell, condition } of this.cellsAndNeighbors) { condition.populateMagicOrTiles(cell, magicOrTiles) } } public evaluate(magicOrTiles: Map>) { const mutations: IMutation[] = [] for (const { cell, condition, action } of this.cellsAndNeighbors) { if (!action) { throw new Error(`BUG: Should not have tried to evaluate something when there is no action`) } const mutation = condition.evaluate(action, cell, magicOrTiles) if (mutation) { mutations.push(mutation) } } return mutations } } export class SimpleBracket extends ISimpleBracket { protected actionDebugFlag: Optional private neighbors: SimpleNeighbor[] private ellipsisBracketListeners: Map private readonly spritesPresentInRowOrColumn: SpriteBitSet private readonly anySpritesPresentInRowOrColumn: SpriteBitSet constructor(source: IGameCode, direction: RULE_DIRECTION, neighbors: SimpleNeighbor[], debugFlag: Optional) { super(source, direction, neighbors, debugFlag) this.actionDebugFlag = null this.neighbors = neighbors this.ellipsisBracketListeners = new Map() // Compute which sprites need to be in the Row/Column to check cells in that row/column (optimization) this.spritesPresentInRowOrColumn = this.neighbors[0].spritesPresent.union(this.neighbors.map((n) => n.spritesPresent)) const anySprites = [] for (const neighbor of this.neighbors) { for (const a of neighbor.anySpritesPresent) { anySprites.push(a) } } this.anySpritesPresentInRowOrColumn = (new SpriteBitSet()).union(anySprites) } public toKey() { const dir = this.dependsOnDirection() ? this.direction : '' return `{${dir}[${this.neighbors.map((n) => n.toKey()).join('|')}]{debugging?${!!this.debugFlag}}}` } public dependsOnDirection() { return this.neighbors.length > 1 || !!this.neighbors.find((n) => n.dependsOnDirection()) } public subscribeToNeighborChanges() { if (this.shouldUseOnDemandMethod()) { return } // Skip. Do not subscribe to changes because we will use .getMatches() to find the matches this.neighbors.forEach((neighbor, index) => { neighbor.subscribeToTileChanges(this, index) }) } public addEllipsisBracket(bracket: SimpleEllipsisBracket, token: BEFORE_OR_AFTER) { this.ellipsisBracketListeners.set(bracket, token) } public clearCaches() { this.firstCells.clear() for (const neighbor of this.neighbors) { neighbor.clearCaches() } } public getNeighbors() { return this.neighbors } public prepareAction(actionBracket: ISimpleBracket) { const actionBracketSimple = actionBracket as SimpleBracket // since we know the condition and action side need to match this.actionDebugFlag = actionBracketSimple.debugFlag for (let index = 0; index < this.neighbors.length; index++) { const condition = this.neighbors[index] const action = actionBracketSimple.neighbors[index] condition.prepareAction(action) } } public addCellsToEmptyRules(cells: Iterable) { if (this.neighbors.length === 1) { if (this.neighbors[0]._tilesWithModifier.size === 0) { for (const cell of cells) { this._addFirstCell(cell) } } } } public _addFirstCell(firstCell: Cell) { if (process.env.NODE_ENV === 'development' && this.debugFlag === DEBUG_FLAG.BREAKPOINT) { // Pausing here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } this.firstCells.add(firstCell) for (const [ellipsisBracket, token] of this.ellipsisBracketListeners) { ellipsisBracket.addFirstCell(this, firstCell, token) } } public addCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell, wantsToMove: Optional) { // check if downstream neighbors match if (!this.matchesDownstream(cell, index)) { // Try to remove the match if there is one const firstCell = this.getUpstream(cell, index) if (firstCell) { this._removeFirstCell(cell) } return } // Loop Upstream // check the neighbors upstream of curCell const firstCellUp = this.matchesUpstream(cell, index) if (!firstCellUp) { // Try to remove the match if there is one const firstCellMatched = this.getUpstream(cell, index) if (firstCellMatched) { this._removeFirstCell(firstCellMatched) } return } // Add to the set of firstNeighbors // We have a match. Add to the firstCells set. this._addFirstCell(firstCellUp) } public removeCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell) { // cell was removed // Loop Upstream const firstCell = this.getFirstCellToRemove(cell, index) // Bracket might not match for all directions (likely not), so we might not find a firstCell to remove // But that's OK. if (firstCell && this.firstCells.has(firstCell)) { this._removeFirstCell(firstCell) } } public shouldUseOnDemandMethod() { // return true // return false // return this.neighbors.length === 1 // return this.neighbors.length !== 1 return process.env.PUZZLESCRIPT_METHOD === 'ondemand' } public getMatchesByTrickling(level: Level, actionBracket: Optional) { const matches: MatchedCellsForRule[] = [] for (const firstCell of this.firstCells) { this.addToCellMatches(matches, firstCell, actionBracket) } return matches } public getMatchesByLooping(level: Level, actionBracket: Optional) { const matches: MatchedCellsForRule[] = [] // Naiive version: // for (const row of level.getCells()) { // for (const cell of row) { // checkCell(cell) // } // } const cells = level.getCells() const rowCount = cells.length const colCount = cells[0].length switch (this.direction) { case RULE_DIRECTION.UP: case RULE_DIRECTION.DOWN: for (let colIndex = 0; colIndex < colCount; colIndex++) { if (level.colContainsSprites(colIndex, this.spritesPresentInRowOrColumn, this.anySpritesPresentInRowOrColumn)) { for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { this.addIfCellMatches(matches, level.getCell(rowIndex, colIndex), actionBracket) } } } break case RULE_DIRECTION.LEFT: case RULE_DIRECTION.RIGHT: for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { if (level.rowContainsSprites(rowIndex, this.spritesPresentInRowOrColumn, this.anySpritesPresentInRowOrColumn)) { for (let colIndex = 0; colIndex < colCount; colIndex++) { this.addIfCellMatches(matches, level.getCell(rowIndex, colIndex), actionBracket) } } } break default: throw new Error(`BUG: Unsupported Direction "${this.direction}"`) } return matches } public getMatches(level: Level, actionBracket: Optional) { if (process.env.NODE_ENV === 'development' && this.debugFlag === DEBUG_FLAG.BREAKPOINT) { // A "DEBUGGER" flag was set in the game so we are pausing here // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } let matches if (!this.shouldUseOnDemandMethod()) { matches = this.getMatchesByTrickling(level, actionBracket) if (process.env.VERIFY_MATCHES) { const loopingMatches = this.getMatchesByLooping(level, actionBracket) if (matches.length !== loopingMatches.length) { debugger // tslint:disable-line:no-debugger this.getMatchesByTrickling(level, actionBracket) // run again so we can step through this.getMatchesByLooping(level, actionBracket) // run again so we can step through throw new Error(`Match lengths differ. Expected ${loopingMatches.length} but found ${matches.length}. \n${this.toString()}`) } } } else { matches = this.getMatchesByLooping(level, actionBracket) } return matches } public a11yGetSprites() { const sprites = new Set() for (const n of this.neighbors) { for (const t of n._tilesWithModifier) { for (const s of t._tile.getSprites()) { sprites.add(s) } } } return [sprites] } protected _removeFirstCell(firstCell: Cell) { if (this.firstCells.has(firstCell)) { if (process.env.NODE_ENV === 'development' && this.debugFlag === DEBUG_FLAG.BREAKPOINT_REMOVE) { // Pausing here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } this.firstCells.delete(firstCell) for (const [ellipsisBracket, token] of this.ellipsisBracketListeners) { ellipsisBracket.removeFirstCell(this, firstCell, token) } } } private matchesDownstream(cell: Cell, index: number) { // Check all the neighbors and add the firstNeighbor to the set of matches for this direction let matched = true let curCell: Optional = cell // Loop Downstream // check the neighbors downstream of curCell for (let x = index + 1; x < this.neighbors.length; x++) { curCell = curCell.getNeighbor(this.direction) // TODO: Convert the neighbor check into a method if (curCell && (this.neighbors[x]._tilesWithModifier.size === 0 || this.neighbors[x].matchesCellSimple(curCell))) { // keep going } else { matched = false break } } return matched } private getUpstream(cell: Cell, index: number) { let curCell: Optional = cell for (let x = index - 1; x >= 0; x--) { curCell = curCell.getNeighbor(opposite(this.direction)) if (curCell) { // keep going } else { return null } } return curCell } private matchesUpstream(cell: Cell, index: number) { let matched = true let curCell: Optional = cell // check the neighbors upstream of curCell for (let x = index - 1; x >= 0; x--) { curCell = curCell.getNeighbor(opposite(this.direction)) if (curCell && (this.neighbors[x]._tilesWithModifier.size === 0 || this.neighbors[x].matchesCellSimple(curCell))) { // keep going } else { matched = false break } } return matched ? curCell : null } private getFirstCellToRemove(cell: Cell, index: number) { // Loop Upstream // check the neighbors upstream of curCell let matched = true let curCell: Optional = cell // check the neighbors upstream of curCell for (let x = index - 1; x >= 0; x--) { curCell = curCell.getNeighbor(opposite(this.direction)) if (curCell) { // keep going } else { matched = false break } } return matched ? curCell : null } private addToCellMatches(matches: MatchedCellsForRule[], cell: Cell, actionBracket: Optional) { const cellMatches = [] let curCell: Optional = cell let didAllNeighborsMatch = true for (let index = 0; index < this.neighbors.length; index++) { if (!curCell) { didAllNeighborsMatch = false break } const condition = this.neighbors[index] let action = null // Some rules only contain a condition bracket and a command if (actionBracket) { action = actionBracket.neighbors[index] || null } const x: IMatchedCellAndCorrespondingNeighbors = { cell: curCell, condition, action } cellMatches.push(x) curCell = curCell.getNeighbor(this.direction) } if (didAllNeighborsMatch) { matches.push(new MatchedCellsForRule(cellMatches)) } } private addIfCellMatches(matches: MatchedCellsForRule[], cell: Cell, actionBracket: Optional) { if (this.neighbors[0].matchesCellSimple(cell) && this.matchesDownstream(cell, 0)) { this.addToCellMatches(matches, cell, actionBracket) } } } enum BEFORE_OR_AFTER { BEFORE, AFTER } class MultiMap { private map: Map> constructor() { this.map = new Map() } public clear() { this.map.clear() } // public has(a: A, b: B) { // const set = this.map.get(a) // if (set) { // return set.has(b) // } // return false // } public getB(a: A) { return this.map.get(a) } public add(a: A, b: B) { let set = this.map.get(a) if (!set) { set = new Set() this.map.set(a, set) } if (!set.has(b)) { set.add(b) return true } return false } public deleteAllA(a: A) { this.map.delete(a) } public deleteAllB(b: B) { const asRemoved = new Set() for (const [a, set] of this.map) { if (set.has(b)) { set.delete(b) if (set.size === 0) { this.map.delete(a) asRemoved.add(a) } } } return asRemoved } public sizeA() { return this.map.size } // public delete(a: A, b: B) { // const set = this.map.get(a) // if (set) { // if (!set.has(b)) { // throw new Error(`BUG: Invariant error. Link did not exist so nothing to remove`) // } // set.delete(b) // } // } // public hasA(a: A) { // return this.map.has(a) // } // public hasB(b: B) { // return !!this.getA(b) // } // public getA(b: B) { // const ret = new Set() // for (const [a, set] of this.map) { // if (set.has(b)) { // ret.add(a) // } // } // if (ret.size > 0) { // return ret // } // return undefined // } // public size() { // let size = 0 // for (const set of this.map.values()) { // size += set.size // } // return size // } } export class SimpleEllipsisBracket extends ISimpleBracket { public beforeEllipsisBracket: SimpleBracket public afterEllipsisBracket: SimpleBracket private linkages: MultiMap // 1 before may have many afters constructor(source: IGameCode, direction: RULE_DIRECTION, beforeEllipsisNeighbors: SimpleNeighbor[], afterEllipsisNeighbors: SimpleNeighbor[], debugFlag: Optional) { super(source, direction, [...beforeEllipsisNeighbors, ...afterEllipsisNeighbors], debugFlag) this.beforeEllipsisBracket = new SimpleBracket(source, direction, beforeEllipsisNeighbors, debugFlag) this.afterEllipsisBracket = new SimpleBracket(source, direction, afterEllipsisNeighbors, debugFlag) this.linkages = new MultiMap() } public subscribeToNeighborChanges() { this.beforeEllipsisBracket.subscribeToNeighborChanges() this.afterEllipsisBracket.subscribeToNeighborChanges() this.beforeEllipsisBracket.addEllipsisBracket(this, BEFORE_OR_AFTER.BEFORE) this.afterEllipsisBracket.addEllipsisBracket(this, BEFORE_OR_AFTER.AFTER) } public toKey() { return `[${this.direction} ${this.beforeEllipsisBracket.toKey()} ... ${this.afterEllipsisBracket.toKey()}]}` } public clearCaches() { this.firstCells.clear() this.linkages.clear() this.beforeEllipsisBracket.clearCaches() this.afterEllipsisBracket.clearCaches() } public prepareAction(action: ISimpleBracket) { const actionBracket = action as SimpleEllipsisBracket // since we know the condition and action side need to match this.beforeEllipsisBracket.prepareAction(actionBracket.beforeEllipsisBracket) this.afterEllipsisBracket.prepareAction(actionBracket.afterEllipsisBracket) } public addCellsToEmptyRules(cells: Iterable) { this.beforeEllipsisBracket.addCellsToEmptyRules(cells) this.afterEllipsisBracket.addCellsToEmptyRules(cells) } public addCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell, wantsToMove: Optional) { throw new Error(`BUG: We should not be subscribed to these events`) } public removeCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell) { throw new Error(`BUG: We should not be subscribed to these events`) } public addFirstCell(bracket: SimpleBracket, firstCell: Cell, token: BEFORE_OR_AFTER) { // // check to see if the new cell is in line with any firstCells in the other bracket. If so, we have a match! // let firstBeforeCells // let firstAfterCells // if (bracket == this.beforeEllipsisBracket) { // firstBeforeCells = new Set([firstCell]) // // search for a matching afterCell // firstAfterCells = this.findMatching(firstCell, this.direction, this.afterEllipsisBracket) // } else if (bracket === this.afterEllipsisBracket) { // firstAfterCells = new Set([firstCell]) // // search for a matching beforeCell // firstBeforeCells = this.findMatching(firstCell, opposite(this.direction), this.beforeEllipsisBracket) // } else { // throw new Error(`BUG: Bracket should only ever be the before-ellipsis or after-ellipsis one`) // } // for (const firstBeforeCell of firstBeforeCells) { // for (const firstAfterCell of firstAfterCells) { // this.checkInvariants() // // Check if we need to actually change anything first. Becauase the !doesEvaluationOrderMatter case // // keeps iterating on the set of firstCells but if they keep flipping then it's a problem because it // // runs in an infinite loop // // Delete any mapping that may have existed before // if (this.linkages.has(firstBeforeCell, firstAfterCell)) { // // nothing to do. we already have those entries // } else { // this.linkages.add(firstBeforeCell, firstAfterCell) // this.firstCells.add(firstBeforeCell) // } // this.checkInvariants() // } // } } public removeFirstCell(bracket: SimpleBracket, firstCell: Cell, token: BEFORE_OR_AFTER) { // Figure out the 1st cell for us and remove it (by maybe looking at the matching bracket) this.checkInvariants() if (bracket === this.beforeEllipsisBracket) { this.linkages.deleteAllA(firstCell) if (this.firstCells.has(firstCell)) { throw new Error(`BUG: Unreachable code`) } } else if (bracket === this.afterEllipsisBracket) { const beforeCellsRemoved = this.linkages.deleteAllB(firstCell) if (beforeCellsRemoved.size > 0) { throw new Error(`BUG: Unreachable code`) } } else { throw new Error(`BUG: Bracket should only ever be the before-ellipsis or after-ellipsis one`) } this.checkInvariants() } // private findMatching(cell: Cell, direction: RULE_DIRECTION, inBracket: SimpleBracket) { // const matches = new Set() // for (const inBracketCell of inBracket.getFirstCells()) { // switch (direction) { // case RULE_DIRECTION.UP: // if (cell.colIndex === inBracketCell.colIndex && cell.rowIndex > inBracketCell.rowIndex) { // matches.add(inBracketCell) // } // break // case RULE_DIRECTION.DOWN: // if (cell.colIndex === inBracketCell.colIndex && cell.rowIndex < inBracketCell.rowIndex) { // matches.add(inBracketCell) // } // break // case RULE_DIRECTION.LEFT: // if (cell.colIndex > inBracketCell.colIndex && cell.rowIndex === inBracketCell.rowIndex) { // matches.add(inBracketCell) // } // break // case RULE_DIRECTION.RIGHT: // if (cell.colIndex < inBracketCell.colIndex && cell.rowIndex === inBracketCell.rowIndex) { // matches.add(inBracketCell) // } // break // default: // throw new Error(`BUG: Invalid direction`) // } // } // return matches // } public getMatches(level: Level, actionBracket: Optional): MatchedCellsForRule[] { const ret: MatchedCellsForRule[] = [] let beforeMatches let afterMatches if (actionBracket) { beforeMatches = this.beforeEllipsisBracket.getMatches(level, actionBracket.beforeEllipsisBracket) afterMatches = this.afterEllipsisBracket.getMatches(level, actionBracket.afterEllipsisBracket) } else { beforeMatches = this.beforeEllipsisBracket.getMatches(level, null) afterMatches = this.afterEllipsisBracket.getMatches(level, null) } const beforeMatchesByIndex = new MultiMap() if (beforeMatches.length === 0 || afterMatches.length === 0) { return [] } switch (this.direction) { case RULE_DIRECTION.UP: case RULE_DIRECTION.DOWN: for (const beforeMatch of beforeMatches) { beforeMatchesByIndex.add(beforeMatch.lastCell().colIndex, beforeMatch) } for (const afterMatch of afterMatches) { const { colIndex, rowIndex } = afterMatch.firstCell() for (const beforeMatch of beforeMatchesByIndex.getB(colIndex) || []) { // check if the afterMatch matches it. // If so, remove the beforeMatch and include the whole match const { rowIndex: beforeRowIndex } = beforeMatch.lastCell() const isAfter = (this.direction === RULE_DIRECTION.DOWN) ? beforeRowIndex < rowIndex : rowIndex < beforeRowIndex if (isAfter) { ret.push(new MatchedCellsForRule([...beforeMatch.cellsAndNeighbors].concat([...afterMatch.cellsAndNeighbors]))) // beforeMatchesByIndex.delete(colIndex, beforeMatch) } } } break case RULE_DIRECTION.LEFT: case RULE_DIRECTION.RIGHT: for (const beforeMatch of beforeMatches) { beforeMatchesByIndex.add(beforeMatch.lastCell().rowIndex, beforeMatch) } for (const afterMatch of afterMatches) { const { rowIndex, colIndex } = afterMatch.firstCell() for (const beforeMatch of beforeMatchesByIndex.getB(rowIndex) || []) { // check if the afterMatch matches it. // If so, remove the beforeMatch and include the whole match const { colIndex: beforeColIndex } = beforeMatch.lastCell() const isAfter = (this.direction === RULE_DIRECTION.RIGHT) ? beforeColIndex < colIndex : colIndex < beforeColIndex if (isAfter) { ret.push(new MatchedCellsForRule([...beforeMatch.cellsAndNeighbors].concat([...afterMatch.cellsAndNeighbors]))) // beforeMatchesByIndex.delete(rowIndex, beforeMatch) } } } break default: throw new Error(`BUG: Invalid direction ${this.direction}`) } return ret } public a11yGetSprites() { return [...this.beforeEllipsisBracket.a11yGetSprites(), ...this.afterEllipsisBracket.a11yGetSprites()] } private checkInvariants() { if (this.firstCells.size !== this.linkages.sizeA()) { throw new Error(`BUG: Invariant violation`) } } } export enum A11Y_MESSAGE_TYPE { ADD = 'ADD', REPLACE = 'REPLACE', REMOVE = 'REMOVE', MOVE = 'MOVE' } export type A11Y_MESSAGE = { type: A11Y_MESSAGE_TYPE.ADD, sprites: Iterable cell: TCell } | { type: A11Y_MESSAGE_TYPE.REPLACE, replacements: Iterable<{oldSprite: TSprite, newSprite: TSprite}> cell: TCell } | { type: A11Y_MESSAGE_TYPE.REMOVE, sprites: Iterable, cell: TCell } | { type: A11Y_MESSAGE_TYPE.MOVE, oldCell: TCell, newCell: TCell, direction: RULE_DIRECTION, sprite: TSprite } class ReplaceTile { private collisionLayer: CollisionLayer private actionTileWithModifier: Optional private mightNotFindConditionButThatIsOk: boolean private conditionSpritesToRemove: Optional private newDirection: Optional constructor(collisionLayer: CollisionLayer, actionTileWithModifier: Optional, mightNotFindConditionButThatIsOk: boolean, conditionSpritesToRemove: Optional, newDirection: Optional) { if (!collisionLayer) { throw new Error('BUG: collisionLayer is not set') } this.collisionLayer = collisionLayer this.actionTileWithModifier = actionTileWithModifier this.mightNotFindConditionButThatIsOk = mightNotFindConditionButThatIsOk this.conditionSpritesToRemove = conditionSpritesToRemove this.newDirection = newDirection } public replace(cell: Cell, magicOrTiles: Map>, orTilesRemoved: Set): { didActuallyChange: boolean, messages: Array>} { let didActuallyChange = false const messages: Array> = [] // Check if we are adding or removing.... if (this.actionTileWithModifier) { // adding let sprites: Iterable // if RANDOM is set then pick a random sprite to add if (this.actionTileWithModifier.isRandom()) { const spritesToChoose = this.actionTileWithModifier._tile.getSprites() const rnd = nextRandom(spritesToChoose.length) sprites = [spritesToChoose[rnd]] } else if (this.actionTileWithModifier._tile.isOr()) { // There is no sprite of this type already in the cell. It's in the magicOrTiles const s = magicOrTiles.get(this.actionTileWithModifier._tile) if (!s) { throw new Error(`BUG: Magic OR tile not found\n${this.actionTileWithModifier.toString()}`) } sprites = s } else { sprites = this.actionTileWithModifier._tile.getSprites() } const addedSprites: GameSprite[] = [] const replacedSprites: Array<{oldSprite: GameSprite, newSprite: GameSprite}> = [] for (const sprite of sprites) { const c = sprite.getCollisionLayer() const wantsToMove = this.newDirection || cell.getCollisionLayerWantsToMove(c) let added if (cell.hasSprite(sprite)) { if (!wantsToMove) { throw new Error(`BUG: Invariant violation. if the sprite exists then wantsToMove must also exist (at least it would be STATIONARY)`) } added = cell.updateSprite(sprite, wantsToMove) } else { const oldSprite = cell.getSpriteByCollisionLayer(c) if (oldSprite) { replacedSprites.push({ oldSprite, newSprite: sprite }) } else { addedSprites.push(sprite) } // preserve the wantsToMove if the sprite is in the same collision layer added = cell.addSprite(sprite, wantsToMove) } didActuallyChange = didActuallyChange || added } messages.push({ type: A11Y_MESSAGE_TYPE.ADD, cell, sprites: addedSprites }) messages.push({ type: A11Y_MESSAGE_TYPE.REPLACE, cell, replacements: replacedSprites }) } else { // removing const removedSprites = new Set() const tile = cell.getSpriteByCollisionLayer(this.collisionLayer) if (!tile && this.mightNotFindConditionButThatIsOk) { // this occurs when there is just a -> [ NO Color ] on the action side (remove color if it exists) return { didActuallyChange: false, messages: [] } } if (!tile) { throw new Error(`BUG: No tile found`) } if (this.conditionSpritesToRemove) { // For OR tiles we need to only remove one of the sprites, not ALL of the sprites if (this.conditionSpritesToRemove._tile.isOr()) { if (! orTilesRemoved.has(this.conditionSpritesToRemove._tile)) { // only remove the sprites in the cell that match the condition... not all the sprites in a collisionLayer const cellSprites = tile.getSprites() for (const conditionSpriteToRemove of this.conditionSpritesToRemove._tile.getSprites()) { if (cellSprites.indexOf(conditionSpriteToRemove) >= 0) { const removed = cell.removeSprite(conditionSpriteToRemove) removedSprites.add(conditionSpriteToRemove) didActuallyChange = didActuallyChange || removed if (removed) { orTilesRemoved.add(this.conditionSpritesToRemove._tile) } break } } } } else { // only remove the sprites in the cell that match the condition... not all the sprites in a collisionLayer const conditionSpritesToRemove = new Set(this.conditionSpritesToRemove._tile.getSprites()) for (const sprite of tile.getSprites()) { if (conditionSpritesToRemove.has(sprite)) { const removed = cell.removeSprite(sprite) removedSprites.add(sprite) didActuallyChange = didActuallyChange || removed } } } } else { throw new Error(`BUG: Not implemented (just commented out)`) // // remove all sprites // for (const sprite of tile.getSprites()) { // const removed = cell.removeSprite(sprite) // didActuallyChange = didActuallyChange || removed // } } messages.push({ type: A11Y_MESSAGE_TYPE.REMOVE, cell, sprites: removedSprites }) } // return the oldSprite for UNDO return { didActuallyChange, messages } } } class ReplaceDirection { private collisionLayer: CollisionLayer private direction: RULE_DIRECTION private mightNotFindConditionButThatIsOk: boolean constructor(collisionLayer: CollisionLayer, direction: RULE_DIRECTION, mightNotFindConditionButThatIsOk: boolean) { if (!collisionLayer) { throw new Error('BUG: collisionLayer is not set') } this.collisionLayer = collisionLayer this.direction = direction this.mightNotFindConditionButThatIsOk = mightNotFindConditionButThatIsOk } public replace(cell: Cell) { let direction = this.direction // It's OK if this sprite is not in the condition. This happens when an OR action tile has sprites that are in multiple collision layers if (this.mightNotFindConditionButThatIsOk && !cell.getSpriteByCollisionLayer(this.collisionLayer)) { return { didActuallyChange: false } } // Pick a random direction if (this.direction === RULE_DIRECTION.RANDOMDIR) { // only set the direction if one has not already been set if (cell.getCollisionLayerWantsToMove(this.collisionLayer) === RULE_DIRECTION.STATIONARY) { switch (nextRandom(4)) { case 0: direction = RULE_DIRECTION.UP break case 1: direction = RULE_DIRECTION.DOWN break case 2: direction = RULE_DIRECTION.LEFT break case 3: direction = RULE_DIRECTION.RIGHT break default: throw new Error(`BUG: invalid random number chosen`) } } else { // a direction was already set return { didActuallyChange: false } } } return { didActuallyChange: cell.setWantsToMoveCollisionLayer(this.collisionLayer, direction) } } } export class SimpleNeighbor extends BaseForLines implements ICacheable { public readonly _tilesWithModifier: Set public spritesPresent: SpriteBitSet public anySpritesPresent: Set private brackets: Map> private debugFlag: Optional private staticCache: Map, replaceDirections: Set}> private cacheYesBitSets: Map private cacheNoBitSets: Map private cacheDirections: Map private cacheMultiCollisionLayerTiles: Set private spritesMissing: SpriteBitSet private spriteMovementsPresent: Map private orTileMovementsPresent: Map private lruCache: LruCache private trickleCells: Set constructor(source: IGameCode, tilesWithModifier: Set, debugFlag: Optional) { super(source) // this.alreadyReportedMismatch = false this._tilesWithModifier = tilesWithModifier this.brackets = new Map() // this._localCellCache = new Map() this.debugFlag = debugFlag this.staticCache = new Map() this.spritesPresent = new SpriteBitSet() this.spritesMissing = new SpriteBitSet() this.anySpritesPresent = new Set() this.spriteMovementsPresent = new Map() this.orTileMovementsPresent = new Map() this.trickleCells = new Set() this.lruCache = new LruCache(LRU_CACHE_SIZE) // Build up the cache BitSet for each collisionLayer this.cacheYesBitSets = new Map() this.cacheNoBitSets = new Map() this.cacheDirections = new Map() this.cacheMultiCollisionLayerTiles = new Set() const allTiles = [...tilesWithModifier] const noTiles = allTiles.filter((t) => t.isNo()) const yesTiles = allTiles.filter((t) => !t.isNo()) for (const t of yesTiles) { if (t._tile.hasSingleCollisionLayer()) { for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (t._direction) { this.cacheDirections.set(c, t._direction) } let yesBitSet = this.cacheYesBitSets.get(c) if (!yesBitSet) { yesBitSet = new BitSet2() as BitSet this.cacheYesBitSets.set(c, yesBitSet) } yesBitSet.set(c.getBitSetIndexOf(sprite)) } } else { this.cacheMultiCollisionLayerTiles.add(t) } if (t._tile.isOr()) { this.anySpritesPresent.add(new SpriteBitSet(t._tile.getSprites())) if (t._direction) { this.orTileMovementsPresent.set(t._tile, t._direction) } } else { this.spritesPresent.addAll(t._tile.getSprites()) for (const sprite of t._tile.getSprites()) { if (t._direction) { const prevDir = this.spriteMovementsPresent.get(sprite.getCollisionLayer()) if (prevDir && prevDir !== t._direction) { throw new Error(`BUG??? prev=${prevDir} ${t._direction}`) } this.spriteMovementsPresent.set(sprite.getCollisionLayer(), t._direction) } } } } for (const t of noTiles) { if (t._tile.hasSingleCollisionLayer()) { for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (t._direction) { throw new Error(`BUG: Unreachable code`) } let noBitSet = this.cacheNoBitSets.get(c) if (!noBitSet) { noBitSet = new BitSet2() as BitSet this.cacheNoBitSets.set(c, noBitSet) } noBitSet.set(c.getBitSetIndexOf(sprite)) } } else { this.cacheMultiCollisionLayerTiles.add(t) } if (t._tile.isOr()) { // NO Color === NO Red NO Green NO Blue this.spritesMissing.addAll(t._tile.getSprites()) } else { this.spritesMissing.addAll(t._tile.getSprites()) } } } public toKey() { return `{${[...this._tilesWithModifier].map((t) => t.toKey()).sort().join(' ')} debugging?${!!this.debugFlag}}` } public dependsOnDirection() { return !![...this._tilesWithModifier].find((t) => !!t._direction) } public prepareAction(actionNeighbor: SimpleNeighbor) { if (process.env.NODE_ENV === 'development' && actionNeighbor.debugFlag === DEBUG_FLAG.BREAKPOINT) { // Pausing here because this breakpoint was marked in the game code debugger // tslint:disable-line:no-debugger } if (this.staticCache.has(actionNeighbor)) { return } // Compute the Mutators on-the-fly for now.... const pairsByCollisionLayer = new Map>() const orTiles = new Map() for (const t of this._tilesWithModifier) { if (t._tile.isOr() && !t._tile.hasSingleCollisionLayer()) { if (!t.isNo()) { orTiles.set(t._tile, t) } } else { // AND Tiles can have multiple collisionLayers too... if (t._tile.hasSingleCollisionLayer()) { const c = t._tile.getCollisionLayer() if (!c) { throw new Error(`BUG: Tile is not assigned to a collision layer\n${t._tile.toString()}`) } // If we have something like `[Player NO PlayerHold] -> ...` then keep the Player, not the PlayerHold if (pairsByCollisionLayer.has(c)) { // Determine whether to keep the 1st match or the current one. // If the current one is a NO tile then definitely do not replace it. // Maybe the correct thing to do is to always keep the 1st thing put in // if (!t.isNo()) { // pairsByCollisionLayer.set(c, new ExtraPair(t, null/*filled in later if there is an action*/, false/*okToIgnoreNonMatches*/)) // } } else { pairsByCollisionLayer.set(c, new ExtraPair(t, null/*filled in later if there is an action*/, false/*okToIgnoreNonMatches*/)) } } else { // loop over each collisionLayer for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (!pairsByCollisionLayer.has(c)) { // TODO: Should we ues the whole tileWithModifier or create a new one out of the sprite? pairsByCollisionLayer.set(c, new ExtraPair(t, null/*filled in later if there is an action*/, false/*okToIgnoreNonMatches*/)) } } } } } // First just pair up all the conditions and actions (keep the negations) // Then, remove all negations // Then, build the ReplaceTile and ReplaceDirections const unmatchedOrTiles = new Map(orTiles.entries()) for (const t of actionNeighbor._tilesWithModifier) { if (t._tile.isOr() && !t._tile.hasSingleCollisionLayer()) { // OR tiles may belong to different collisionlayers so... it's complicated const orTile = orTiles.get(t._tile) if (orTile) { unmatchedOrTiles.delete(t._tile) // simple case. at most we just change direction const conditionT = orTile if (conditionT._direction !== t._direction) { for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (!pairsByCollisionLayer.has(c)) { pairsByCollisionLayer.set(c, new ExtraPair( new SimpleTileWithModifier(conditionT.__source, conditionT._isNegated /*since the action side is a NO */, conditionT._isRandom/*isRandom*/, conditionT._direction, sprite, conditionT._debugFlag), new SimpleTileWithModifier(t.__source, t._isNegated /*since the action side is a NO */, t._isRandom/*isRandom*/, t._direction, sprite, t._debugFlag), true/*okToIgnoreNonMatches*/)) } } } } else { if (t.isNo()) { for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (!pairsByCollisionLayer.has(c)) { const t2 = new SimpleTileWithModifier(t.__source, false /*since the action side is a NO */, t._isRandom/*isRandom*/, t._direction, t._tile, t._debugFlag) pairsByCollisionLayer.set(c, new ExtraPair(t2, null, true/*okToIgnoreNonMatches*/)) } } } else { for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (!pairsByCollisionLayer.has(c)) { const t2 = new SimpleTileWithModifier(t.__source, false /*since the action side is NOT? NO */, t._isRandom/*isRandom*/, t._direction, t._tile, t._debugFlag) pairsByCollisionLayer.set(c, new ExtraPair(null, t2, true/*okToIgnoreNonMatches*/)) } } } } } else { for (const c of t.getCollisionLayers()) { if (!c) { throw new Error(`BUG: Tile is not assigned to a collision layer.\n${t._tile.toString()}`) } // if the condition is the same as the action then it's a no-op and we can remove the code const p = pairsByCollisionLayer.get(c) const conditionVersion = (p && p.condition) || null if (conditionVersion && conditionVersion.equals(t)) { // condition and action are the same. No need to add a Pair pairsByCollisionLayer.delete(c) } else { if (t.isNo()) { // set it to be null (removed) if (p) { // just leave the action side as null (so it's removed) if (p.condition === t) { throw new Error(`BUG: Unreachable code`) } } else { // we need to set the condition side to be the tile so that it is removed // (it might not exist in the cell though but that's an optimization for later) const t2 = new SimpleTileWithModifier(t.__source, false /*since the action side is a NO */, false/*isRandom*/, t._direction, t._tile, t._debugFlag) pairsByCollisionLayer.set(c, new ExtraPair(t2, null, true/*okToIgnoreNonMatches*/)) } } else { if (p) { p.action = t } else { pairsByCollisionLayer.set(c, new ExtraPair(null, t, false/*okToIgnoreNonMatches*/)) } } } } } } // Any unmatched OR tiles need to be removed from the Cell if (unmatchedOrTiles.size > 0) { for (const t of unmatchedOrTiles.values()) { for (const sprite of t._tile.getSprites()) { const c = sprite.getCollisionLayer() if (!pairsByCollisionLayer.has(c)) { const t2 = new SimpleTileWithModifier(t.__source, false /*since the action side is a NO */, false/*isRandom*/, t._direction, t._tile, t._debugFlag) pairsByCollisionLayer.set(c, new ExtraPair(t2, null, true/*okToIgnoreNonMatches*/)) } } } } const replaceTiles = new Set() const replaceDirections = new Set() for (const [collisionLayer, { condition, action, extra }] of pairsByCollisionLayer.entries()) { if (condition && action) { if (condition !== action) { // Could be `[ TrolleyFull no CleanDishes] -> [TrolleyEmpty no CleanDishes ]` let newDirection = null if (condition._direction !== action._direction || condition.isNo()) { newDirection = action._direction || RULE_DIRECTION.STATIONARY } if (!condition._tile.equals(action._tile) || condition.isNo()) { replaceTiles.add(new ReplaceTile(collisionLayer, action, extra, null, newDirection)) } else if (newDirection) { replaceDirections.add(new ReplaceDirection(collisionLayer, newDirection, extra)) } } } else if (condition) { if (!condition.isNo()) { replaceTiles.add(new ReplaceTile(collisionLayer, null, extra, condition, null)) } } else if (action) { if (!action.isNo()) { replaceTiles.add(new ReplaceTile(collisionLayer, action, extra, null, action._direction || RULE_DIRECTION.STATIONARY)) } } } this.staticCache.set(actionNeighbor, { replaceTiles, replaceDirections }) } public evaluate(actionNeighbor: SimpleNeighbor, cell: Cell, magicOrTiles: Map>) { if (process.env.NODE_ENV === 'development' && actionNeighbor.debugFlag === DEBUG_FLAG.BREAKPOINT) { // Pausing here because this breakpoint was marked in the game code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } const r = this.staticCache.get(actionNeighbor) if (!r) { throw new Error('BUG: Missing actionNeighbor. Should have been prepared before') } const { replaceTiles, replaceDirections } = r let didChangeSprites = false let didChangeDirection = false const orTilesRemoved = new Set() let allMessages: Array> = [] for (const replaceTile of replaceTiles) { const { didActuallyChange, messages } = replaceTile.replace(cell, magicOrTiles, orTilesRemoved) if (didActuallyChange) { allMessages = [...allMessages, ...messages] } didChangeSprites = didChangeSprites || didActuallyChange || false } for (const replaceDirection of replaceDirections) { const { didActuallyChange } = replaceDirection.replace(cell) didChangeDirection = didChangeDirection || didActuallyChange } // TODO: Be better about recording when the cell actually updated if (didChangeSprites || didChangeDirection) { return new CellMutation(cell, allMessages) } else { return null } } public clearCaches() { // this._localCellCache.clear() for (const t of this._tilesWithModifier) { t.clearCaches() } } // set this ahead of time becuase order does not matter when populating the magicOrTiles `[ > Player | Pill ] -> [ Pill OldPos | Player ]` public populateMagicOrTiles(cell: Cell, magicOrTiles: Map>) { for (const t of this._tilesWithModifier) { if (!t.isNo() && t._tile.isOr()) { const sprites = setIntersection(new Set(t._tile.getSprites()), cell.getSpritesAsSet()) magicOrTiles.set(t._tile, sprites) } } } public subscribeToTileChanges(bracket: ISimpleBracket, index: number) { // add the bracket and then subscribe the tiles let b = this.brackets.get(bracket) if (!b) { b = new Set() this.brackets.set(bracket, b) } b.add(index) this._tilesWithModifier.forEach((t) => { t.subscribeToCellChanges(this) }) } public matchesCellSimple(cell: Cell) { return this.matchesCell(cell, null, null) } public addCells(t: SimpleTileWithModifier, sprite: GameSprite, cells: Iterable, wantsToMove: Optional) { if (process.env.NODE_ENV === 'development' && this.debugFlag === DEBUG_FLAG.BREAKPOINT) { // Pausing here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } for (const cell of cells) { if (this.matchesTiles(cell)) { this.trickleCells.add(cell) for (const [bracket, indexes] of this.brackets.entries()) { for (const index of indexes) { bracket.addCell(index, this, t, sprite, cell, wantsToMove) } } } else if (this.trickleCells.has(cell)) { throw new Error(`BUG: Should be unreachable`) } } } public updateCells(t: SimpleTileWithModifier, sprite: GameSprite, cells: Iterable, wantsToMove: RULE_DIRECTION) { this.addCells(t, sprite, cells, wantsToMove) } public removeCells(t: SimpleTileWithModifier, sprite: GameSprite, cells: Iterable) { if (process.env.NODE_ENV === 'development' && this.debugFlag === DEBUG_FLAG.BREAKPOINT_REMOVE) { // Pausing here because it was marked in the code // if (process.stdout) { TerminalUI.debugRenderScreen() } debugger // tslint:disable-line:no-debugger } for (const cell of cells) { // Check if the cell still matches. If not, remove it from upstream // It's a little funky if we have a NO tile. I _think_ we need to negate the // result of matchesCellWithout in that case but not completely sure if (!this.matchesTiles(cell)) { this.trickleCells.delete(cell) // remove it from upstream for (const [bracket, indexes] of this.brackets.entries()) { for (const index of indexes) { bracket.removeCell(index, this, t, sprite, cell) } } } } } private check1(cell: Cell) { return cell.spriteBitSet.containsNone(this.spritesMissing) && cell.spriteBitSet.containsAll(this.spritesPresent) } private check2(cell: Cell) { for (const anySpritesPresent of this.anySpritesPresent) { if (!cell.spriteBitSet.containsAny(anySpritesPresent)) { return false } } return true } private check3(cell: Cell) { // Check CollisionLayers // TODO: Move this into the Cell definition for (const [collisionLayer, direction] of this.spriteMovementsPresent) { const cellDir = cell.getCollisionLayerWantsToMove(collisionLayer) if (direction !== cellDir) { return false } } return true } private check4(cell: Cell) { for (const [orTile, direction] of this.orTileMovementsPresent) { if (orTile.hasSingleCollisionLayer()) { const cellDir = cell.getCollisionLayerWantsToMove(orTile.getCollisionLayer()) if (direction !== cellDir) { return false } } else { // find which sprite in the OR tile matched and get its direction let foundSprite = false // the OR tile can match multiple sprites so make sure at least one matched (not all) // e.g: // Movable = Player OR Island // Rule: [ LEFT Movable ] // Cell: STATIONARY Player LEFT Island let didMatch = false for (const sprite of orTile.getSprites()) { if (cell.spriteBitSet.has(sprite)) { foundSprite = true const cellDir = cell.getCollisionLayerWantsToMove(sprite.getCollisionLayer()) if (direction === cellDir) { didMatch = true } } } if (!didMatch) { return false } if (!foundSprite) { throw new Error(`BUG: Could not find sprite. One should have already been matched before`) } } } return true } private matchesCell(cell: Cell, tileWithModifier: Optional, wantsToMove: Optional) { return this.lruCache.get(`[${cell.toKey()}]`, () => this.check1(cell) && this.check2(cell) && this.check3(cell) && this.check4(cell) ) } private matchesTiles(cell: Cell) { for (const t of this._tilesWithModifier) { if (!t.hasCell(cell)) { return false } } return true } }