local radar = {} local GRID_SIZE = 16 local MAX_RADIUS = 3200 local SECTORS_PER_INTERVAL = 1 -- earned per radar per 30 s window at full power local MAX_CREDIT = 60 -- cap on banked sectors per force+planet group local SECTOR_TILES = GRID_SIZE * 2 -- one sector = one 32x32 chunk local FRESH_TICKS = 180 -- a fresh scan shows blue for 3 s radar.SECTOR_TILES = SECTOR_TILES radar.FRESH_TICKS = FRESH_TICKS local function rlog(message) log("[PVP-RADAR] " .. message) end local function powered(entity) return entity.energy > 0 end -- Half-size of the square active-vision footprint, matching what the entity reveals on the map. local function vision_half_size(entity) if entity.type == "radar" then return (entity.prototype.get_max_distance_of_nearby_sector_revealed(entity.quality.name) or 3) * 32 end local cell = entity.logistic_cell return cell and cell.construction_radius or 0 end local function enemy_vision(entity, force) return entity.force ~= force and entity.force.name ~= "neutral" end -- Zones are rebuilt once per jam tick so scans never search entities themselves. -- quiet skips logging; the UI polls this every refresh and would spam the log. local function sector_jammed(surface, position, force, quiet) local zones = storage.jam_zones and storage.jam_zones[surface.index] if not zones then return false end for _, zone in pairs(zones) do if zone.force ~= force.name and zone.force ~= "neutral" and math.abs(position.x - zone.x) <= zone.half and math.abs(position.y - zone.y) <= zone.half then if not quiet then rlog("sector_jammed=true surface=" .. surface.name .. " pos=(" .. position.x .. "," .. position.y .. ") force=" .. force.name .. " by " .. zone.name .. " force=" .. zone.force .. " pos=(" .. zone.x .. "," .. zone.y .. ")") end return true end end return false end radar.sector_jammed = sector_jammed local function scan_orbit(force, surface, scan_key) storage.grid_pos = storage.grid_pos or {} if not storage.grid_pos[scan_key] then storage.grid_pos[scan_key] = {x = 0, y = 0, d = 0, step_limit = 1, steps_taken = 0, iteration = 0} end local state = storage.grid_pos[scan_key] local position = {x = state.x, y = state.y} local scan_range = 1 local chunk = {x = position.x / SECTOR_TILES, y = position.y / SECTOR_TILES} if not surface.is_chunk_generated(chunk) then -- Ask for the chunk and retry this sector next turn; the cursor does not -- advance, so world gen is limited to one sector per queue tick. surface.request_to_generate_chunks(position, scan_range) return end if sector_jammed(surface, position, force) then rlog("scan_orbit blocked: key=" .. scan_key .. " pos=(" .. position.x .. "," .. position.y .. ")") -- Remember attempted-but-jammed sectors; the UI only shows red where the -- force actually tried to scan, not the whole enemy coverage map. storage.jammed_attempts = storage.jammed_attempts or {} local af = storage.jammed_attempts[force.name] or {} storage.jammed_attempts[force.name] = af local as = af[surface.name] or {} af[surface.name] = as as[chunk.x .. "," .. chunk.y] = game.tick else force.chart(surface, { {position.x - (GRID_SIZE * scan_range), position.y - (GRID_SIZE * scan_range)}, {position.x + (GRID_SIZE * scan_range), position.y + (GRID_SIZE * scan_range)} }) -- Timestamped so the UI can flash freshly scanned sectors blue. storage.recent_scans = storage.recent_scans or {} local by_force = storage.recent_scans[force.name] or {} storage.recent_scans[force.name] = by_force local by_surface = by_force[surface.name] or {} by_force[surface.name] = by_surface by_surface[chunk.x .. "," .. chunk.y] = game.tick end -- Flag the sector for open radar windows so they repaint only this cell. storage.scan_dirty = storage.scan_dirty or {} local dirty_force = storage.scan_dirty[force.name] or {} storage.scan_dirty[force.name] = dirty_force local dirty_surface = dirty_force[surface.name] or {} dirty_force[surface.name] = dirty_surface dirty_surface[chunk.x .. "," .. chunk.y] = game.tick if state.d == 0 then state.x = state.x + GRID_SIZE * scan_range * 2 elseif state.d == 1 then state.y = state.y + GRID_SIZE * scan_range * 2 elseif state.d == 2 then state.x = state.x - GRID_SIZE * scan_range * 2 else state.y = state.y - GRID_SIZE * scan_range * 2 end state.steps_taken = state.steps_taken + 1 if state.steps_taken >= state.step_limit then state.steps_taken = 0 state.d = (state.d + 1) % 4 state.iteration = state.iteration + 1 if state.iteration % 2 == 0 then state.step_limit = state.step_limit + 1 end end if math.abs(state.x) > MAX_RADIUS or math.abs(state.y) > MAX_RADIUS then storage.grid_pos[scan_key] = {x = 0, y = 0, d = 0, step_limit = 1, steps_taken = 0, iteration = 0} end end -- Stored charge as a fraction of the buffer; drives how fast a radar accumulates scans. local function scan_power(entity) local buffer = entity.electric_buffer_size if not buffer or buffer == 0 then return 1 end return math.min(1, entity.energy / buffer) end local function register_orbital_radar(entity) if entity.type == "radar" and entity.surface.platform then storage.orbital_radars = storage.orbital_radars or {} storage.orbital_radars[entity.unit_number] = {entity = entity} end end -- One-time seed for saves that predate the registry; afterwards events keep it current. local function rebuild_registry() storage.orbital_radars = {} for _, surface in pairs(game.surfaces) do if surface.platform then for _, entity in pairs(surface.find_entities_filtered{type = "radar"}) do if entity.valid then storage.orbital_radars[entity.unit_number] = {entity = entity} end end end end rlog("orbital radar registry rebuilt: " .. tostring(table_size(storage.orbital_radars)) .. " radars") end -- Ground vision sources (radars + roboports on planet surfaces), kept by events -- so the jam tick never searches entities. Seeded once for old saves. local function register_ground_source(entity) if (entity.type == "radar" or entity.type == "roboport") and not entity.surface.platform and entity.unit_number then storage.ground_sources = storage.ground_sources or {} storage.ground_sources[entity.unit_number] = entity end end local function rebuild_ground_sources() storage.ground_sources = {} for _, surface in pairs(game.surfaces) do if not surface.platform then for _, entity in pairs(surface.find_entities_filtered{type = {"radar", "roboport"}}) do if entity.valid and entity.unit_number then storage.ground_sources[entity.unit_number] = entity end end end end rlog("ground source registry rebuilt: " .. tostring(table_size(storage.ground_sources)) .. " entities") end function radar.on_entity_removed(event) -- Not every dying entity has a unit_number (trees, rocks, decoratives). local unit_number = event.entity and event.entity.unit_number if not unit_number then return end if storage.orbital_radars then storage.orbital_radars[unit_number] = nil end if storage.ground_sources then storage.ground_sources[unit_number] = nil end end -- Runs once per second. Accumulates power readings and refreshes each radar's -- force and target planet, so platforms moving between planets just work. function radar.on_power_sample_tick() if not storage.orbital_radars then rebuild_registry() end local power = storage.radar_power or {} storage.radar_power = power for unit_number, entry in pairs(storage.orbital_radars) do local entity = entry.entity if not entity.valid then storage.orbital_radars[unit_number] = nil power[unit_number] = nil else local platform = entity.surface.platform local location = platform and platform.space_location if platform and entity.active and location and game.planets[location.name] then entry.force = entity.force.name entry.target = location.name local acc = power[unit_number] or {sum = 0, count = 0} acc.sum = acc.sum + scan_power(entity) acc.count = acc.count + 1 power[unit_number] = acc end end end end -- Runs every 30 s. Converts each radar's average power into sector credit, -- pooled per force+planet so duplicate coverage is never queued twice. function radar.on_orbital_scan_tick() local power = storage.radar_power if not power then return end storage.scan_credit = storage.scan_credit or {} for unit_number, acc in pairs(power) do local entry = storage.orbital_radars and storage.orbital_radars[unit_number] if entry and entry.force and entry.target and acc.count > 0 then local planets = storage.scan_credit[entry.force] or {} storage.scan_credit[entry.force] = planets planets[entry.target] = math.min(MAX_CREDIT, (planets[entry.target] or 0) + (acc.sum / acc.count) * SECTORS_PER_INTERVAL) end end storage.radar_power = {} storage.scan_dirty = {} -- UI drains entries fast; 30 s of leftovers is enough -- Recent-scan marks only matter for the blue flash; drop stale ones. local scans = storage.recent_scans if scans then local cutoff = game.tick - FRESH_TICKS * 4 for _, by_surface in pairs(scans) do for _, sectors in pairs(by_surface) do for key, tick in pairs(sectors) do if tick < cutoff then sectors[key] = nil end end end end end end -- Shared sector coordinate conversion: sector (sx, sy) covers the 32x32 chunk at -- chunk position (sx, sy); its centre tile is (sx * 32, sy * 32). UI, scans and -- jam checks all use this so a cell always maps to the same ground area. -- already_charted lets the UI skip the is_chunk_charted API call for cells it -- has drawn green before; a charted chunk can never revert. local function sector_display_state(force, surface, sx, sy, tick, already_charted, jammed_set) -- Red only where the force attempted a scan and the zone is still live. local in_zone = jammed_set and jammed_set[sx .. "," .. sy] or (not jammed_set and sector_jammed(surface, {x = sx * SECTOR_TILES, y = sy * SECTOR_TILES}, force, true)) if in_zone then local attempts = storage.jammed_attempts and storage.jammed_attempts[force.name] local attempted = attempts and attempts[surface.name] and attempts[surface.name][sx .. "," .. sy] if attempted then return "jammed" end end local scans = storage.recent_scans and storage.recent_scans[force.name] local last = scans and scans[surface.name] and scans[surface.name][sx .. "," .. sy] if last and tick - last < FRESH_TICKS then return "fresh" end if already_charted or force.is_chunk_charted(surface, {x = sx, y = sy}) then return "charted" end return "unscanned" end radar.sector_display_state = sector_display_state -- Enemy jam zones converted to a sector set ("sx,sy" keys) so a UI refresh does -- one zone pass per frame instead of one zone loop per cell. function radar.jammed_sector_set(surface, force) local zones = storage.jam_zones and storage.jam_zones[surface.index] if not zones then return nil end local set = {} for _, zone in pairs(zones) do if zone.force ~= force.name and zone.force ~= "neutral" then for sx = math.floor((zone.x - zone.half) / SECTOR_TILES), math.floor((zone.x + zone.half) / SECTOR_TILES) do for sy = math.floor((zone.y - zone.half) / SECTOR_TILES), math.floor((zone.y + zone.half) / SECTOR_TILES) do if math.abs(sx * SECTOR_TILES - zone.x) <= zone.half and math.abs(sy * SECTOR_TILES - zone.y) <= zone.half then set[sx .. "," .. sy] = true end end end end end return set end -- Expected scan rate (sectors/min) and mean power fraction for a force+planet, -- from the per-radar averages accumulated so far this interval. function radar.scan_stats(force_name, planet_name) local power_acc = storage.radar_power or {} local radars, power_sum, rate = 0, 0, 0 for unit_number, entry in pairs(storage.orbital_radars or {}) do if entry.force == force_name and entry.target == planet_name then local acc = power_acc[unit_number] local avg = acc and acc.count > 0 and acc.sum / acc.count or 0 radars = radars + 1 power_sum = power_sum + avg rate = rate + avg * SECTORS_PER_INTERVAL * 2 -- two 30 s windows per minute end end return {rate = rate, power = radars > 0 and power_sum / radars or 0, radars = radars} end -- Runs every 12 ticks (200 ms). Drains exactly one sector per call, round-robin -- across groups, so per-update work stays fixed no matter how deep the credit is. function radar.on_scan_queue_tick() local credit = storage.scan_credit if not credit then return end local ready = {} for force_name, planets in pairs(credit) do for planet_name, amount in pairs(planets) do if amount >= 1 then ready[#ready + 1] = {f = force_name, p = planet_name} end end end if #ready == 0 then return end table.sort(ready, function(a, b) if a.f == b.f then return a.p < b.p end return a.f < b.f end) local cursor = (storage.scan_queue_cursor or 0) % #ready + 1 storage.scan_queue_cursor = cursor local pick = ready[cursor] local force = game.forces[pick.f] local planet = game.planets[pick.p] local surface = planet and planet.surface if not (force and surface) then credit[pick.f][pick.p] = nil return end scan_orbit(force, surface, pick.f .. "_" .. pick.p) local left = credit[pick.f][pick.p] - 1 credit[pick.f][pick.p] = left > 0 and left or nil end -- Powered overlapping enemy vision squares jam each other. The engine cannot -- darken only the overlap, so an overlapped radar is disabled entirely until -- the other side dies, loses power, or moves out of range. Roboports count as -- vision sources but are never disabled themselves. function radar.on_jam_tick() storage.jammed_entities = storage.jammed_entities or {} storage.jam_zones = storage.jam_zones or {} for unit_number, entity in pairs(storage.jammed_entities) do if entity.valid then entity.active = true rlog("unjammed " .. entity.name .. "@" .. unit_number .. " on " .. entity.surface.name) else rlog("dropping stale jammed entity @" .. unit_number .. " (invalid)") end storage.jammed_entities[unit_number] = nil end if not storage.ground_sources then rebuild_ground_sources() end local sources_by_surface = {} for unit_number, entity in pairs(storage.ground_sources) do if not entity.valid then storage.ground_sources[unit_number] = nil elseif entity.active and powered(entity) and not entity.surface.platform then local list = sources_by_surface[entity.surface.index] if not list then list = {} sources_by_surface[entity.surface.index] = list end list[#list + 1] = entity end end for _, surface in pairs(game.surfaces) do if not surface.platform then local sources = sources_by_surface[surface.index] or {} for i = 1, #sources do for j = i + 1, #sources do local a, b = sources[i], sources[j] if enemy_vision(a, b.force) then local reach = vision_half_size(a) + vision_half_size(b) if math.abs(a.position.x - b.position.x) <= reach and math.abs(a.position.y - b.position.y) <= reach then rlog("vision overlap on " .. surface.name .. ": " .. a.name .. "@" .. a.unit_number .. " force=" .. a.force.name .. " pos=(" .. a.position.x .. "," .. a.position.y .. ")" .. " vs " .. b.name .. "@" .. b.unit_number .. " force=" .. b.force.name .. " pos=(" .. b.position.x .. "," .. b.position.y .. ")") for _, entity in pairs({a, b}) do if entity.type == "radar" then entity.active = false storage.jammed_entities[entity.unit_number] = entity rlog("jammed radar @" .. entity.unit_number .. " force=" .. entity.force.name .. " on " .. surface.name) end end end end end end -- Cache still-active vision sources for cheap jam checks during scans. local zones = {} for _, source in pairs(sources) do if source.valid and source.active then zones[#zones + 1] = {name = source.name, x = source.position.x, y = source.position.y, half = vision_half_size(source), force = source.force.name} end end storage.jam_zones[surface.index] = zones end end storage.jam_version = (storage.jam_version or 0) + 1 end function radar.on_built_entity(event) local entity = event.entity if not (entity and entity.valid) then return end if entity.surface.platform then if entity.type == "radar" then rlog("radar built @" .. entity.unit_number .. " name=" .. entity.name .. " force=" .. entity.force.name .. " surface=" .. entity.surface.name .. " pos=(" .. entity.position.x .. "," .. entity.position.y .. ")" .. " platform=true") register_orbital_radar(entity) end return end if entity.type == "radar" or entity.type == "roboport" then register_ground_source(entity) end if entity.type ~= "radar" then return end rlog("radar built @" .. entity.unit_number .. " name=" .. entity.name .. " force=" .. entity.force.name .. " surface=" .. entity.surface.name .. " pos=(" .. entity.position.x .. "," .. entity.position.y .. ")" .. " platform=false") -- A fresh vision source jams any enemy drop-zone lock inside its footprint. local jammer_force, jammer_pos = entity.force, entity.position local jammer_half = vision_half_size(entity) for _, enemy_force in pairs(game.forces) do if enemy_force.name ~= jammer_force.name and enemy_force.name ~= "neutral" then storage.player_targets = storage.player_targets or {} local key = enemy_force.name .. "_" .. entity.surface.name local target = storage.player_targets[key] if target then local dx, dy = math.abs(target.position.x - jammer_pos.x), math.abs(target.position.y - jammer_pos.y) if dx <= jammer_half and dy <= jammer_half then rlog("jammed drop zone: force=" .. enemy_force.name .. " key=" .. key .. " target=(" .. target.position.x .. "," .. target.position.y .. ")" .. " by radar@" .. entity.unit_number) storage.player_targets[key] = nil local message = "[color=red][JAMMER] Your drop zone at [" .. math.floor(target.position.x) .. ", " .. math.floor(target.position.y) .. "] on " .. entity.surface.name .. " has been JAMMED by " .. jammer_force.name .. "![/color]" for _, player in pairs(enemy_force.players) do if player.valid then player.print(message) end end end end end end end return radar