-- Dyna Blaster: Copperline's AI player, as a Lua bot. -- -- A port of ~/mydev/amiga/dynabot: the planner (crates/dynabot, a search over -- tiles and frames that knows nothing about Amigas) and the seat -- (crates/dynaseat: eyes from the game's RAM, Copperline's pilot steering). -- The Rust is the reference; this follows it function by function, with the -- same tie-breaks, so the two decide alike. Runs in the emulator's bot sandbox -- (od-unix/netplay_bot.cpp): ram.* to read, frame() answers joystick bits. --------------------------------------------------------------------------- -- The planner (dynabot) --------------------------------------------------------------------------- local FLOOR, BRICK, PILLAR, UNKNOWN = 0, 1, 2, 3 -- Steps in a fixed order, so a tie is broken the same way every time. local NORTH, SOUTH, EAST, WEST = 1, 2, 3, 4 local STEPS = { NORTH, SOUTH, EAST, WEST } local DX = { 0, 0, 1, -1 } local DY = { -1, 1, 0, 0 } local OPPOSITE = { SOUTH, NORTH, WEST, EAST } local FLEEING, ATTACKING, DIGGING, HUNTING, HOLDING = "fleeing", "attacking", "digging", "hunting", "holding" -- An arena: cols, rows, and tiles[r * cols + c + 1]. local function inside(a, c, r) return c >= 0 and r >= 0 and c < a.cols and r < a.rows end local function tile_at(a, c, r) if not inside(a, c, r) then return PILLAR end return a.tiles[r * a.cols + c + 1] end local function walkable(a, c, r) return tile_at(a, c, r) == FLOOR end local function index(a, c, r) return r * a.cols + c + 1 end -- Which tiles a bomb's blast covers, stopping at the first wall along each -- arm and taking a brick with it. Cells as {c, r}. local function blast_cells(a, c0, r0, power) local cells = { { c0, r0 } } for _, s in ipairs(STEPS) do for d = 1, power do local c, r = c0 + DX[s] * d, r0 + DY[s] * d if not inside(a, c, r) then break end local t = tile_at(a, c, r) if t == PILLAR or t == UNKNOWN then break end cells[#cells + 1] = { c, r } if t ~= FLOOR then break end -- a brick is caught and stops it end end return cells end local function cells_contain(cells, c, r) for _, x in ipairs(cells) do if x[1] == c and x[2] == r then return true end end return false end -- When every tile is on fire: per tile, a list of {from, to} windows. -- Chains are a fixpoint: a bomb caught by another's blast goes off early. local function danger(a, bombs, rules) local goes_off = {} for i, b in ipairs(bombs) do goes_off[i] = b.fuse_left end local cells = {} for i, b in ipairs(bombs) do cells[i] = blast_cells(a, b.c, b.r, b.power) end for _ = 0, #bombs do local moved = false for i = 1, #bombs do for j = 1, #bombs do if i ~= j and cells_contain(cells[i], bombs[j].c, bombs[j].r) and goes_off[i] < goes_off[j] then goes_off[j] = goes_off[i] moved = true end end end if not moved then break end end local windows = {} for k = 1, a.cols * a.rows do windows[k] = {} end for i, b in ipairs(bombs) do local at = goes_off[i] for _, cell in ipairs(cells[i]) do local w = windows[index(a, cell[1], cell[2])] w[#w + 1] = { at, at + rules.blast_frames } end -- The bomb's own tile cannot be walked through while it is there. local w = windows[index(a, b.c, b.r)] w[#w + 1] = { 0, at } end return { a = a, windows = windows } end local function windows_at(d, c, r) if not inside(d.a, c, r) then return {} end return d.windows[index(d.a, c, r)] end local function lethal_between(d, c, r, from, to) for _, w in ipairs(windows_at(d, c, r)) do if from <= w[2] and w[1] <= to then return true end end return false end local function next_flame(d, c, r, from) local best for _, w in ipairs(windows_at(d, c, r)) do if w[2] >= from then local f = math.max(w[1], from) if best == nil or f < best then best = f end end end return best end local function blocked_has(blocked, c, r) return blocked[c .. "," .. r] == true end -- Coordinates compare column first, then row, as Rust's (i32, i32) does. local function coord_less(c1, r1, c2, r2) return c1 < c2 or (c1 == c2 and r1 < r2) end -- The earliest frame each tile can be reached from (fc, fr), walking only -- through tiles clear for the whole of the step that enters them. local function earliest_arrival(a, d, fc, fr, now, rules, blocked) local best, came = {}, {} if not inside(a, fc, fr) then return best, came end best[index(a, fc, fr)] = now local frontier = { { fc, fr } } while #frontier > 0 do local pick = 1 for i = 2, #frontier do local x, p = frontier[i], frontier[pick] local ta, tb = best[index(a, x[1], x[2])], best[index(a, p[1], p[2])] if ta < tb or (ta == tb and coord_less(x[1], x[2], p[1], p[2])) then pick = i end end local cell = frontier[pick] frontier[pick] = frontier[#frontier] -- swap_remove frontier[#frontier] = nil local at = best[index(a, cell[1], cell[2])] for _, s in ipairs(STEPS) do local nc, nr = cell[1] + DX[s], cell[2] + DY[s] if walkable(a, nc, nr) and not blocked_has(blocked, nc, nr) then local arrive = at + rules.frames_per_tile if not lethal_between(d, nc, nr, at, arrive) then local slot = index(a, nc, nr) if best[slot] == nil or arrive < best[slot] then best[slot] = arrive came[slot] = cell local present = false for _, f in ipairs(frontier) do if f[1] == nc and f[2] == nr then present = true break end end if not present then frontier[#frontier + 1] = { nc, nr } end end end end end end return best, came end -- The first move of the path the search found to (tc, tr). local function first_step_of(came, a, fc, fr, tc, tr) local c, r = tc, tr while true do local prev = came[index(a, c, r)] if prev == nil then return nil end if prev[1] == fc and prev[2] == fr then local dx, dy = c - fc, r - fr for _, s in ipairs(STEPS) do if DX[s] == dx and DY[s] == dy then return s end end return nil end c, r = prev[1], prev[2] end end -- The best refuge reachable from (fc, fr): nearest, then not a pocket, then -- most slack, then position. local function find_refuge(a, d, fc, fr, now, rules, blocked) local arrivals, came = earliest_arrival(a, d, fc, fr, now, rules, blocked) local best, bk for r = 0, a.rows - 1 do for c = 0, a.cols - 1 do local arrive = arrivals[index(a, c, r)] if arrive ~= nil and not lethal_between(d, c, r, arrive, now + rules.horizon_frames) then local f = next_flame(d, c, r, arrive) local slack = f and (f - arrive) or rules.horizon_frames local exits = 0 for _, s in ipairs(STEPS) do if walkable(a, c + DX[s], r + DY[s]) then exits = exits + 1 end end local pocket = exits <= 1 and 1 or 0 local key = { arrive, pocket, -slack, c, r } local better = bk == nil if not better then for k = 1, 5 do if key[k] ~= bk[k] then better = key[k] < bk[k] break end end end if better then best = { c = c, r = r, first = first_step_of(came, a, fc, fr, c, r), arrive = arrive, slack = slack } bk = key end end end end return best end -- Tiles a foe stands on, and those within foe_clearance steps of one. local function foe_tiles(view, rules) local out = {} local list = {} local function add(c, r) local k = c .. "," .. r if not out[k] and not (c == view.mc and r == view.mr) then out[k] = true list[#list + 1] = { c, r } end end for _, f in ipairs(view.foes) do add(f.c, f.r) end for _ = 1, math.max(rules.foe_clearance, 0) do local grown = {} for _, x in ipairs(list) do grown[#grown + 1] = x end for _, x in ipairs(grown) do for _, s in ipairs(STEPS) do local nc, nr = x[1] + DX[s], x[2] + DY[s] if walkable(view.arena, nc, nr) then add(nc, nr) end end end end return out end -- Whether dropping a bomb here leaves a way out with margin to spare. local function bomb_is_survivable(view, rules) local bombs = {} for i, b in ipairs(view.bombs) do bombs[i] = b end bombs[#bombs + 1] = { c = view.mc, r = view.mr, power = math.max(rules.assumed_power, rules.own_power), fuse_left = rules.fuse_frames } local after = danger(view.arena, bombs, rules) local refuge = find_refuge(view.arena, after, view.mc, view.mr, 0, rules, foe_tiles(view, rules)) return refuge ~= nil and not (refuge.c == view.mc and refuge.r == view.mr) and refuge.slack >= rules.slack_frames end -- Decide what to do this frame: {step, drop_bomb, reason}. local function plan(view, rules) local now = 0 local live = danger(view.arena, view.bombs, rules) local blocked = foe_tiles(view, rules) -- 1. Is the tile under us going to burn, and when? local doomed = next_flame(live, view.mc, view.mr, now) if doomed ~= nil then local touching = {} for _, f in ipairs(view.foes) do touching[f.c .. "," .. f.r] = true end local escape for _, avoid in ipairs({ blocked, touching, {} }) do local r = find_refuge(view.arena, live, view.mc, view.mr, now, rules, avoid) if r and r.first then escape = r break end end if escape == nil then return { step = nil, drop_bomb = false, reason = HOLDING } end if doomed <= escape.arrive + rules.frames_per_tile + rules.slack_frames then return { step = escape.first, drop_bomb = false, reason = FLEEING } end end -- 2. Is a bomb here worth placing, and survivable? if view.bombs_spare > 0 and bomb_is_survivable(view, rules) then local hits = blast_cells(view.arena, view.mc, view.mr, rules.own_power) for _, f in ipairs(view.foes) do if f.killable and cells_contain(hits, f.c, f.r) then return { step = nil, drop_bomb = true, reason = ATTACKING } end end for _, h in ipairs(hits) do if tile_at(view.arena, h[1], h[2]) == BRICK then return { step = nil, drop_bomb = true, reason = DIGGING } end end end -- 3. Walk towards the nearest thing worth reaching. local arrivals, came = earliest_arrival(view.arena, live, view.mc, view.mr, now, rules, blocked) local tcost, tc, tr for r = 0, view.arena.rows - 1 do for c = 0, view.arena.cols - 1 do local cost = arrivals[index(view.arena, c, r)] if cost ~= nil and not (c == view.mc and r == view.mr) then local worth = false for _, f in ipairs(view.foes) do if f.killable and math.abs(f.c - c) + math.abs(f.r - r) <= rules.own_power then worth = true break end end if not worth then for _, s in ipairs(STEPS) do if tile_at(view.arena, c + DX[s], r + DY[s]) == BRICK then worth = true break end end end if worth and (tcost == nil or cost < tcost or (cost == tcost and coord_less(c, r, tc, tr))) then tcost, tc, tr = cost, c, r end end end end if tcost ~= nil then local step = first_step_of(came, view.arena, view.mc, view.mr, tc, tr) if step then return { step = step, drop_bomb = false, reason = HUNTING } end end if doomed ~= nil then for _, avoid in ipairs({ blocked, {} }) do local r = find_refuge(view.arena, live, view.mc, view.mr, now, rules, avoid) if r and r.first then return { step = r.first, drop_bomb = false, reason = FLEEING } end end end return { step = nil, drop_bomb = false, reason = HOLDING } end --------------------------------------------------------------------------- -- The seat (dynaseat): eyes from RAM, Copperline's pilot --------------------------------------------------------------------------- local RECORD0, STRIDE, MAX_PLAYERS = 0x44f08, 0x28, 4 local F_X, F_Y, F_MAX_BOMBS, F_BLAST, F_BOMBS_OUT = 0x00, 0x02, 0x10, 0x12, 0x14 local MAZE, COLS, ROWS, COLUMN_BYTES = 0x4682e, 13, 11, 26 local T_FLOOR, T_BRICK, T_PILLAR = 0x3c, 0x3b, 0x27 local TILE_PX = 16 local CLOCK, CLOCK_STILL = 0x439b1, 75 local FIRE_HOLD, FIRE_GAP, REPLAN_AT_LEAST_EVERY = 3, 8, 40 local PROMPT_TAP_EVERY, PROMPT_TAP_HOLD = 75, 10 -- Measured on this game in WinUAE; rounded the cautious way. local rules = { frames_per_tile = 14, fuse_frames = 120, blast_frames = 30, own_power = 2, assumed_power = 3, slack_frames = 15, horizon_frames = 240, foe_clearance = 1, } local me, players = 0, 4 local tick = 0 local prev_out = { 0, 0, 0, 0 } local bombs = {} -- {owner, c, r, power, placed, gone_off} local was_c, was_r = nil, nil local planned_at = 0 local off_x, off_y = 0.5, 0.5 local current = { step = nil, drop_bomb = false, reason = HOLDING } local held, held_since = nil, 0 local fire_for, fire_gap = 0, 0 local clock, clock_moved = nil, 0 local stats = { rounds = 0, drops = 0, playing = false } local function u16(s, i) -- big-endian word at 0-based offset return s:byte(i + 1) * 256 + s:byte(i + 2) end local function clamp(v, lo, hi) if v < lo then return lo elseif v > hi then return hi else return v end end local function read_players() local s = ram.bytes(RECORD0, STRIDE * players) if not s then return nil end local all = {} for p = 0, players - 1 do local b = p * STRIDE all[p + 1] = { x = u16(s, b + F_X), y = u16(s, b + F_Y), bombs_out = clamp(u16(s, b + F_BOMBS_OUT), 0, 8), max_bombs = clamp(u16(s, b + F_MAX_BOMBS), 1, 8), blast = clamp(u16(s, b + F_BLAST), 1, 8), } end return all end -- The maze, column-major in RAM; nil unless the pillar lattice is all there. local function read_maze() local s = ram.bytes(MAZE, COLUMN_BYTES * COLS) if not s then return nil end local tiles = {} for c = 0, COLS - 1 do for r = 0, ROWS - 1 do local v = u16(s, c * COLUMN_BYTES + 2 + r * 2) local pillar_slot = (c % 2 == 1) and (r % 2 == 1) if pillar_slot ~= (v == T_PILLAR) then return nil end local t = FLOOR -- a bomb, a flame, a power-up if v == T_BRICK then t = BRICK elseif v == T_PILLAR then t = PILLAR end tiles[r * COLS + c + 1] = t end end return { cols = COLS, rows = ROWS, tiles = tiles } end local function tile_of(s) return (s.x + TILE_PX // 2) // TILE_PX, (s.y + TILE_PX // 2) // TILE_PX end -- Every bomber's count of live bombs: up is a bomb under it, down is its -- oldest gone off. local function track_bombs(all) for p = 1, #all do local s, before = all[p], prev_out[p] if s.bombs_out > before then local c, r = tile_of(s) for _ = before + 1, s.bombs_out do bombs[#bombs + 1] = { owner = p, c = c, r = r, power = s.blast, placed = tick } end elseif s.bombs_out < before then for _ = s.bombs_out + 1, before do local oldest for _, b in ipairs(bombs) do if b.owner == p and b.gone_off == nil and (oldest == nil or b.placed < oldest.placed) then oldest = b end end if oldest then oldest.gone_off = tick end end end prev_out[p] = s.bombs_out end local kept = {} for _, b in ipairs(bombs) do local keep if b.gone_off then keep = tick - b.gone_off <= rules.blast_frames else keep = tick - b.placed <= rules.fuse_frames * 2 + rules.blast_frames end if keep then kept[#kept + 1] = b end end bombs = kept end local function make_view(all, arena) local s = all[me + 1] local mc, mr = tile_of(s) off_x = ((s.x + TILE_PX // 2) % TILE_PX) / TILE_PX off_y = ((s.y + TILE_PX // 2) % TILE_PX) / TILE_PX if inside(arena, mc, mr) then arena.tiles[index(arena, mc, mr)] = FLOOR end local foes = {} for p = 1, #all do if p ~= me + 1 then local c, r = tile_of(all[p]) foes[#foes + 1] = { c = c, r = r, killable = true } end end local vb = {} for _, b in ipairs(bombs) do local fuse if b.gone_off then fuse = -(tick - b.gone_off) else fuse = rules.fuse_frames - (tick - b.placed) end vb[#vb + 1] = { c = b.c, r = b.r, power = b.power, fuse_left = fuse } end return { arena = arena, mc = mc, mr = mr, bombs = vb, foes = foes, bombs_spare = math.max(s.max_bombs - s.bombs_out, 0) } end -- A turn across the axis of travel waits until lined up with the corridor. local function steer() local step = current.step if step == nil then return nil end local LINED_UP = 0.25 local aligned if step == NORTH or step == SOUTH then aligned = math.abs(off_x - 0.5) <= LINED_UP else aligned = math.abs(off_y - 0.5) <= LINED_UP end if aligned then return step end if step == NORTH or step == SOUTH then return off_x < 0.5 and EAST or WEST end return off_y < 0.5 and SOUTH or NORTH end local STEP_BITS = { [NORTH] = bits.UP, [SOUTH] = bits.DOWN, [EAST] = bits.RIGHT, [WEST] = bits.LEFT } function init(port, n) me = ({ [2] = 0, [3] = 1, [1] = 2, [0] = 3 })[port] or 0 players = clamp(n, 2, MAX_PLAYERS) if me >= players then me = players - 1 end print(("dynablaster.lua: port %d, record %d of %d"):format(port, me, players)) end function frame(n) tick = tick + 1 local all = read_players() if not all then return 0 end track_bombs(all) -- In play only while the round clock moves: the maze stays in RAM, and a -- valid lattice, on every screen between rounds. local raw = ram.bytes(CLOCK, 5) local cl = raw and (raw:sub(1, 1) .. raw:sub(3, 3) .. raw:sub(5, 5)) -- M, S, S if cl ~= clock then clock, clock_moved = cl, tick end local arena = (tick - clock_moved < CLOCK_STILL) and read_maze() or nil local playing = arena ~= nil if playing ~= stats.playing then if playing then stats.rounds = stats.rounds + 1 was_c, held = nil, nil current = { step = nil, drop_bomb = false, reason = HOLDING } end stats.playing = playing end if not playing then return (tick % PROMPT_TAP_EVERY < PROMPT_TAP_HOLD) and bits.FIRE or 0 end local view = make_view(all, arena) local arrived = not (was_c == view.mc and was_r == view.mr) local crowded = false for _, f in ipairs(view.foes) do if math.abs(f.c - view.mc) + math.abs(f.r - view.mr) <= 2 then crowded = true break end end local threatened = crowded or lethal_between(danger(view.arena, view.bombs, rules), view.mc, view.mr, 0, rules.frames_per_tile * 3) local stale = tick - planned_at >= REPLAN_AT_LEAST_EVERY if arrived or threatened or stale then local fresh = plan(view, rules) local step_still_clear = false if current.step then local dc, dr = view.mc + DX[current.step], view.mr + DY[current.step] step_still_clear = true for _, f in ipairs(view.foes) do if math.abs(f.c - dc) + math.abs(f.r - dr) <= 1 then step_still_clear = false break end end end local keep_old = not arrived and not stale and current.reason == HUNTING and fresh.reason == HUNTING and not fresh.drop_bomb and step_still_clear if not keep_old then current = fresh end planned_at = tick was_c, was_r = view.mc, view.mr end if current.drop_bomb and fire_for == 0 and fire_gap == 0 and view.bombs_spare > 0 then fire_for, fire_gap = FIRE_HOLD, FIRE_HOLD + FIRE_GAP stats.drops = stats.drops + 1 end local firing = fire_for > 0 fire_for = math.max(fire_for - 1, 0) fire_gap = math.max(fire_gap - 1, 0) local want = steer() -- The turnaround debounce: no walking straight back over ground just -- covered, unless fleeing. if current.reason ~= FLEEING and want and held and want == OPPOSITE[held] and tick - held_since <= rules.frames_per_tile * 2 then want = nil end local out = firing and bits.FIRE or 0 if want then out = out | STEP_BITS[want] if held ~= want then held, held_since = want, tick end end return out end