explodorama / files / test / oil-slide.test.ts
oil-slide.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { planIntents } from '../src/sim/enemies' |
| 3 | import { autoMoveAction, safeStep } from '../src/sim/path' |
| 4 | import { predictDanger } from '../src/sim/danger' |
| 5 | import { step } from '../src/sim/turn' |
| 6 | import { board, v } from './helpers' |
| 7 | |
| 8 | describe('oil is slippery (user request, 2026-10-31)', () => { |
| 9 | it('a step onto oil slides the player to the last oiled tile of the line', () => { |
| 10 | const s = board(` |
| 11 | ######### |
| 12 | #P.~~~.## |
| 13 | #########`) |
| 14 | const r = step(s, { id: 'move', target: v(2, 1) }) |
| 15 | expect(r.state.player).toEqual(v(2, 1)) |
| 16 | const t = step(r.state, { id: 'move', target: v(3, 1) }) |
| 17 | expect(t.state.player).toEqual(v(5, 1)) |
| 18 | expect(t.events).toContainEqual({ type: 'oilSlid', from: v(3, 1), to: v(5, 1) }) |
| 19 | }) |
| 20 | |
| 21 | it('stops on the last oiled tile, not at the wall or the next plain tile', () => { |
| 22 | const s = board(` |
| 23 | ####### |
| 24 | #P~~..# |
| 25 | #######`) |
| 26 | expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(3, 1)) |
| 27 | }) |
| 28 | |
| 29 | it('goes straight on: it does not follow a bend in the spill', () => { |
| 30 | const s = board(` |
| 31 | ##### |
| 32 | #P~.# |
| 33 | #.~.# |
| 34 | #####`) |
| 35 | expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(2, 1)) |
| 36 | }) |
| 37 | |
| 38 | it('leaving oil, or stepping on a lone oil tile, does not slide', () => { |
| 39 | const s = board(` |
| 40 | ##### |
| 41 | #~P.# |
| 42 | #####`) |
| 43 | expect(step(s, { id: 'move', target: v(3, 1) }).state.player).toEqual(v(3, 1)) |
| 44 | expect(step(s, { id: 'move', target: v(1, 1) }).state.player).toEqual(v(1, 1)) |
| 45 | }) |
| 46 | |
| 47 | it('stops before a bomb, and before an enemy', () => { |
| 48 | const bomb = board(` |
| 49 | ####### |
| 50 | #P~~1.# |
| 51 | #######`) |
| 52 | bomb.bombs[0]!.timer = 5 |
| 53 | expect(step(bomb, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(3, 1)) |
| 54 | const foe = board(` |
| 55 | ####### |
| 56 | #P~~B.# |
| 57 | #######`) |
| 58 | expect(step(foe, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(3, 1)) |
| 59 | }) |
| 60 | |
| 61 | it('a run that lands on oil slides on', () => { |
| 62 | const s = board(` |
| 63 | ########## |
| 64 | #P.~~~~..# |
| 65 | ##########`) |
| 66 | s.powers.run = true |
| 67 | expect(step(s, { id: 'run', target: v(3, 1) }).state.player).toEqual(v(6, 1)) |
| 68 | }) |
| 69 | |
| 70 | it('picks up what lies on the oil it slides over', () => { |
| 71 | const s = board(` |
| 72 | ######### |
| 73 | #P~~~~..# |
| 74 | #########`) |
| 75 | s.drops.push({ pos: v(3, 1), kind: 'coins', amount: 1 }) |
| 76 | const r = step(s, { id: 'move', target: v(2, 1) }) |
| 77 | expect(r.state.player).toEqual(v(5, 1)) |
| 78 | expect(r.state.coins).toBe(1) |
| 79 | }) |
| 80 | |
| 81 | it('a slide that ends in flame kills', () => { |
| 82 | const s = board(` |
| 83 | ######### |
| 84 | #P~~~...# |
| 85 | #########`) |
| 86 | s.bombs.push({ pos: v(5, 1), timer: 1, fire: 2, placedTurn: 0 }) |
| 87 | // The bomb's flame (and the oil it lights) is where the slide ends. |
| 88 | const r = step(s, { id: 'move', target: v(2, 1) }) |
| 89 | expect(r.state.status).toBe('dead') |
| 90 | }) |
| 91 | |
| 92 | it('a basic enemy planning a step onto oil announces the whole slide', () => { |
| 93 | const s = board(` |
| 94 | ########## |
| 95 | #D~~~..P## |
| 96 | ##########`) |
| 97 | planIntents(s) |
| 98 | expect(s.enemies[0]!.plan[0]).toEqual(v(4, 1)) |
| 99 | }) |
| 100 | |
| 101 | it('moves along the whole announced slide', () => { |
| 102 | const s = board(` |
| 103 | ########## |
| 104 | #D~~~..P## |
| 105 | ##########`) |
| 106 | s.enemies[0]!.plan = [v(4, 1)] |
| 107 | const r = step(s, { id: 'wait' }) |
| 108 | expect(r.state.enemies[0]!.pos).toEqual(v(4, 1)) |
| 109 | }) |
| 110 | |
| 111 | it('a Ghost or Wisp flies over oil without slipping', () => { |
| 112 | const s = board(` |
| 113 | ########## |
| 114 | #G~~~..P## |
| 115 | ##########`) |
| 116 | s.enemies[0]!.plan = [v(2, 1)] |
| 117 | const r = step(s, { id: 'wait' }) |
| 118 | expect(r.state.enemies[0]!.pos).toEqual(v(2, 1)) |
| 119 | }) |
| 120 | |
| 121 | it('an enemy that slides through the player catches them there', () => { |
| 122 | const s = board(` |
| 123 | ########## |
| 124 | #D~~~~...# |
| 125 | #####P#### |
| 126 | ##########`) |
| 127 | s.player = v(3, 1) |
| 128 | s.enemies[0]!.plan = [v(5, 1)] |
| 129 | const r = step(s, { id: 'wait' }) |
| 130 | expect(r.state.status).toBe('dead') |
| 131 | expect(r.state.enemies[0]!.pos).toEqual(v(3, 1)) |
| 132 | }) |
| 133 | |
| 134 | it('a plan that no longer matches the oil is dropped and planned again', () => { |
| 135 | const s = board(` |
| 136 | ########## |
| 137 | #D~~~..P## |
| 138 | ##########`) |
| 139 | s.enemies[0]!.plan = [v(4, 1)] |
| 140 | s.oil = [] |
| 141 | planIntents(s) |
| 142 | expect(s.enemies[0]!.plan[0]).not.toEqual(v(4, 1)) |
| 143 | }) |
| 144 | |
| 145 | it('walking onto an enemy that stands on oil is a death, even though the slide carries on', () => { |
| 146 | const s = board(` |
| 147 | ####### |
| 148 | #PB~~.# |
| 149 | #######`) |
| 150 | s.oil.push(2 + 7) |
| 151 | const r = step(s, { id: 'move', target: v(2, 1) }) |
| 152 | expect(r.state.status).toBe('dead') |
| 153 | expect(r.state.death?.cause).toBe('walkedIntoEnemy') |
| 154 | }) |
| 155 | |
| 156 | it('a diagonal step onto oil does not slide', () => { |
| 157 | const s = board(` |
| 158 | ##### |
| 159 | #P..# |
| 160 | #.~~# |
| 161 | #####`) |
| 162 | s.powers.diagonal = true |
| 163 | expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2)) |
| 164 | }) |
| 165 | |
| 166 | it('with Bomb Pass the slide goes over a bomb', () => { |
| 167 | const s = board(` |
| 168 | ####### |
| 169 | #P~~5.# |
| 170 | #######`) |
| 171 | s.oil.push(4 + 7) |
| 172 | s.powers.bombPass = true |
| 173 | expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(4, 1)) |
| 174 | }) |
| 175 | |
| 176 | it('stops on the open exit, which clears the level', () => { |
| 177 | const s = board(` |
| 178 | ####### |
| 179 | #P~~E.# |
| 180 | #######`) |
| 181 | s.oil.push(4 + 7) |
| 182 | const r = step(s, { id: 'move', target: v(2, 1) }) |
| 183 | expect(r.state.player).toEqual(v(4, 1)) |
| 184 | expect(r.state.status).toBe('cleared') |
| 185 | }) |
| 186 | |
| 187 | it('lingering flame on the way burns the player', () => { |
| 188 | const s = board(` |
| 189 | ####### |
| 190 | #P~~~.# |
| 191 | #######`) |
| 192 | s.burning = [3 + 7] |
| 193 | expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') |
| 194 | }) |
| 195 | |
| 196 | it('a Sprinter’s planned dash that lands on oil slides on, and the plan is kept while valid', () => { |
| 197 | const s = board(` |
| 198 | ########### |
| 199 | #R.~~~..P## |
| 200 | ###########`) |
| 201 | const e = s.enemies[0]! |
| 202 | e.plan = [v(1, 1), v(5, 1)] |
| 203 | planIntents(s) |
| 204 | expect(e.plan.slice(0, 2)).toEqual([v(1, 1), v(5, 1)]) |
| 205 | }) |
| 206 | |
| 207 | it('auto-move keeps off a step that would slide, so it never bounces or slides onto the exit', () => { |
| 208 | const s = board(` |
| 209 | ######### |
| 210 | #P.~~~~.# |
| 211 | #########`) |
| 212 | const danger = predictDanger(s) |
| 213 | expect(autoMoveAction(s, v(4, 1), (p) => safeStep(s, danger, p))).toBeNull() |
| 214 | const e = board(` |
| 215 | ########## |
| 216 | #P~~E...## |
| 217 | ####.##### |
| 218 | ##########`) |
| 219 | e.oil.push(4 + 10) |
| 220 | const act = autoMoveAction(e, v(4, 2), () => true) |
| 221 | expect(act).toBeNull() |
| 222 | }) |
| 223 | |
| 224 | it('a Ghost’s plan is not slid when planned', () => { |
| 225 | const s = board(` |
| 226 | ########## |
| 227 | #G~~~..P## |
| 228 | ##########`) |
| 229 | s.enemies[0]!.dir = 'right' |
| 230 | planIntents(s) |
| 231 | for (const p of s.enemies[0]!.plan) expect(p.x).toBeLessThanOrEqual(2) |
| 232 | }) |
| 233 | }) |
| 234 | |
| 235 | describe('two gems on one turn (user report, 2026-10-31)', () => { |
| 236 | it('a slide over two gems offers them one after the other', () => { |
| 237 | const s = board(` |
| 238 | ######## |
| 239 | #P.....# |
| 240 | ########`) |
| 241 | s.powers.slide = true |
| 242 | s.gems = [{ pos: v(2, 1), color: 'red' }, { pos: v(3, 1), color: 'red' }] |
| 243 | const r = step(s, { id: 'slide', target: v(6, 1) }) |
| 244 | expect(r.state.offer).not.toBeNull() |
| 245 | expect(r.state.waitingGems).toHaveLength(1) |
| 246 | expect(r.state.gems).toHaveLength(0) |
| 247 | const first = r.state.offer![0]! |
| 248 | const t = step(r.state, { id: 'choose', kind: first }) |
| 249 | expect(t.state.offer).not.toBeNull() |
| 250 | expect(t.state.waitingGems).toHaveLength(0) |
| 251 | expect(t.events.some((e) => e.type === 'offerAgain')).toBe(true) |
| 252 | const u = step(t.state, { id: 'choose', kind: t.state.offer![0]! }) |
| 253 | expect(u.state.offer).toBeNull() |
| 254 | }) |
| 255 | }) |
| 256 | |
| 257 | describe('two gems, more cases (reviewer, 2026-10-31)', () => { |
| 258 | it('a slide over two gems onto the open exit still asks for both', () => { |
| 259 | const s = board(` |
| 260 | ######### |
| 261 | #P.%.%.E# |
| 262 | #########`) |
| 263 | s.powers.slide = true |
| 264 | const r = step(s, { id: 'slide', target: v(7, 1) }) |
| 265 | expect(r.state.status).toBe('cleared') |
| 266 | expect(r.state.offer).not.toBeNull() |
| 267 | const t = step(r.state, { id: 'choose', kind: r.state.offer![0]! }) |
| 268 | expect(t.state.offer).not.toBeNull() |
| 269 | expect(t.events.filter((e) => e.type === 'offerAgain')).toHaveLength(1) |
| 270 | expect(r.events.filter((e) => e.type === 'gemQueued')).toHaveLength(1) |
| 271 | }) |
| 272 | |
| 273 | it('dying on the turn two gems are taken drops both', () => { |
| 274 | const s = board(` |
| 275 | ######### |
| 276 | #P%%1...# |
| 277 | #########`) |
| 278 | s.powers.slide = true |
| 279 | s.gems = [{ pos: v(2, 1), color: 'red' }, { pos: v(3, 1), color: 'red' }] |
| 280 | const r = step(s, { id: 'slide', target: v(3, 1) }) |
| 281 | expect(r.state.status).toBe('dead') |
| 282 | expect(r.state.offer).toBeNull() |
| 283 | expect(r.state.waitingGems).toEqual([]) |
| 284 | }) |
| 285 | }) |