From f9446db988975ff9e51d8c4f8f9269f6e530c5b4 Mon Sep 17 00:00:00 2001 From: lex Date: Wed, 29 Jul 2026 23:14:55 +0300 Subject: [PATCH 1/3] Fix City Blocks ore density, room edges and build restrictions Playtest findings on DO/City-Blocks, and one shared bug behind two of them. Ore density is halved. A room is 4x4 chunks of solid single-ore field, so at canonical richness one room outlasts anything built in it and clearing ground stops being a decision. Rooms now keep a 3 tile ore-free band along their edge with the rail corridor. Previously the ore ran flush to the corridor, so a train stop or inserter needed a bay hand-mined first and nothing in a fresh room could be automated until it had been cleared by hand. entity_placement_restriction never refunded anything. It read the item from event.consumed_items.get_contents()[1], which in 2.0 returns plain ItemWithQualityCount tables ({name, quality, count}) with no `valid` field, then gated the refund on `stack.valid` - always nil, so every entity it destroyed was silently deleted and the item lost. The refund item now comes from the entity prototype with quality preserved, which also works for robot builds, whose event carries no consumed_items at all. The module also only hooked on_built_entity, so every rule built on it was bypassable with construction robots: place a ghost, let the bots finish it. It now hooks on_robot_built_entity too and refunds to the robot so the item returns to the network. This closes the same hole on DO/Safety-Ores, which uses the module for its inverse rule. Note for anyone chasing the same suspicion on the ore restriction: the live ore rule is modules/allowed_entities.lua, and it already hooks both build events and already refunds via items_to_place_this. It was not affected. modules/banned_entities.lua is dead code with no requires. Corridor naming is unified on "rail corridor" (was variously strip, wall and corridor). Co-Authored-By: Claude Opus 5 --- map_gen/maps/danger_ores/changelog.lua | 6 + .../maps/danger_ores/modules/city_blocks.lua | 53 +++++-- .../presets/danger_ore_city_blocks.lua | 4 +- .../shared/entity_placement_restriction.lua | 140 +++++++++++------- 4 files changed, 135 insertions(+), 68 deletions(-) diff --git a/map_gen/maps/danger_ores/changelog.lua b/map_gen/maps/danger_ores/changelog.lua index 4ed6d502d..6b6d9bbd8 100644 --- a/map_gen/maps/danger_ores/changelog.lua +++ b/map_gen/maps/danger_ores/changelog.lua @@ -160,4 +160,10 @@ return [[ - [DO:ScrapMaze] Added DO/Scrapworld-Maze preset - [DO:OmniMaze] Added DO/Omnimatter-Maze preset - [DO:CityBlocks] Added DO/City-Blocks preset + + 2026-07-29: + - [DO:CityBlocks] Halved main ore density + - [DO:CityBlocks] Left a 3 tile ore-free band inside each room along the rail corridors + - [DO] Build restrictions now apply to construction robots, not just player placement + - [DO] Fixed entities destroyed by a build restriction not being refunded ]] diff --git a/map_gen/maps/danger_ores/modules/city_blocks.lua b/map_gen/maps/danger_ores/modules/city_blocks.lua index 81cda2f48..3b54b720e 100644 --- a/map_gen/maps/danger_ores/modules/city_blocks.lua +++ b/map_gen/maps/danger_ores/modules/city_blocks.lua @@ -20,6 +20,10 @@ local shuffle = table.shuffle_table local PITCH = 5 -- chunks per room+wall cell local ROOMS_RADIUS = 12 -- rooms span -R..R on both axes +-- Ore-free band inside each room along its edge with the corridor. Without it the ore starts +-- flush against the corridor and you have to hand-mine a bay before you can place a train stop +-- or an inserter, so nothing in a fresh room can be automated until it has been cleared by hand. +local ROOM_EDGE_MARGIN = 3 local RAIL_A = 13 -- track offsets within a corridor chunk: odd (chunk edges are multiples of local RAIL_B = 19 -- 32, matching the rail grid) and at corridor-center -3/+3, exactly where -- the corner roundabouts' approach lanes sit; the 4-tile gap still fits signals everywhere @@ -345,9 +349,9 @@ local function on_chunk(event) end end --- === strip placement rule ================================================== +-- === rail corridor placement rule ========================================== -local ALLOWED_ON_STRIP = { +local ALLOWED_ON_CORRIDOR = { ['straight-rail'] = true, ['curved-rail-a'] = true, ['curved-rail-b'] = true, @@ -364,25 +368,25 @@ local ALLOWED_ON_STRIP = { ['artillery-wagon'] = true, } -local function on_wall_chunk(x, y) +local function on_corridor_chunk(x, y) local _, lx = room_of_chunk(floor(x / 32)) local _, ly = room_of_chunk(floor(y / 32)) return lx == PITCH - 1 or ly == PITCH - 1 end --- Corridor rule via the shared entity_placement_restriction module (handles ghosts, --- refunds and destruction): keep everything off the corridors, and on them only the --- allowed types (whitelisted by LuaEntity type, not name, for mod compatibility). +-- Corridor rule via the shared entity_placement_restriction module, which handles ghosts, +-- robot placement, refunds and destruction: keep everything off the corridors, and on them +-- only the allowed types (whitelisted by LuaEntity type, not name, for mod compatibility). local keep_alive_callback = Token.register(function(entity) local e_type = entity.type if e_type == 'entity-ghost' then e_type = entity.ghost_type end - if ALLOWED_ON_STRIP[e_type] then + if ALLOWED_ON_CORRIDOR[e_type] then return true end local pos = entity.position - return not on_wall_chunk(pos.x, pos.y) + return not on_corridor_chunk(pos.x, pos.y) end) local function on_restricted_destroyed(event) @@ -412,30 +416,59 @@ function Public.register(config) Event.add(RestrictEntities.events.on_restricted_entity_destroyed, on_restricted_destroyed) end +-- True for tiles in the ore-free band along a room's edge with the corridor. Rooms are +-- PITCH-1 chunks wide, so a room's outermost chunks are the ones at local offset 0 and +-- PITCH-2; only those can hold the band, and only on the side facing the corridor. +local function in_room_margin(x, y) + local tx, ty = floor(x), floor(y) + local _, lx = room_of_chunk(floor(tx / 32)) + local _, ly = room_of_chunk(floor(ty / 32)) + local ox, oy = tx % 32, ty % 32 + + if lx == 0 and ox < ROOM_EDGE_MARGIN then + return true + end + if lx == PITCH - 2 and ox >= 32 - ROOM_EDGE_MARGIN then + return true + end + if ly == 0 and oy < ROOM_EDGE_MARGIN then + return true + end + if ly == PITCH - 2 and oy >= 32 - ROOM_EDGE_MARGIN then + return true + end + return false +end + -- danger-ores main_ores_builder: per tile, pick the room's dominant-ore shape function Public.main_ores_builder(config) local main_ores = config.main_ores return function(tile_builder, ore_builder, spawn_shape, water_shape, _) local shapes = {} + -- Same ground, no ore entity: used for the room-edge band so the tiles still look like + -- the room's ore field but can be built on straight away. + local bare_shapes = {} for _, ore_data in ipairs(main_ores) do local land = tile_builder(ore_data.tiles) local ratios = ore_data.ratios local weighted = b.prepare_weighted_array(ratios) local ore = ore_builder(ore_data.name, ore_data.start, ratios, weighted) shapes[#shapes + 1] = b.apply_entity(land, ore) + bare_shapes[#bare_shapes + 1] = land end local function rooms(x, y, world) local ri = floor((floor(x / 32) + 2) / PITCH) local rj = floor((floor(y / 32) + 2) / PITCH) + local set = in_room_margin(x, y) and bare_shapes or shapes if ri == 0 and rj == 0 then -- spawn room splits the main ores across its quadrants local quadrant = ((x >= 0) and 1 or 0) + ((y >= 0) and 2 or 0) - return shapes[quadrant % #shapes + 1](x, y, world) + return set[quadrant % #set + 1](x, y, world) end local ore_index = data.room_ore[ri .. '/' .. rj] or 1 - return shapes[ore_index](x, y, world) + return set[ore_index](x, y, world) end return b.any { spawn_shape, water_shape, rooms } diff --git a/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua b/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua index a0ef95abf..f8f00883a 100644 --- a/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua +++ b/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua @@ -25,7 +25,9 @@ starting_items[#starting_items + 1] = { count = 1, name = 'locomotive' } starting_items[#starting_items + 1] = { count = 2, name = 'cargo-wagon' } starting_items[#starting_items + 1] = { count = 50, name = 'coal' } -local main_ores = require 'map_gen.maps.danger_ores.config.vanilla_ores' +-- Half the canonical density: a room is 4x4 chunks of solid single-ore field, so at full +-- richness one room outlasts anything you can build in it and clearing ground stops mattering. +local main_ores = require('map_gen.maps.danger_ores.config.vanilla_ores'):scale_richness(0.5) DOC.biter_drops.enabled = false DOC.scenario_name = 'danger-ore-city-blocks' diff --git a/map_gen/shared/entity_placement_restriction.lua b/map_gen/shared/entity_placement_restriction.lua index 6c0ac28d8..033ed69d3 100644 --- a/map_gen/shared/entity_placement_restriction.lua +++ b/map_gen/shared/entity_placement_restriction.lua @@ -12,7 +12,11 @@ This function must be a registered with the Token module and the keep_alive_callback function will take the Token-id as parameter This is to prevent upvalue errors - Refunds for items that were placed can be toggled on or off via the enable and disable_refund functions + Rules apply to both player and construction-robot placement, and to ghosts. + + Refunds for items that were placed can be toggled on or off via the enable and disable_refund functions. + The refunded item is derived from the entity's prototype (quality preserved) and goes back to + whoever built it: the placing player, or the construction robot for a robot build. Lastly, this module raises 2 events: on_pre_restricted_entity_destroyed and on_restricted_entity_destroyed events. They are fully defined below. @@ -171,7 +175,74 @@ local function entities_with_inventory(entity, player) return false end ---- Token for the on_built event callback, checks if an entity should be destroyed. +--- The item to hand back for a destroyed entity, quality preserved. +-- Derived from the entity prototype rather than the build event, because robot build events +-- carry no consumed_items at all, and because consumed_items.get_contents() returns plain +-- ItemWithQualityCount tables ({name, quality, count}) with no `valid` field - the old +-- `stack.valid` guard was therefore never true and every destroyed entity was silently +-- deleted with no refund. +local function placement_stack(entity) + local items = entity.prototype.items_to_place_this + if not items or #items == 0 then + return nil + end + local item = items[1] + if not item or not item.name then + return nil + end + return {name = item.name, count = item.count or 1, quality = entity.quality} +end + +--- Destroys a disallowed entity and refunds whoever built it (player or construction robot). +local function destroy_restricted(entity, ghost, event) + local index = event.player_index + local player = index and game.get_player(index) + local robot = event.robot + local stack = (not ghost) and placement_stack(entity) or nil + + raise_event( + Public.events.on_pre_restricted_entity_destroyed, + { + player_index = index, + created_entity = entity, + ghost = ghost, + stack = stack or {} + } + ) + + -- Need to revalidate the entity since we sent it to the raised event + if entity.valid then + -- Checking if the entity has an inventory and spills the content on the ground to prevent destroying those too + if player and player.valid and entities_with_inventory(entity, player) then + ghost = true -- Cheating to prevent refunds + else + entity.destroy() + end + end + + -- Refund to the robot when a robot built it, so the item returns to the logistic network + -- instead of vanishing; otherwise to the player who placed it. + local actor = robot or player + local item_returned = false + if stack and not ghost and primitives.refund and actor and actor.valid and actor.can_insert(stack) then + actor.insert(stack) + item_returned = true + end + + raise_event( + Public.events.on_restricted_entity_destroyed, + { + player_index = index, + player = player, + ghost = ghost, + item_returned = item_returned + } + ) +end + +--- Token for the build event callbacks, checks if an entity should be destroyed. +-- Registered for both on_built_entity and on_robot_built_entity: restricting only player +-- placement left every rule in this module bypassable with construction robots. local on_built_token = Token.register( function(event) @@ -212,73 +283,28 @@ local on_built_token = return end - local index = event.player_index - local stack = event.consumed_items.get_contents()[1] - if not stack then - if index then - return - else - stack = {} - end - end - - raise_event( - Public.events.on_pre_restricted_entity_destroyed, - { - player_index = index, - created_entity = entity, - ghost = ghost, - stack = stack - } - ) - - local player = game.get_player(index) - - -- Need to revalidate the entity since we sent it to the raised event - if entity.valid then - -- Checking if the entity has an inventory and spills the content on the ground to prevent destroying those too - if entities_with_inventory(entity, player) then - ghost = true -- Cheating to prevent refunds - else - entity.destroy() - end - end - - -- Check if we issue a refund: make sure refund is enabled, make sure we're not refunding a ghost, - -- and revalidate the stack since we sent it to the raised event - local item_returned - if player and player.valid and primitives.refund and not ghost and stack.valid then - player.insert(stack) - item_returned = true - else - item_returned = false - end - - raise_event( - Public.events.on_restricted_entity_destroyed, - { - player_index = index, - player = player, - ghost = ghost, - item_returned = item_returned - } - ) + destroy_restricted(entity, ghost, event) end ) ---- Registers and unregisters the event hook +--- Registers and unregisters the event hooks. +-- Both the player and the robot build event are hooked: a rule that only covers +-- on_built_entity is trivially bypassed by placing a ghost the bots then complete, or by any +-- ghost the game creates itself (entity death with a ghost, upgrade planner, undo). local function check_event_status() - -- First we check if the event hook is in place or not + -- First we check if the event hooks are in place or not if primitives.event then - -- If there are no items in either list and no function is present, unhook the event + -- If there are no items in either list and no function is present, unhook the events if not next(allowed_entities) and not next(banned_entities) and not primitives.keep_alive_callback then Event.remove_removable(defines.events.on_built_entity, on_built_token) + Event.remove_removable(defines.events.on_robot_built_entity, on_built_token) primitives.event = nil end else - -- If either of the lists have an entry or there is a function present, hook the event + -- If either of the lists have an entry or there is a function present, hook the events if next(allowed_entities) or next(banned_entities) or primitives.keep_alive_callback then Event.add_removable(defines.events.on_built_entity, on_built_token) + Event.add_removable(defines.events.on_robot_built_entity, on_built_token) primitives.event = true end end From 95a1d92172625c04b27782e4eadc03c467cd53a7 Mon Sep 17 00:00:00 2001 From: lex Date: Fri, 31 Jul 2026 15:52:07 +0300 Subject: [PATCH 2/3] Allow roboports on the City Blocks rail corridors Players want them there for the same reason they would on any map, and refusing costs nothing to protect: a roboport's logistics_radius is 25 and roboports join into a single network when their logistic zones touch, so two placed inside adjacent rooms already reach across a 32 tile corridor. Bots could ferry between rooms without anything ever standing on a corridor, so the rule was only denying the tidy placement, not the capability. Trains still move the tonnage; belts still cannot cross. Co-Authored-By: Claude Opus 5 --- map_gen/maps/danger_ores/changelog.lua | 1 + map_gen/maps/danger_ores/modules/city_blocks.lua | 14 ++++++++++---- .../danger_ores/presets/danger_ore_city_blocks.lua | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/map_gen/maps/danger_ores/changelog.lua b/map_gen/maps/danger_ores/changelog.lua index 6b6d9bbd8..f4d966f49 100644 --- a/map_gen/maps/danger_ores/changelog.lua +++ b/map_gen/maps/danger_ores/changelog.lua @@ -164,6 +164,7 @@ return [[ 2026-07-29: - [DO:CityBlocks] Halved main ore density - [DO:CityBlocks] Left a 3 tile ore-free band inside each room along the rail corridors + - [DO:CityBlocks] Allowed roboports to be built on the rail corridors - [DO] Build restrictions now apply to construction robots, not just player placement - [DO] Fixed entities destroyed by a build restriction not being refunded ]] diff --git a/map_gen/maps/danger_ores/modules/city_blocks.lua b/map_gen/maps/danger_ores/modules/city_blocks.lua index 3b54b720e..d4fafd08f 100644 --- a/map_gen/maps/danger_ores/modules/city_blocks.lua +++ b/map_gen/maps/danger_ores/modules/city_blocks.lua @@ -2,9 +2,9 @@ -- lattice carrying a ready-made double-track network -- ordinary, minable rails: continuous -- lines ring every room and meet at a signalled RAIL ROUNDABOUT on every corner (geometry -- decoded from the user's blueprint; native Factorio 2.0 rail pieces). Players --- may branch their own rails, signals and poles anywhere on the lattice, but nothing else --- can be built there, and belts cannot span the 32-tile corridors: trains are the only --- inter-room logistics. Room ores are assigned once in on_init and stored in Global. +-- may branch their own rails, signals, poles and roboports anywhere on the lattice, but +-- nothing else can be built there, and belts cannot span the 32-tile corridors: trains are +-- the bulk inter-room logistics. Room ores are assigned once in on_init and stored in Global. local b = require 'map_gen.shared.builders' local Event = require 'utils.event' local Generate = require 'map_gen.shared.generate' @@ -362,6 +362,12 @@ local ALLOWED_ON_CORRIDOR = { ['rail-chain-signal'] = true, ['train-stop'] = true, ['electric-pole'] = true, + -- Roboports are allowed even though bots can ferry items between rooms, because they can + -- do that already: a roboport's logistics_radius is 25 and roboports join into one network + -- when their logistic zones touch, so two placed inside adjacent rooms are within reach + -- across a 32 tile corridor without anything ever being built on the corridor itself. + -- Keeping them off it only forced players to give up the tidy placement, not the capability. + ['roboport'] = true, ['locomotive'] = true, ['cargo-wagon'] = true, ['fluid-wagon'] = true, @@ -392,7 +398,7 @@ end) local function on_restricted_destroyed(event) local player = event.player if player and player.valid then - player.print('Only rail infrastructure (rails, signals, stations, power poles) and trains can be built on the rail corridors!') + player.print('Only rail infrastructure (rails, signals, stations, power poles, roboports) and trains can be built on the rail corridors!') end end diff --git a/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua b/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua index f8f00883a..5b7e5ecca 100644 --- a/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua +++ b/map_gen/maps/danger_ores/presets/danger_ore_city_blocks.lua @@ -13,8 +13,8 @@ ScenarioInfo.add_map_extra_info([[ double-track network: continuous lines ring every room and meet at a signalled roundabout on every corner, and the rails are yours -- extend them, reroute them, mine them. Rails, signals, train stops, - power poles and trains are the only things buildable on the - corridors, and belts cannot cross them: trains are your logistics. + power poles, roboports and trains are the only things buildable on + the corridors, and belts cannot cross them: trains move the tonnage. ]]) local starting_items = Config.player_create.starting_items From 67aa9bd63b41e8393c97cdb6404a8a8a6382ae53 Mon Sep 17 00:00:00 2001 From: lex Date: Fri, 14 Aug 2026 08:38:43 +0300 Subject: [PATCH 3/3] Trim the refund comment to the reason that still applies Review feedback: the note about consumed_items.get_contents() returning tables with no valid field described the old code, not this code. Co-Authored-By: Claude Opus 5 --- map_gen/shared/entity_placement_restriction.lua | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/map_gen/shared/entity_placement_restriction.lua b/map_gen/shared/entity_placement_restriction.lua index 033ed69d3..2621c19c3 100644 --- a/map_gen/shared/entity_placement_restriction.lua +++ b/map_gen/shared/entity_placement_restriction.lua @@ -177,10 +177,7 @@ end --- The item to hand back for a destroyed entity, quality preserved. -- Derived from the entity prototype rather than the build event, because robot build events --- carry no consumed_items at all, and because consumed_items.get_contents() returns plain --- ItemWithQualityCount tables ({name, quality, count}) with no `valid` field - the old --- `stack.valid` guard was therefore never true and every destroyed entity was silently --- deleted with no refund. +-- carry no consumed_items at all. local function placement_stack(entity) local items = entity.prototype.items_to_place_this if not items or #items == 0 then