explodorama / files / src / sim / enemies.ts
enemies.ts
c37a2b0b 2026-10-20 Pavel Safronov | 1 | import { Rng } from './rng' |
1f0038ac 2026-11-01 Pavel Safronov | 2 | import { add, bombAt, cellAt, eq, idx, oilSlide } from './state' |
82adee18 2026-10-20 Pavel Safronov | 3 | import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' |
c37a2b0b 2026-10-20 Pavel Safronov | 4 | |
565a6873 2026-10-25 Pavel Safronov | 5 | /** Ghosts and Wisps go through crates (soft blocks), as the NES's Kondoria and Ovapi did. */ |
| 6 | export const PASSES_CRATES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp']) | |
| 7 | ||
1f0038ac 2026-11-01 Pavel Safronov | 8 | /** Ghosts and Wisps fly: they don't slip on oil. Every other enemy slides over it (user request, 2026-10-31). */ |
| 9 | export const FLIES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp']) | |
| 10 | ||
565a6873 2026-10-25 Pavel Safronov | 11 | /** |
| 12 | * Ghosts and Sprinters move only every other turn, resting in between (a Sprinter dashes 2 tiles | |
| 13 | * when it moves). A rest is a planned "move" to the tile it is on. | |
| 14 | */ | |
| 15 | export const RESTS: ReadonlySet<EnemyKind> = new Set(['ghost', 'sprinter']) | |
| 16 | ||
| 17 | /** | |
| 18 | * Enemies walk on floor, Ghosts and Wisps through crates too, and all are stopped by bombs. They | |
| 19 | * don't block each other; they can share a tile. | |
| 20 | */ | |
| 21 | export function enemyCanEnter(s: GameState, p: Vec, kind?: EnemyKind): boolean { | |
| 22 | const cell = cellAt(s, p) | |
5f066960 2026-10-26 Pavel Safronov | 23 | // Spikes: every enemy keeps off them, up or down (PLAN.md §19.3). |
| 24 | return (cell === 'floor' || (cell === 'block' && !!kind && PASSES_CRATES.has(kind))) && !bombAt(s, p) && !s.spikes.some((sp) => sp.cell === idx(s, p)) | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Where enemies chase: the player, or while the player hides in a bush, where they were last seen | |
| 29 | * out of one (PLAN.md §19.3). | |
| 30 | */ | |
| 31 | export function chased(s: GameState): Vec { | |
| 32 | return s.bushes.includes(idx(s, s.player)) ? s.lastSeen : s.player | |
c37a2b0b 2026-10-20 Pavel Safronov | 33 | } |
| 34 | ||
| 35 | export function enemyAt(s: GameState, p: Vec): Enemy | undefined { | |
| 36 | return s.enemies.find((e) => eq(e.pos, p)) | |
| 37 | } | |
| 38 | ||
| 39 | interface Step { | |
| 40 | dir: Dir | |
| 41 | to: Vec | |
| 42 | } | |
| 43 | ||
82adee18 2026-10-20 Pavel Safronov | 44 | /** Where an enemy is, or will be, while its moves are planned ahead. */ |
| 45 | interface Walker { | |
565a6873 2026-10-25 Pavel Safronov | 46 | kind: EnemyKind |
82adee18 2026-10-20 Pavel Safronov | 47 | pos: Vec |
| 48 | dir: Dir | |
52d68b59 2026-10-24 Pavel Safronov | 49 | /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */ |
| 50 | crowded(p: Vec): boolean | |
82adee18 2026-10-20 Pavel Safronov | 51 | } |
| 52 | ||
565a6873 2026-10-25 Pavel Safronov | 53 | /** The direction of a step in a straight line (one tile, or a Sprinter's two); null for a rest. */ |
| 54 | export function dirBetween(from: Vec, to: Vec): Dir | null { | |
| 55 | const dx = Math.sign(to.x - from.x) | |
| 56 | const dy = Math.sign(to.y - from.y) | |
| 57 | return DIR_LIST.find((d) => DIRS[d].x === dx && DIRS[d].y === dy) ?? null | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * The tiles a planned move from `from` to `to` passes over and ends on: none for a rest, the one | |
1f0038ac 2026-11-01 Pavel Safronov | 62 | * tile for a step, every tile on the line for a Sprinter's dash or a slide over oil. |
565a6873 2026-10-25 Pavel Safronov | 63 | */ |
| 64 | export function moveTiles(from: Vec, to: Vec): Vec[] { | |
| 65 | const steps = Math.abs(to.x - from.x) + Math.abs(to.y - from.y) | |
1f0038ac 2026-11-01 Pavel Safronov | 66 | const dir = dirBetween(from, to) |
| 67 | if (!dir) return [] | |
| 68 | return Array.from({ length: steps }, (_, i) => ({ x: from.x + DIRS[dir].x * (i + 1), y: from.y + DIRS[dir].y * (i + 1) })) | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Where a move of `n` tiles from `from` heading `dir` ends for `kind`: those tiles, then on over | |
| 73 | * oil if it lands on some (not for a flier). Null if a tile on the way can't be entered. | |
| 74 | */ | |
| 75 | function moveEnd(s: GameState, kind: EnemyKind, from: Vec, dir: Dir, n: number): Vec | null { | |
| 76 | let land = from | |
| 77 | for (let i = 0; i < n; i++) { | |
| 78 | land = add(land, DIRS[dir]) | |
| 79 | if (!enemyCanEnter(s, land, kind)) return null | |
| 80 | } | |
| 81 | if (FLIES.has(kind)) return land | |
| 82 | return oilSlide(s, land, DIRS[dir], (q) => enemyCanEnter(s, q, kind)).at(-1) ?? land | |
565a6873 2026-10-25 Pavel Safronov | 83 | } |
| 84 | ||
| 85 | /** Whether a kept planned move is still one this enemy can make on the board as it is now. */ | |
| 86 | function stillPossible(s: GameState, kind: EnemyKind, from: Vec, to: Vec): boolean { | |
| 87 | const dx = Math.abs(to.x - from.x) | |
| 88 | const dy = Math.abs(to.y - from.y) | |
| 89 | if (dx + dy === 0) return RESTS.has(kind) | |
1f0038ac 2026-11-01 Pavel Safronov | 90 | if (dx && dy) return false |
| 91 | const dir = dirBetween(from, to)! | |
| 92 | // A step (a Sprinter's dash too), with the slide over oil it may end in. | |
| 93 | return (kind === 'sprinter' ? [1, 2] : [1]).some((n) => { | |
| 94 | const end = moveEnd(s, kind, from, dir, n) | |
| 95 | return !!end && eq(end, to) | |
| 96 | }) | |
82adee18 2026-10-20 Pavel Safronov | 97 | } |
| 98 | ||
| 99 | /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */ | |
| 100 | export const PLAN_AHEAD = 3 | |
| 101 | ||
565a6873 2026-10-25 Pavel Safronov | 102 | function openSteps(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step[] { |
| 103 | return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to, w.kind)) | |
c37a2b0b 2026-10-20 Pavel Safronov | 104 | } |
| 105 | ||
52d68b59 2026-10-24 Pavel Safronov | 106 | /** |
| 107 | * The open steps that keep clear of the other enemies (user request, 2026-10-23: enemies on one | |
| 108 | * tile went everywhere together). Only when every open step is crowded is a crowded one used. | |
| 109 | */ | |
| 110 | function spreadSteps(s: GameState, w: Walker): Step[] { | |
| 111 | const steps = openSteps(s, w) | |
| 112 | const clear = steps.filter((st) => !w.crowded(st.to)) | |
| 113 | return clear.length ? clear : steps | |
| 114 | } | |
| 115 | ||
c37a2b0b 2026-10-20 Pavel Safronov | 116 | /** Blob: carries on the way it was going three times in four, otherwise any open way. */ |
82adee18 2026-10-20 Pavel Safronov | 117 | function wander(s: GameState, w: Walker, rng: Rng): Step | null { |
52d68b59 2026-10-24 Pavel Safronov | 118 | const steps = spreadSteps(s, w) |
82adee18 2026-10-20 Pavel Safronov | 119 | const ahead = steps.find((st) => st.dir === w.dir) |
c37a2b0b 2026-10-20 Pavel Safronov | 120 | if (ahead && rng.next() < 0.75) return ahead |
| 121 | return steps.length ? steps[rng.int(steps.length)]! : null | |
| 122 | } | |
| 123 | ||
52d68b59 2026-10-24 Pavel Safronov | 124 | /** Drifter: straight on until blocked (or the way ahead is another enemy's), then any open way. */ |
82adee18 2026-10-20 Pavel Safronov | 125 | function drift(s: GameState, w: Walker, rng: Rng): Step | null { |
52d68b59 2026-10-24 Pavel Safronov | 126 | const steps = spreadSteps(s, w) |
82adee18 2026-10-20 Pavel Safronov | 127 | return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null) |
c37a2b0b 2026-10-20 Pavel Safronov | 128 | } |
| 129 | ||
82adee18 2026-10-20 Pavel Safronov | 130 | /** |
565a6873 2026-10-25 Pavel Safronov | 131 | * The first step of a shortest path over tiles this enemy can enter (through crates, for a |
| 132 | * Ghost) toward what they chase: the decoy while there is one, otherwise the player. Null if | |
| 133 | * there is no way. | |
82adee18 2026-10-20 Pavel Safronov | 134 | */ |
565a6873 2026-10-25 Pavel Safronov | 135 | export function stepToward(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step | null { |
5f066960 2026-10-26 Pavel Safronov | 136 | const goal = s.decoy?.pos ?? chased(s) |
c37a2b0b 2026-10-20 Pavel Safronov | 137 | const key = (p: Vec) => p.y * s.width + p.x |
| 138 | const first = new Map<number, Step>() | |
| 139 | const queue: Vec[] = [] | |
82adee18 2026-10-20 Pavel Safronov | 140 | for (const st of openSteps(s, w)) { |
| 141 | if (eq(st.to, goal)) return st | |
c37a2b0b 2026-10-20 Pavel Safronov | 142 | first.set(key(st.to), st) |
| 143 | queue.push(st.to) | |
| 144 | } | |
| 145 | for (let i = 0; i < queue.length; i++) { | |
| 146 | const p = queue[i]! | |
| 147 | for (const d of DIR_LIST) { | |
| 148 | const q = add(p, DIRS[d]) | |
565a6873 2026-10-25 Pavel Safronov | 149 | if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q, w.kind)) continue |
c37a2b0b 2026-10-20 Pavel Safronov | 150 | first.set(key(q), first.get(key(p))!) |
82adee18 2026-10-20 Pavel Safronov | 151 | if (eq(q, goal)) return first.get(key(q))! |
c37a2b0b 2026-10-20 Pavel Safronov | 152 | queue.push(q) |
| 153 | } | |
| 154 | } | |
| 155 | return null | |
| 156 | } | |
| 157 | ||
565a6873 2026-10-25 Pavel Safronov | 158 | /** A step along the shortest way to the player (unless another enemy takes that tile then), else a wander. */ |
| 159 | function chase(s: GameState, w: Walker, rng: Rng): Step | null { | |
| 160 | const toward = stepToward(s, w) | |
| 161 | return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng) | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Sprinter: half the time a chase, otherwise a wander; then on along the same line for a second | |
| 166 | * tile if it can (a dash), or just the one. | |
| 167 | */ | |
| 168 | function dash(s: GameState, w: Walker, rng: Rng): Step | null { | |
| 169 | const first = rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng) | |
| 170 | if (!first) return null | |
| 171 | const second = add(first.to, DIRS[first.dir]) | |
| 172 | return enemyCanEnter(s, second, w.kind) && !w.crowded(second) ? { dir: first.dir, to: second } : first | |
| 173 | } | |
| 174 | ||
82adee18 2026-10-20 Pavel Safronov | 175 | const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = { |
c37a2b0b 2026-10-20 Pavel Safronov | 176 | blob: wander, |
565a6873 2026-10-25 Pavel Safronov | 177 | // Seeker: half the time a chase, otherwise a Blob's wander. |
| 178 | seeker: (s, w, rng) => (rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng)), | |
c37a2b0b 2026-10-20 Pavel Safronov | 179 | drifter: drift, |
565a6873 2026-10-25 Pavel Safronov | 180 | sprinter: dash, |
| 181 | // Ghost: always chases, through crates, every other turn. | |
| 182 | ghost: chase, | |
| 183 | // Wisp: wanders like a Blob, through crates. | |
| 184 | wisp: wander, | |
| 185 | // Hunter: always chases. | |
| 186 | hunter: chase, | |
c37a2b0b 2026-10-20 Pavel Safronov | 187 | } |
| 188 | ||
82adee18 2026-10-20 Pavel Safronov | 189 | /** |
| 190 | * Phase 5: every enemy tops its plan up to PLAN_AHEAD moves, and those are the moves it will | |
| 191 | * make. Planned steps the board no longer allows (a bomb now in the way) are dropped first, | |
| 192 | * along with everything after them, and planned again from there. The steps are worked out on | |
| 193 | * the board as it is now, so a later bomb can still block one. | |
52d68b59 2026-10-24 Pavel Safronov | 194 | * Enemies are planned in turn, each keeping clear of where the ones before it will be at each |
| 195 | * of the coming moves (`spreadSteps`), so they spread out instead of moving as one. | |
82adee18 2026-10-20 Pavel Safronov | 196 | */ |
c37a2b0b 2026-10-20 Pavel Safronov | 197 | export function planIntents(s: GameState): void { |
| 198 | const rng = new Rng(s.aiRng) | |
52d68b59 2026-10-24 Pavel Safronov | 199 | // "t:x,y": an enemy planned so far will be at x,y after t moves (it stays where its plan ends). |
| 200 | const taken = new Set<string>() | |
| 201 | const at = (t: number, p: Vec) => `${t}:${p.x},${p.y}` | |
c37a2b0b 2026-10-20 Pavel Safronov | 202 | for (const e of s.enemies) { |
82adee18 2026-10-20 Pavel Safronov | 203 | let from = e.pos |
| 204 | const kept: Vec[] = [] | |
565a6873 2026-10-25 Pavel Safronov | 205 | let dir = e.dir |
| 206 | let rested: boolean | null = null | |
82adee18 2026-10-20 Pavel Safronov | 207 | for (const p of e.plan) { |
565a6873 2026-10-25 Pavel Safronov | 208 | if (!stillPossible(s, e.kind, from, p)) break |
82adee18 2026-10-20 Pavel Safronov | 209 | kept.push(p) |
565a6873 2026-10-25 Pavel Safronov | 210 | rested = eq(p, from) |
| 211 | dir = dirBetween(from, p) ?? dir | |
82adee18 2026-10-20 Pavel Safronov | 212 | from = p |
| 213 | } | |
565a6873 2026-10-25 Pavel Safronov | 214 | // Carry on from the last kept step, heading the way the last move went. |
| 215 | const w: Walker = { kind: e.kind, pos: from, dir, crowded: (p) => taken.has(at(e.plan.length + 1, p)) } | |
| 216 | // One that rests moves and rests by turns: after its last kept move it rests; with nothing | |
| 217 | // kept, the turn's evenness decides, so they don't all move on the same turns. | |
| 218 | let rest = RESTS.has(e.kind) && (rested === null ? (s.turn + e.id) % 2 === 1 : !rested) | |
82adee18 2026-10-20 Pavel Safronov | 219 | e.plan = kept |
| 220 | while (e.plan.length < PLAN_AHEAD) { | |
565a6873 2026-10-25 Pavel Safronov | 221 | if (rest) { |
| 222 | e.plan.push(w.pos) | |
| 223 | rest = false | |
| 224 | continue | |
| 225 | } | |
1f0038ac 2026-11-01 Pavel Safronov | 226 | const planned = PLANNERS[e.kind](s, w, rng) |
| 227 | if (!planned) break | |
| 228 | // Landing on oil, it slides on to the last oiled tile of the line. | |
| 229 | const st = { dir: planned.dir, to: FLIES.has(e.kind) ? planned.to : (oilSlide(s, planned.to, DIRS[planned.dir], (q) => enemyCanEnter(s, q, e.kind)).at(-1) ?? planned.to) } | |
82adee18 2026-10-20 Pavel Safronov | 230 | e.plan.push(st.to) |
| 231 | w.pos = st.to | |
| 232 | w.dir = st.dir | |
565a6873 2026-10-25 Pavel Safronov | 233 | rest = RESTS.has(e.kind) |
82adee18 2026-10-20 Pavel Safronov | 234 | } |
| 235 | e.dir = w.dir | |
52d68b59 2026-10-24 Pavel Safronov | 236 | for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos)) |
c37a2b0b 2026-10-20 Pavel Safronov | 237 | } |
| 238 | s.aiRng = rng.state | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
82adee18 2026-10-20 Pavel Safronov | 242 | * Phase 2: every enemy takes the first move of its plan. One whose tile was blocked this turn |
| 243 | * (a bomb placed there) stays put and does not re-plan until phase 5, which plans it afresh. | |
99869630 2026-10-23 Pavel Safronov | 244 | * Frozen, they all stay put and keep their plans, for as long as the Freeze lasts. |
c37a2b0b 2026-10-20 Pavel Safronov | 245 | */ |
| 246 | export function moveEnemies(s: GameState, events: GameEvent[]): void { | |
99869630 2026-10-23 Pavel Safronov | 247 | if (s.frozen > 0) { |
| 248 | s.frozen-- | |
c37a2b0b 2026-10-20 Pavel Safronov | 249 | return |
| 250 | } | |
| 251 | for (const e of s.enemies) { | |
82adee18 2026-10-20 Pavel Safronov | 252 | const next = e.plan[0] |
| 253 | if (!next) continue | |
565a6873 2026-10-25 Pavel Safronov | 254 | const tiles = moveTiles(e.pos, next) |
| 255 | if (!tiles.every((p) => enemyCanEnter(s, p, e.kind))) { | |
82adee18 2026-10-20 Pavel Safronov | 256 | e.plan = [] |
| 257 | continue | |
| 258 | } | |
| 259 | e.plan.shift() | |
565a6873 2026-10-25 Pavel Safronov | 260 | // A rest: it stays where it is. |
| 261 | if (!tiles.length) continue | |
| 262 | // A Sprinter dashing through the player's tile stops there: it caught them. | |
| 263 | const to = tiles.find((p) => eq(p, s.player)) ?? next | |
| 264 | if (!eq(to, next)) e.plan = [] | |
| 265 | events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to }) | |
| 266 | e.pos = to | |
c37a2b0b 2026-10-20 Pavel Safronov | 267 | } |
| 268 | } | |
| 269 | ||
0b63d158 2026-10-25 Pavel Safronov | 270 | /** |
| 271 | * Chance of a coin drop, and of an action-charge drop, per type (PLAN.md §10). Coin chances raised | |
| 272 | * 2026-10-24 for the shop (from 0.2, 0.5 and 0.2): a kill is worth about 1 coin on average. | |
| 273 | */ | |
c37a2b0b 2026-10-20 Pavel Safronov | 274 | const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { |
0b63d158 2026-10-25 Pavel Safronov | 275 | blob: { coins: 0.6, charge: 0.15 }, |
| 276 | seeker: { coins: 0.8, charge: 0.25 }, | |
| 277 | drifter: { coins: 0.5, charge: 0.5 }, | |
565a6873 2026-10-25 Pavel Safronov | 278 | // The later ones (2026-10-24), after PLAN.md §10: a Sprinter and a Wisp mostly a charge, a |
| 279 | // Ghost coins, a Hunter both. | |
| 280 | sprinter: { coins: 0.4, charge: 0.6 }, | |
| 281 | ghost: { coins: 0.9, charge: 0.25 }, | |
| 282 | wisp: { coins: 0.4, charge: 0.6 }, | |
| 283 | hunter: { coins: 0.9, charge: 0.6 }, | |
c37a2b0b 2026-10-20 Pavel Safronov | 284 | } |
| 285 | ||
52d68b59 2026-10-24 Pavel Safronov | 286 | /** |
d3e4ed26 2026-10-25 Pavel Safronov | 287 | * The charges that still help once every enemy is gone: all of them, since one charge at a time |
| 288 | * (2026-10-24) every charge is carried to the next level. (Until then Freeze and Decoy worked on | |
| 289 | * pickup, so the last enemy dropped only Pierce, Power, Nuke, Shield or ICBM.) | |
52d68b59 2026-10-24 Pavel Safronov | 290 | */ |
d3e4ed26 2026-10-25 Pavel Safronov | 291 | export const LASTING_CHARGES: readonly ChargeId[] = CHARGE_IDS |
52d68b59 2026-10-24 Pavel Safronov | 292 | |
8da5a7c0 2026-10-24 Pavel Safronov | 293 | /** |
d3e4ed26 2026-10-25 Pavel Safronov | 294 | * A Decoy, Freeze or Shield already lying on the board makes another one useless (user report, |
| 295 | * 2026-10-23: a second Decoy dropped beside the first). Pierce, Power, Nuke and ICBM add up, so | |
| 296 | * they are never wasted. | |
8da5a7c0 2026-10-24 Pavel Safronov | 297 | */ |
| 298 | function wasted(s: GameState, id: ChargeId): boolean { | |
d3e4ed26 2026-10-25 Pavel Safronov | 299 | return (id === 'decoy' || id === 'freeze' || id === 'shield') && s.drops.some((d) => d.kind === id) |
8da5a7c0 2026-10-24 Pavel Safronov | 300 | } |
| 301 | ||
| 302 | /** Every charge that wouldn't be wasted is equally likely; with `last`, every lasting one. */ | |
| 303 | function pickCharge(s: GameState, rng: Rng, last: boolean): ChargeId { | |
| 304 | const ids = (last ? LASTING_CHARGES : CHARGE_IDS).filter((id) => !wasted(s, id)) | |
52d68b59 2026-10-24 Pavel Safronov | 305 | return ids[Math.floor(rng.next() * ids.length)]! |
82adee18 2026-10-20 Pavel Safronov | 306 | } |
| 307 | ||
52d68b59 2026-10-24 Pavel Safronov | 308 | /** |
| 309 | * What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). `last`: | |
| 310 | * no enemies are left, so a charge is a lasting one. | |
| 311 | */ | |
| 312 | export function rollDrops(s: GameState, e: Enemy, last = false): Drop[] { | |
c37a2b0b 2026-10-20 Pavel Safronov | 313 | if (e.spawned) return [] |
| 314 | const rng = new Rng(s.dropRng) | |
| 315 | const drops: Drop[] = [] | |
| 316 | const chance = DROP_CHANCE[e.kind] | |
| 317 | if (rng.next() < chance.coins) { | |
| 318 | const r = rng.next() | |
| 319 | drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 }) | |
| 320 | } | |
| 321 | if (rng.next() < chance.charge) { | |
8da5a7c0 2026-10-24 Pavel Safronov | 322 | drops.push({ pos: e.pos, kind: pickCharge(s, rng, last) }) |
c37a2b0b 2026-10-20 Pavel Safronov | 323 | } |
| 324 | s.dropRng = rng.state | |
| 325 | return drops | |
| 326 | } |