puzzlescript
Version:
Play PuzzleScript games in your terminal!
1,269 lines (1,160 loc) • 87.9 kB
text/typescript
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<A> {
public readonly condition: Optional<A>
public action: Optional<A>
constructor(condition: Optional<A>, action: Optional<A>) {
this.condition = condition
this.action = action
}
}
class ExtraPair<A> extends BracketPair<A> {
public readonly extra: boolean
constructor(condition: Optional<A>, action: Optional<A>, 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<Cell>) => void
totalTimeMs?: number
timesRan?: number
a11yGetConditionSprites(): Array<Set<GameSprite>>
toKey(): string
}
export interface IMutation {
messages: Array<A11Y_MESSAGE<Cell, GameSprite>>
hasCell: () => boolean
getCell: () => Cell
getCommand: () => Command<SoundItem<IGameTile>>
}
class CellMutation implements IMutation {
public readonly messages: Array<A11Y_MESSAGE<Cell, GameSprite>>
private cell: Cell
constructor(cell: Cell, messages: Array<A11Y_MESSAGE<Cell, GameSprite>>) {
this.cell = cell
this.messages = messages
}
public hasCell() { return true }
public getCell() { return this.cell }
public getCommand(): Command<SoundItem<IGameTile>> {
throw new Error(`BUG: check hasCommand first`)
}
}
class CommandMutation implements IMutation {
public readonly messages: Array<A11Y_MESSAGE<Cell, GameSprite>>
private command: Command<SoundItem<IGameTile>>
constructor(command: Command<SoundItem<IGameTile>>) {
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<T>(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<SoundItem<IGameTile>>) {
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<Cell>) {
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<Set<GameSprite>> = []
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<Command<SoundItem<IGameTile>>>
public debugFlag: Optional<DEBUG_FLAG>
// private evaluationDirection: RULE_DIRECTION
private _isLate: boolean
private readonly isRigid: boolean
private isSubscribedToCellChanges: boolean
constructor(source: IGameCode,
conditionBrackets: ISimpleBracket[], actionBrackets: ISimpleBracket[],
commands: Array<Command<SoundItem<IGameTile>>>, isLate: boolean, isRigid: boolean, debugFlag: Optional<DEBUG_FLAG>) {
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<Cell>) {
for (const bracket of this.conditionBrackets) {
bracket.addCellsToEmptyRules(cells)
}
}
public a11yGetConditionSprites() {
let sprites: Array<Set<GameSprite>> = []
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<RULE_DIRECTION>
public readonly _tile: IGameTile
public readonly _debugFlag: Optional<DEBUG_FLAG>
private neighbors: Set<SimpleNeighbor>
private trickleCells: Set<Cell>
constructor(source: IGameCode, isNegated: boolean, isRandom: boolean, direction: Optional<RULE_DIRECTION>, tile: IGameTile, debugFlag: Optional<DEBUG_FLAG>) {
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<CollisionLayer>()
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<RULE_DIRECTION>) {
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<RULE_DIRECTION>) {
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<RULE_DIRECTION>) {
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<RULE_DIRECTION>) {
return this.matchesCellWantsToMove(cells[0], wantsToMove)
}
}
export abstract class ISimpleBracket extends BaseForLines implements ICacheable {
public readonly debugFlag: Optional<DEBUG_FLAG>
public readonly direction: RULE_DIRECTION
protected firstCells: Set<Cell>
private allNeighbors: SimpleNeighbor[]
constructor(source: IGameCode, direction: RULE_DIRECTION, allNeighbors: SimpleNeighbor[], debugFlag: Optional<DEBUG_FLAG>) {
super(source)
this.direction = direction
this.debugFlag = debugFlag
this.allNeighbors = allNeighbors
this.firstCells = new Set<Cell>()
}
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<RULE_DIRECTION>): void
public abstract removeCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell): void
public abstract addCellsToEmptyRules(cells: Iterable<Cell>): void
public abstract getMatches(level: Level, actionBracket: Optional<ISimpleBracket>): MatchedCellsForRule[]
public abstract a11yGetSprites(): Array<Set<GameSprite>>
public _getAllNeighbors() {
return this.allNeighbors
}
public hasMatches(level: Level, actionBracket: Optional<ISimpleBracket>) {
return this.getMatches(level, actionBracket).length > 0
}
}
interface IMatchedCellAndCorrespondingNeighbors {
cell: Cell,
condition: SimpleNeighbor,
action: Optional<SimpleNeighbor>
}
class MatchedCellsForRule {
public readonly cellsAndNeighbors: IMatchedCellAndCorrespondingNeighbors[]
private cellKeys: Map<Cell, string>
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<IGameTile, Set<GameSprite>>) {
// 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<IGameTile, Set<GameSprite>>) {
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<DEBUG_FLAG>
private neighbors: SimpleNeighbor[]
private ellipsisBracketListeners: Map<SimpleEllipsisBracket, BEFORE_OR_AFTER>
private readonly spritesPresentInRowOrColumn: SpriteBitSet
private readonly anySpritesPresentInRowOrColumn: SpriteBitSet
constructor(source: IGameCode, direction: RULE_DIRECTION, neighbors: SimpleNeighbor[], debugFlag: Optional<DEBUG_FLAG>) {
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<Cell>) {
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<RULE_DIRECTION>) {
// 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<SimpleBracket>) {
const matches: MatchedCellsForRule[] = []
for (const firstCell of this.firstCells) {
this.addToCellMatches(matches, firstCell, actionBracket)
}
return matches
}
public getMatchesByLooping(level: Level, actionBracket: Optional<SimpleBracket>) {
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<SimpleBracket>) {
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<GameSprite>()
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> = 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> = 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> = 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> = 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<SimpleBracket>) {
const cellMatches = []
let curCell: Optional<Cell> = 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<SimpleBracket>) {
if (this.neighbors[0].matchesCellSimple(cell) && this.matchesDownstream(cell, 0)) {
this.addToCellMatches(matches, cell, actionBracket)
}
}
}
enum BEFORE_OR_AFTER {
BEFORE,
AFTER
}
class MultiMap<A, B> {
private map: Map<A, Set<B>>
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<Cell, Cell> // 1 before may have many afters
constructor(source: IGameCode, direction: RULE_DIRECTION, beforeEllipsisNeighbors: SimpleNeighbor[], afterEllipsisNeighbors: SimpleNeighbor[], debugFlag: Optional<DEBUG_FLAG>) {
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<Cell>) {
this.beforeEllipsisBracket.addCellsToEmptyRules(cells)
this.afterEllipsisBracket.addCellsToEmptyRules(cells)
}
public addCell(index: number, neighbor: SimpleNeighbor, t: SimpleTileWithModifier, sprite: GameSprite, cell: Cell, wantsToMove: Optional<RULE_DIRECTION>) {
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.si