diff --git a/.luacheckrc b/.luacheckrc index 962fb1b..2ac2aac 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -123,6 +123,7 @@ stds.creative_mod = { "gui_menu_cheats", "gui_menu_magicwand", "gui_menu_modding", + "gui_menu_surface", "item_providers_util", "item_source", "item_void", @@ -135,6 +136,7 @@ stds.creative_mod = { "remote_interface", "rights", "static_item_container_type", + "surface_creation", "super_boiler", "super_cooler", "transport_belt_item_distance", diff --git a/GUI-DESIGN-SYSTEM.md b/GUI-DESIGN-SYSTEM.md new file mode 100644 index 0000000..6b0a1ed --- /dev/null +++ b/GUI-DESIGN-SYSTEM.md @@ -0,0 +1,129 @@ +# GUI Design System + +Conventions for building menus in this mod so new tabs/menus look and behave like the +existing ones. Follow this when adding a submenu, a sub-sub-menu, or a backend action behind +a menu. File:line references point at the canonical examples to copy from. + +## Menu hierarchy + +Three nesting levels, all hosted in `mod_gui.get_frame_flow(player)` → `main_menu_container`: + +``` +Main menu ("Creative Mode") button column of submenu buttons + └─ Submenu ("Cheats", "Surface") button column of section buttons + └─ Sub-sub-menu content frame opened beside the column +``` + +- **Main menu**: one vertical `frame` (caption "Creative Mode") of `main_menu_button`s, one per + submenu. Built in `gui-menu.lua` `create_or_hide_main_menu_for_player` from the + `submenus_gui_data` registry (`gui-menu.lua:47`). +- **Submenu**: its own horizontal `flow` container (e.g. `surface_menus_container`) holding a + captioned vertical `frame` of `main_menu_button`s — the **button column**. Each button opens a + sub-sub-menu *beside* the column inside the same container. +- **Sub-sub-menu**: a captioned vertical `frame` added next to the button column, holding the + actual controls. Toggled open/closed; opening one closes the siblings. + +Canonical examples: the **Cheats** submenu (`gui-menu-cheats.lua`) and the **Surface** submenu +(`gui-menu-surface.lua`), which deliberately mirror each other. + +## Adding a new submenu + +1. **New module** `scripts/gui-menu-.lua` defining `gui_menu_` with: + - `get_container_name()` → a unique container name from `defines.lua`. + - `create_or_destroy_menu_for_player(player)` — toggles the submenu container; when building, + adds a captioned `frame` (the button column) + one `main_menu_button` per section. + - `on_gui_click(element, element_name, player, button, alt, control, shift)` returning whether + the event was consumed. + - `on_gui_selection_state_changed(...)` if it has drop-downs. +2. **Register** in `submenus_gui_data` (`gui-menu.lua:47`): `button_name`, `button_caption`, + `get_player_can_access_function` (e.g. `function(player) return player.admin end` for + admin-only), `get_submenu_container_name_function`, `open_submenu_for_player_function`, + `update_accessibility_for_player_function` (or `nil`). +3. **Wire the click fan-out**: add `gui_menu_.on_gui_click` to the `on_gui_click` dispatch in + `gui-menu.lua`, and `on_gui_selection_state_changed` to that fan-out if needed. +4. **`control.lua`**: `require("scripts.gui-menu-")` **before** `require("scripts.gui-menu")` + — `gui-menu.lua` references submenu globals at load time when building `submenus_gui_data`. +5. **`defines.lua`**: add element-name constants. **`locale/en/base.cfg`**: captions/messages. +6. **`.luacheckrc`**: register the new module global. + +## Section pattern within a submenu (the three-column nav) + +Drive the button column from a `section_data` table + an explicit `section_order` array (because +`pairs()` is unordered). See `gui-menu-surface.lua` `section_data` / `section_order`. Each section: + +- `button_name`, `button_caption`, plus either: + - `build_content = function(frame) ... end` — fills a content frame the helper creates for you, **or** + - `create_or_destroy = function(player, destroy_only) ... end` — delegate open/close to another + module (used to host the Surface Cheats menu, whose content is built by the cheats machinery). +- Optional `get_player_can_access_function` to gate the button's visibility. +- Optional gating flag (e.g. `space_age_only`, checked via `script.feature_flags["space_travel"]`). + +`create_or_destroy_section_for_player(player, data, destroy_only)` toggles the section's content +frame inside the submenu container; the click handler closes the other sections first, then toggles +the clicked one — mirroring `create_or_destroy_cheats_menu_for_player` (`gui-menu-cheats.lua:2560`) +and its fan-out (`gui-menu-cheats.lua:3761`). + +## Shared style vocabulary + +Reuse these styles (defined in `prototypes/style.lua`, named in `defines.lua` under `gui_styles`) +so new menus match the rest of the UI: + +| Element | Style | +|---|---| +| Submenu / section column button | `main_menu_button` | +| Selection list scroll-pane | `cheat_scroll_pane` | +| Selection list frame | `cheat_target_selection_container_frame` | +| Selectable list button (active / inactive) | `cheat_target_selected_button` / `cheat_target_unselected_button` | +| Form row table | `cheat_table` | +| Form label | `cheat_name_label` | +| Text input | `cheat_numeric_textfield` | +| Spacer between input and button | `cheat_textfield_and_button_separate_flow` | +| Apply / action button (short caption) | `cheat_apply_button` | +| Action button (longer caption, auto-sized) | `small_default_bold_button` | + +A captioned `frame` renders its `caption` as a title bar — that is how submenus/sub-sub-menus get +their heading; no separate title-bar helper exists. + +**Gotcha:** `cheat_apply_button` has a *fixed* width sized for short captions (e.g. "Apply") and +clips longer ones like "Create" → "Cre...". A runtime `minimal_width` cannot grow past a fixed +width, so for a longer caption use its parent style `small_default_bold_button` instead — same bold +apply-button look, but auto-sizes to the caption. + +## Event wiring + +Engine events are forwarded down a fixed chain, mirroring existing handlers: + +`scripts/events.lua` (local forwarder, registered in `event_handlers_look_up`) → `gui.on_` +→ `gui_menu.on_` → the relevant `gui_menu_.on_`. + +The generic dispatcher in `control.lua` loops over all `defines.events`, so a new handler needs +only the forwarder + `event_handlers_look_up` entry, not a new explicit `script.on_event` +registration. `on_surface_created` (Phase 1 of the surface tool) is the worked example. Note the +engine payload carries `surface_index`, not `surface` — resolve via `game.surfaces[event.surface_index]`. + +**Live list refresh:** to make a newly created/removed target appear without reopening the menu, +route through `add_or_remove_target_in_cheats_menu_for_all_players(surface, , true)`; +each target descriptor's `verify_target_for_insert_function` enforces per-player access for free. + +## Backend actions behind a menu + +- **Put logic in a backend module** (e.g. `scripts/surface-creation.lua`), not the GUI. Return a + structured result — `true, ` on success or `false, ` — so both the GUI + and the remote interface can report without re-validating. +- **Take a `force`, not a `player`,** where possible, so the action is callable headless. The GUI + passes `player.force`; the remote wrapper resolves a force from an optional `player_index` and + falls back to `game.forces["player"]` (which always exists, even headless). See `resolve_force` + in `scripts/remote-interface.lua`. +- **Feature-gate Space Age paths** on `script.feature_flags["space_travel"]` in *both* the backend + (refuse with an error key) and the UI (don't build the control). +- **Expose every action through the remote interface** (`scripts/remote-interface.lua` + `remote_functions`) returning a boolean/structured result, so `verify.py behavior` can assert it + via `remote.call` in the headless sandbox (which has Space Age enabled). + +## Verification + +Every user-facing change runs `uv run verify.py static | load | behavior` (see `DEBUG.md`), and +adds a `changelog.txt` entry. GUI layout/behavior that the headless suite can't exercise is +checked manually via `uv run verify.py debug --gui`. Only one Factorio instance can hold the +`.debug` sandbox lock at a time — close a running `debug --gui` session before running +`load`/`behavior`. diff --git a/README.md b/README.md index 9a4a5b1..e4bbda7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ This mod provides a suite of tools and entities that allow for creative building To use Creative Mode, simply enable the mod in the Factorio mod manager. Once enabled, you'll have access to the creative tools and entities in the game. +## Development + +* **GUI conventions:** see [`GUI-DESIGN-SYSTEM.md`](GUI-DESIGN-SYSTEM.md) for the menu hierarchy, styling vocabulary, event wiring, and backend/remote patterns to follow when adding menus. +* **Debugging & verification:** see [`DEBUG.md`](DEBUG.md). + ## Credits This mod is based on the original Creative Mode mod by Y.Petremann, with patches by Pac0master, and also incorporates elements from the Test Mode mod by rk84. It is a fork of the Creative Mode (Fix for 0.16) by [Chrisgbk](https://mods.factorio.com/user/chrisgbk). diff --git a/changelog.txt b/changelog.txt index 6aafbaf..61a25b4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,15 @@ --------------------------------------------------------------------------------------------------- +Version: 2.3.0 +Date: ???? + Features: + - Add an admin-only Surface tab. Admins can create a new blank surface by typing a name; the surface immediately appears in the Surface Cheats target list for every connected player allowed to see other surfaces. + - Add space-platform creation to the Surface tab (Space Age only). Admins can type a name and pick the planet to orbit; the platform comes up with a hub already placed and its surface immediately appears in the Surface Cheats target list. + - Add planet-surface creation to the Surface tab (Space Age only). Admins can pick a planet and create its surface; if the surface already exists the action is a harmless no-op, and any newly created surface immediately appears in the Surface Cheats target list. + Changes: + - Move the Surface Cheats menu from the Cheats tab into the Surface tab, grouping it with the surface-creation tools. + Bugfixes: + - Fix Teleport doing nothing useful while in remote view (map view): it now exits remote view first and teleports your actual character/player instead of just moving the remote-view camera. +--------------------------------------------------------------------------------------------------- Version: 2.2.0 Date: 2026-06-27 Features: diff --git a/control.lua b/control.lua index fc04f10..773d956 100644 --- a/control.lua +++ b/control.lua @@ -24,6 +24,8 @@ require("scripts.gui-menu-admin") require("scripts.gui-menu-cheats") require("scripts.gui-menu-magicwand") require("scripts.gui-menu-modding") +require("scripts.surface-creation") +require("scripts.gui-menu-surface") require("scripts.gui-menu") -- It has to be loaded after the submenus. require("scripts.item-providers-util") require("scripts.item-source") diff --git a/defines.lua b/defines.lua index c62a59d..25fe117 100644 --- a/defines.lua +++ b/defines.lua @@ -1280,6 +1280,37 @@ creative_mode_defines.names.gui = { .. "item-void-can-remove-from-player-checkbox", item_void_can_remove_from_ground_checkbox = creative_mode_defines.name_prefix .. "item-void-can-remove-from-ground-checkbox", + -- Surface creation submenu. + main_menu_open_surface_button = creative_mode_defines.name_prefix .. "main-menu-open-surface-button", + surface_menus_container = creative_mode_defines.name_prefix .. "surface-menus-container", + -- The button-column frame, exactly like the Cheats submenu's button frame: a captioned ("Surface") + -- vertical frame holding one button per section. Clicking a button opens that section's content + -- frame beside the column (the surface_*_frame content frames below). + surface_menu_frame = creative_mode_defines.name_prefix .. "surface-menu-frame", + -- Section column buttons (styled like the Cheats column buttons). + surface_nav_blank_button = creative_mode_defines.name_prefix .. "surface-nav-blank-button", + surface_nav_platform_button = creative_mode_defines.name_prefix .. "surface-nav-platform-button", + surface_nav_planet_button = creative_mode_defines.name_prefix .. "surface-nav-planet-button", + -- Blank-surface section content frame (the sub-sub-menu opened by the Blank surface button). + surface_blank_frame = creative_mode_defines.name_prefix .. "surface-blank-frame", + surface_blank_container = creative_mode_defines.name_prefix .. "surface-blank-container", + surface_blank_name_label = creative_mode_defines.name_prefix .. "surface-blank-name-label", + surface_blank_name_textfield = creative_mode_defines.name_prefix .. "surface-blank-name-textfield", + surface_blank_create_button = creative_mode_defines.name_prefix .. "surface-blank-create-button", + surface_platform_frame = creative_mode_defines.name_prefix .. "surface-platform-frame", + surface_platform_container = creative_mode_defines.name_prefix .. "surface-platform-container", + surface_platform_name_label = creative_mode_defines.name_prefix .. "surface-platform-name-label", + surface_platform_name_textfield = creative_mode_defines.name_prefix .. "surface-platform-name-textfield", + surface_platform_planet_label = creative_mode_defines.name_prefix .. "surface-platform-planet-label", + surface_platform_planet_drop_down = creative_mode_defines.name_prefix .. "surface-platform-planet-drop-down", + surface_platform_create_button = creative_mode_defines.name_prefix .. "surface-platform-create-button", + -- Planet-surface section content frame (the sub-sub-menu opened by the Planet surface button). + -- Its planet drop-down is independent from the platform-orbit picker above. + surface_planet_frame = creative_mode_defines.name_prefix .. "surface-planet-frame", + surface_planet_container = creative_mode_defines.name_prefix .. "surface-planet-container", + surface_planet_planet_label = creative_mode_defines.name_prefix .. "surface-planet-planet-label", + surface_planet_planet_drop_down = creative_mode_defines.name_prefix .. "surface-planet-planet-drop-down", + surface_planet_create_button = creative_mode_defines.name_prefix .. "surface-planet-create-button", -- Legacy GUI. Used for finding and destroying the legacy "more cheats" popup. cheats_table = creative_mode_defines.name_prefix .. "cheats-table", } diff --git a/locale/en/base.cfg b/locale/en/base.cfg index f56c36e..6b4cf0a 100644 --- a/locale/en/base.cfg +++ b/locale/en/base.cfg @@ -209,6 +209,18 @@ creative-mode_already-enabled=Creative Mode has already been enabled! creative-mode_not-yet-enabled=Creative Mode has not been enabled! creative-mode_enabled-all-cheats=__1__ has enabled all personal cheats to all existing players, team cheats to all existing teams and freeze daytime at midday on all existing surfaces. creative-mode_available_commands=Available commands: +creative-mode_surface-creation-blank-success=Created blank surface "__1__". +creative-mode_surface-creation-blank-empty-name=Cannot create surface: please enter a name. +creative-mode_surface-creation-blank-duplicate-name=Cannot create surface: a surface named "__1__" already exists. +creative-mode_surface-creation-blank-failed=Failed to create surface "__1__". +creative-mode_surface-creation-platform-success=Created space platform on surface "__1__". +creative-mode_surface-creation-platform-failed=Failed to create space platform. +creative-mode_surface-creation-platform-invalid-planet=Cannot create space platform: please pick a planet to orbit. +creative-mode_surface-creation-planet-success=Created planet surface "__1__". +creative-mode_surface-creation-planet-already-existed=Planet surface "__1__" already existed. +creative-mode_surface-creation-planet-failed=Failed to create planet surface. +creative-mode_surface-creation-planet-invalid-planet=Cannot create planet surface: please pick a planet. +creative-mode_surface-creation-not-available-without-space-age=This feature is only available with the Space Age expansion. creative-mode_failed-to-apply-to-single-player-admin=Failed to apply to __1__. Reason: __2__ creative-mode_failed-to-apply-to-multiple-players-admin=Failed to apply to __1__ players. Reason: __2__ @@ -387,6 +399,20 @@ creative-mode_build-options=Build Options creative-mode_magic-wand=Magic Wand creative-mode_modding=Modding creative-mode_admin=Admin +creative-mode_surface=Surface +creative-mode_surface-creation-nav-blank=Blank surface +creative-mode_surface-creation-nav-platform=Space platform +creative-mode_surface-creation-nav-planet=Planet surface +creative-mode_surface-creation-blank-title=Create Blank Surface +creative-mode_surface-creation-blank-name-label=Name +creative-mode_surface-creation-blank-create-button=Create +creative-mode_surface-creation-platform-title=Create Space Platform +creative-mode_surface-creation-platform-name-label=Name +creative-mode_surface-creation-platform-planet-label=Orbit +creative-mode_surface-creation-platform-create-button=Create +creative-mode_surface-creation-planet-title=Create Planet Surface +creative-mode_surface-creation-planet-planet-label=Planet +creative-mode_surface-creation-planet-create-button=Create creative-mode_list-select-all=Select All [?] creative-mode_list-select-all-tooltip=Hold control and click to select additional targets without deselecting the selected ones.\nHold shift and click to select multiple targets. diff --git a/scripts/cheats.lua b/scripts/cheats.lua index 1ee8e2a..1b03e41 100644 --- a/scripts/cheats.lua +++ b/scripts/cheats.lua @@ -1728,6 +1728,13 @@ cheats.teleport_cheats_data = { if not (target_surface and target_surface.valid and source_player) then return nil end + -- In remote view (map view) the active controller is the remote camera, so + -- source_player.surface/position and source_player.teleport() act on the camera, not the + -- physical body (since 2.1.7 teleport no longer implicitly exits remote view). Exit remote + -- view first so the rest of this function operates on, and moves, the real player. + if source_player.controller_type == defines.controllers.remote then + source_player.exit_remote_view() + end local dest = compute_safe_position(source_player, target_surface) if source_player.surface == target_surface then -- Same-surface selection: reposition to a fresh safe spot on the current surface. diff --git a/scripts/events.lua b/scripts/events.lua index c921a96..e20804f 100644 --- a/scripts/events.lua +++ b/scripts/events.lua @@ -575,6 +575,11 @@ local function on_player_changed_surface(event) gui.on_player_changed_surface(event) end +-- Callback of the on_surface_created event, which is invoked after a new surface is created. +local function on_surface_created(event) + gui.on_surface_created(event) +end + -- Callback of the on_player_cursor_stack_changed event, which is invoked after a player picks up or puts down an item stack. local function on_player_cursor_stack_changed(event) gui.on_player_cursor_stack_changed(event) @@ -690,6 +695,7 @@ local event_handlers_look_up = { [defines.events.on_player_joined_game] = on_player_joined_game, [defines.events.on_player_left_game] = on_player_left_game, [defines.events.on_player_changed_surface] = on_player_changed_surface, + [defines.events.on_surface_created] = on_surface_created, [defines.events.on_player_selected_area] = on_player_selected_area, [defines.events.on_player_alt_selected_area] = on_player_alt_selected_area, [defines.events.on_player_cursor_stack_changed] = on_player_cursor_stack_changed, diff --git a/scripts/gui-menu-cheats.lua b/scripts/gui-menu-cheats.lua index ecce0c4..c116031 100644 --- a/scripts/gui-menu-cheats.lua +++ b/scripts/gui-menu-cheats.lua @@ -1336,13 +1336,6 @@ local cheats_menus_gui_data = { get_player_can_access_function = rights.can_player_access_team_cheats_menu, cheats_menu_gui_data = team_cheats_menu_gui_data, }, - surface_cheats = { - button_name = creative_mode_defines.names.gui.surface_cheats_menu_button, - button_caption = { "gui.creative-mode_surface-cheats" }, - access_right_code = rights.access_surface_cheats_code, - get_player_can_access_function = rights.can_player_access_surface_cheats_menu, - cheats_menu_gui_data = surface_cheats_menu_gui_data, - }, teleport = { button_name = creative_mode_defines.names.gui.teleport_menu_button, button_caption = { "gui.creative-mode_teleport" }, @@ -1364,6 +1357,30 @@ for _, data in pairs(cheats_menus_gui_data.contents) do data.cheats_menu_gui_data.parent = cheats_menus_gui_data end +-- GUI data about the surface cheats menu, which now lives in the Surface submenu instead of the +-- Cheats submenu. It mirrors the cheats_menus_gui_data shape so the generic cheats machinery +-- (create/destroy, target buttons, select-all, cheat toggles, live refresh) keeps working, but its +-- content frame is opened inside the Surface container instead. Note that gui_menu_surface is +-- required after this file, so get_container_name is resolved lazily at runtime via a wrapper. +local surface_cheats_menus_gui_data = { + get_container_name_function = function() + return gui_menu_surface.get_container_name() + end, + contents = { + surface_cheats = { + button_name = creative_mode_defines.names.gui.surface_cheats_menu_button, + button_caption = { "gui.creative-mode_surface-cheats" }, + access_right_code = rights.access_surface_cheats_code, + get_player_can_access_function = rights.can_player_access_surface_cheats_menu, + cheats_menu_gui_data = surface_cheats_menu_gui_data, + }, + }, +} +-- Set parent. +for _, data in pairs(surface_cheats_menus_gui_data.contents) do + data.cheats_menu_gui_data.parent = surface_cheats_menus_gui_data +end + ----- -- GUI data about the whole build options menu. @@ -2746,6 +2763,13 @@ local function create_or_destroy_cheats_menu_for_player(player, cheats_menu_gui_ end end +-- Creates or destroys the surface cheats menu for the given player. The surface cheats menu now +-- lives in the Surface submenu, so its content frame is opened inside the Surface container. This +-- wrapper exposes the otherwise-local generic toggle so gui_menu_surface can delegate to it. +function gui_menu_cheats.create_or_destroy_surface_cheats_menu_for_player(player, destroy_only) + create_or_destroy_cheats_menu_for_player(player, surface_cheats_menu_gui_data, destroy_only) +end + -------------------------------------------------------------------- -- Updates the target list of the cheats menu of given cheats menu GUI data in the given outer table with its data for the given player. @@ -3169,6 +3193,15 @@ function gui_menu_cheats.on_player_changed_surface(event) ) end +-- Updates GUI when a new surface is created. +function gui_menu_cheats.on_surface_created(event) + local surface = game.surfaces[event.surface_index] + if surface and surface.valid then + -- Add the new surface as an option in the surface cheats menu for all players who can see other surfaces. + add_or_remove_target_in_cheats_menu_for_all_players(surface, surface_cheats_menu_gui_data, true) + end +end + -- Detects on_gui_click event on the toggles of the cheats menu of given cheats menu GUI data for the given player. -- This is the first pass, meaning the element name detection should be straight forward, i.e. simple comparison. -- Returns whether the on_gui_click event is consumed. @@ -3797,6 +3830,23 @@ function gui_menu_cheats.on_gui_click(element, element_name, player, button, alt return true end + -- Detect for the surface cheats menu (its content now lives in the Surface submenu, but its + -- target buttons / select-all / cheat toggles are still handled by the cheats click fan-out). + if + on_gui_click_in_cheats_menus_gui_data_contents( + element, + element_name, + player, + button, + alt, + control, + shift, + surface_cheats_menus_gui_data + ) + then + return true + end + return false end diff --git a/scripts/gui-menu-surface.lua b/scripts/gui-menu-surface.lua new file mode 100644 index 0000000..d5d99c0 --- /dev/null +++ b/scripts/gui-menu-surface.lua @@ -0,0 +1,419 @@ +local mod_gui = require("mod-gui") + +require("scripts.surface-creation") + +-- This file contains variables and functions related to Creative Mode menu - surface creation GUI. +-- The Surface submenu mirrors the Cheats submenu's three-column navigation: the main menu opens a +-- "Surface" frame holding a column of buttons (Blank surface / Space platform), and clicking a +-- button opens that section's content as a sub-sub-menu frame beside the column. +if not gui_menu_surface then + gui_menu_surface = {} +end + +-- Gets the name of the surface menu container. +function gui_menu_surface.get_container_name() + return creative_mode_defines.names.gui.surface_menus_container +end + +-- Enumerates the available planets in a deterministic order, returning two parallel arrays: +-- items :: array of LocalisedString captions for a drop-down. +-- planet_ids:: array of SpaceLocationID, where planet_ids[i] is the planet for items[i]. +-- The same order is used when building the drop-down and when resolving a selected_index back +-- to a planet id, so no per-player index map needs to be stored. +local function get_planet_drop_down_data() + -- Collect planet ids first, then sort them so the order is identical between the call that + -- builds the drop-down and the call that resolves a selected_index back to a planet id. + local planet_ids = {} + for planet_id in pairs(game.planets) do + planet_ids[#planet_ids + 1] = planet_id + end + table.sort(planet_ids) + + local items = {} + for i, planet_id in ipairs(planet_ids) do + local planet = game.planets[planet_id] + items[i] = (planet and planet.prototype.localised_name) or planet_id + end + return items, planet_ids +end + +------ + +-- Builds the Blank-surface section content inside its (already created, captioned) frame. +local function build_blank_section_content(frame) + -- Label|input row in a cheat_table, then the create button below — consistent with the platform + -- and planet sections (the button sits under the inputs, not inline to the right). + local blank_container = frame.add({ + type = "table", + name = creative_mode_defines.names.gui.surface_blank_container, + style = creative_mode_defines.names.gui_styles.cheat_table, + column_count = 2, + }) + blank_container.add({ + type = "label", + name = creative_mode_defines.names.gui.surface_blank_name_label, + style = creative_mode_defines.names.gui_styles.cheat_name_label, + caption = { "gui.creative-mode_surface-creation-blank-name-label" }, + }) + blank_container.add({ + type = "textfield", + name = creative_mode_defines.names.gui.surface_blank_name_textfield, + style = creative_mode_defines.names.gui_styles.cheat_numeric_textfield, + }) + frame.add({ + type = "button", + name = creative_mode_defines.names.gui.surface_blank_create_button, + -- small_default_bold_button auto-sizes to the caption (see the platform/planet sections). + style = creative_mode_defines.names.gui_styles.small_default_bold_button, + caption = { "gui.creative-mode_surface-creation-blank-create-button" }, + }) +end + +-- Builds the Space-platform section content inside its (already created, captioned) frame. +local function build_platform_section_content(frame) + -- Two label|input rows in a cheat_table, then the create button below. + local platform_container = frame.add({ + type = "table", + name = creative_mode_defines.names.gui.surface_platform_container, + style = creative_mode_defines.names.gui_styles.cheat_table, + column_count = 2, + }) + platform_container.add({ + type = "label", + name = creative_mode_defines.names.gui.surface_platform_name_label, + style = creative_mode_defines.names.gui_styles.cheat_name_label, + caption = { "gui.creative-mode_surface-creation-platform-name-label" }, + }) + platform_container.add({ + type = "textfield", + name = creative_mode_defines.names.gui.surface_platform_name_textfield, + style = creative_mode_defines.names.gui_styles.cheat_numeric_textfield, + }) + platform_container.add({ + type = "label", + name = creative_mode_defines.names.gui.surface_platform_planet_label, + style = creative_mode_defines.names.gui_styles.cheat_name_label, + caption = { "gui.creative-mode_surface-creation-platform-planet-label" }, + }) + -- Planet drop-down enumerated from game.planets (so modded planets appear). + local planet_items, planet_ids = get_planet_drop_down_data() + -- Default the selection to nauvis when present; otherwise the first planet (or none). + local default_index = #planet_items > 0 and 1 or 0 + for i, planet_id in ipairs(planet_ids) do + if planet_id == "nauvis" then + default_index = i + break + end + end + platform_container.add({ + type = "drop-down", + name = creative_mode_defines.names.gui.surface_platform_planet_drop_down, + items = planet_items, + selected_index = default_index, + }) + frame.add({ + type = "button", + name = creative_mode_defines.names.gui.surface_platform_create_button, + -- small_default_bold_button auto-sizes to the caption (see the blank-section note above). + style = creative_mode_defines.names.gui_styles.small_default_bold_button, + caption = { "gui.creative-mode_surface-creation-platform-create-button" }, + }) +end + +-- Builds the Planet-surface section content inside its (already created, captioned) frame. +local function build_planet_section_content(frame) + -- Row: label | drop-down, then the create button below. This planet drop-down is independent + -- from the platform-orbit picker (its own element name + its own index->planet-id resolution). + local planet_container = frame.add({ + type = "table", + name = creative_mode_defines.names.gui.surface_planet_container, + style = creative_mode_defines.names.gui_styles.cheat_table, + column_count = 2, + }) + planet_container.add({ + type = "label", + name = creative_mode_defines.names.gui.surface_planet_planet_label, + style = creative_mode_defines.names.gui_styles.cheat_name_label, + caption = { "gui.creative-mode_surface-creation-planet-planet-label" }, + }) + -- Planet drop-down enumerated from game.planets (so modded planets appear). + local planet_items, planet_ids = get_planet_drop_down_data() + -- Default the selection to nauvis when present; otherwise the first planet (or none), + -- mirroring the platform section. + local default_index = #planet_items > 0 and 1 or 0 + for i, planet_id in ipairs(planet_ids) do + if planet_id == "nauvis" then + default_index = i + break + end + end + planet_container.add({ + type = "drop-down", + name = creative_mode_defines.names.gui.surface_planet_planet_drop_down, + items = planet_items, + selected_index = default_index, + }) + frame.add({ + type = "button", + name = creative_mode_defines.names.gui.surface_planet_create_button, + -- small_default_bold_button auto-sizes to the caption (see the blank-section note above). + style = creative_mode_defines.names.gui_styles.small_default_bold_button, + caption = { "gui.creative-mode_surface-creation-planet-create-button" }, + }) +end + +-- The sections inside the Surface submenu. Each entry pairs a column button with the content frame +-- it opens (built on demand, like the Cheats sub-sub-menus). The "platform" section is gated on +-- Space Age. (A third "planet" section will join here in Phase 3.) section_order fixes the button +-- order in the column (pairs() is unordered). +local section_data = { + blank = { + button_name = creative_mode_defines.names.gui.surface_nav_blank_button, + button_caption = { "gui.creative-mode_surface-creation-nav-blank" }, + frame_name = creative_mode_defines.names.gui.surface_blank_frame, + frame_caption = { "gui.creative-mode_surface-creation-blank-title" }, + space_age_only = false, + build_content = build_blank_section_content, + }, + platform = { + button_name = creative_mode_defines.names.gui.surface_nav_platform_button, + button_caption = { "gui.creative-mode_surface-creation-nav-platform" }, + frame_name = creative_mode_defines.names.gui.surface_platform_frame, + frame_caption = { "gui.creative-mode_surface-creation-platform-title" }, + space_age_only = true, + build_content = build_platform_section_content, + }, + planet = { + button_name = creative_mode_defines.names.gui.surface_nav_planet_button, + button_caption = { "gui.creative-mode_surface-creation-nav-planet" }, + frame_name = creative_mode_defines.names.gui.surface_planet_frame, + frame_caption = { "gui.creative-mode_surface-creation-planet-title" }, + space_age_only = true, + build_content = build_planet_section_content, + }, + -- The surface-cheats section reuses the existing Cheats machinery (its content frame is built by + -- gui_menu_cheats), so instead of a generic build_content it delegates open/close to that module. + -- Its column button is gated on the surface-cheats access right (see the menu builder below). + surface_cheats = { + button_name = creative_mode_defines.names.gui.surface_cheats_menu_button, + button_caption = { "gui.creative-mode_surface-cheats" }, + space_age_only = false, + get_player_can_access_function = rights.can_player_access_surface_cheats_menu, + create_or_destroy = function(player, destroy_only) + gui_menu_cheats.create_or_destroy_surface_cheats_menu_for_player(player, destroy_only) + end, + }, +} +local section_order = { "blank", "platform", "planet", "surface_cheats" } + +-- Returns whether the given section is currently available (built) for this configuration. +local function is_section_available(data) + return (not data.space_age_only) or script.feature_flags["space_travel"] +end + +------ + +-- Creates or destroys the given section's content frame (a sub-sub-menu) beside the button column, +-- mirroring the Cheats submenu's create_or_destroy_cheats_menu_for_player toggle. If destroy_only is +-- true, an open frame is only closed (never opened). +local function create_or_destroy_section_for_player(player, data, destroy_only) + -- Sections may delegate their open/close to a custom function (e.g. the surface-cheats section, + -- whose content is built by the generic Cheats machinery) instead of the generic frame path. + if data.create_or_destroy then + data.create_or_destroy(player, destroy_only) + return + end + local left = mod_gui.get_frame_flow(player) + local container = left[creative_mode_defines.names.gui.main_menu_container] + if not container then + return + end + local surface_menus_container = container[gui_menu_surface.get_container_name()] + if not surface_menus_container then + return + end + local frame = surface_menus_container[data.frame_name] + if frame then + -- Already opened. + frame.destroy() + elseif not destroy_only then + -- Not yet opened. Build a captioned content frame and fill it. + frame = surface_menus_container.add({ + type = "frame", + name = data.frame_name, + direction = "vertical", + caption = data.frame_caption, + }) + data.build_content(frame) + end +end + +------ + +-- Creates the surface creation menu (the button column) for the given player. If the menu already +-- exists, it will be destroyed instead. +function gui_menu_surface.create_or_destroy_menu_for_player(player) + local left = mod_gui.get_frame_flow(player) + local container = left[creative_mode_defines.names.gui.main_menu_container] + if container then + -- Surface container. + local surface_menus_container = container[gui_menu_surface.get_container_name()] + if surface_menus_container then + surface_menus_container.destroy() + else + surface_menus_container = container.add({ + type = "flow", + name = gui_menu_surface.get_container_name(), + style = creative_mode_defines.names.gui_styles.no_horizontal_spacing_flow, + direction = "horizontal", + }) + + -- Surface frame: the button column, exactly like the Cheats submenu's button frame. + local surface_menu_frame = surface_menus_container.add({ + type = "frame", + name = creative_mode_defines.names.gui.surface_menu_frame, + direction = "vertical", + caption = { "gui.creative-mode_surface" }, + }) + + -- One button per available section, styled like the Cheats column buttons. + for _, key in ipairs(section_order) do + local data = section_data[key] + if is_section_available(data) then + local button = surface_menu_frame.add({ + type = "button", + name = data.button_name, + style = creative_mode_defines.names.gui_styles.main_menu_button, + caption = data.button_caption, + }) + -- Gate visibility by the section's access right when it defines one, mirroring the Cheats + -- column (button.visible = data.get_player_can_access_function(player)). + if data.get_player_can_access_function then + button.visible = data.get_player_can_access_function(player) + end + end + end + end + end +end + +-------------------------------------------------------------------- + +-- Resolves the content frame of the given section for the player's open Surface submenu, or nil. +local function get_section_frame(player, frame_name) + local left = mod_gui.get_frame_flow(player) + local container = left[creative_mode_defines.names.gui.main_menu_container] + if not container then + return nil + end + local surface_menus_container = container[gui_menu_surface.get_container_name()] + if not surface_menus_container then + return nil + end + return surface_menus_container[frame_name] +end + +-- Callback of the on_gui_click event, extended from gui-menu.lua. +-- Returns whether the event is consumed. +function gui_menu_surface.on_gui_click(element, element_name, player, button, alt, control, shift) + -- Column navigation: clicking a section button closes the other sections and toggles this one. + for _, data in pairs(section_data) do + if element_name == data.button_name then + for _, data2 in pairs(section_data) do + if data2 ~= data then + create_or_destroy_section_for_player(player, data2, true) + end + end + create_or_destroy_section_for_player(player, data, false) + return true + end + end + + if element_name == creative_mode_defines.names.gui.surface_blank_create_button then + -- Create blank surface button. + local blank_frame = get_section_frame(player, creative_mode_defines.names.gui.surface_blank_frame) + if blank_frame then + local blank_container = blank_frame[creative_mode_defines.names.gui.surface_blank_container] + local textfield = blank_container[creative_mode_defines.names.gui.surface_blank_name_textfield] + local name = textfield.text + + local success, result = surface_creation.create_blank_surface(name) + if success then + player.print({ "message.creative-mode_surface-creation-blank-success", result.name }) + -- Clear the field for convenience. + textfield.text = "" + else + -- result is a localised error message. + player.print(result) + end + end + return true + elseif element_name == creative_mode_defines.names.gui.surface_platform_create_button then + -- Create space platform button. + local platform_frame = get_section_frame(player, creative_mode_defines.names.gui.surface_platform_frame) + if platform_frame then + local platform_container = platform_frame[creative_mode_defines.names.gui.surface_platform_container] + local textfield = platform_container[creative_mode_defines.names.gui.surface_platform_name_textfield] + local drop_down = platform_container[creative_mode_defines.names.gui.surface_platform_planet_drop_down] + local name = textfield.text + + -- Resolve the selected drop-down index back to a planet id using the same ordering + -- that built the drop-down. + local _, planet_ids = get_planet_drop_down_data() + local planet_id = planet_ids[drop_down.selected_index] + + local success, result = surface_creation.create_space_platform(player.force, name, planet_id) + if success then + player.print({ "message.creative-mode_surface-creation-platform-success", result.name }) + -- Clear the name field for convenience. + textfield.text = "" + else + -- result is a localised error message. + player.print(result) + end + end + return true + elseif element_name == creative_mode_defines.names.gui.surface_planet_create_button then + -- Create planet surface button. + local planet_frame = get_section_frame(player, creative_mode_defines.names.gui.surface_planet_frame) + if planet_frame then + local planet_container = planet_frame[creative_mode_defines.names.gui.surface_planet_container] + local drop_down = planet_container[creative_mode_defines.names.gui.surface_planet_planet_drop_down] + + -- Resolve the selected drop-down index back to a planet id using the same ordering + -- that built the drop-down. This is the planet section's own independent picker. + local _, planet_ids = get_planet_drop_down_data() + local planet_id = planet_ids[drop_down.selected_index] + + local success, result = surface_creation.create_planet_surface(planet_id) + if success then + if result.already_existed then + player.print({ "message.creative-mode_surface-creation-planet-already-existed", result.surface.name }) + else + player.print({ "message.creative-mode_surface-creation-planet-success", result.surface.name }) + end + else + -- result is a localised error message. + player.print(result) + end + end + return true + end + return false +end + +-------------------------------------------------------------------- + +-- Callback of the on_gui_selection_state_changed event, extended from gui-menu.lua. +-- Returns whether the event is consumed. +function gui_menu_surface.on_gui_selection_state_changed(element, element_name, player) + if + element_name == creative_mode_defines.names.gui.surface_platform_planet_drop_down + or element_name == creative_mode_defines.names.gui.surface_planet_planet_drop_down + then + -- The selected planet is read directly from the drop-down at create time, so nothing to + -- record here. Consume the event so the chain stops. + return true + end + return false +end diff --git a/scripts/gui-menu.lua b/scripts/gui-menu.lua index a7d14bb..f1e3b94 100644 --- a/scripts/gui-menu.lua +++ b/scripts/gui-menu.lua @@ -87,6 +87,16 @@ local submenus_gui_data = { open_submenu_for_player_function = gui_menu_admin.create_or_destroy_menu_for_player, update_accessibility_for_player_function = nil, }, + surface = { + button_name = creative_mode_defines.names.gui.main_menu_open_surface_button, + button_caption = { "gui.creative-mode_surface" }, + get_player_can_access_function = function(player) + return player.admin + end, + get_submenu_container_name_function = gui_menu_surface.get_container_name, + open_submenu_for_player_function = gui_menu_surface.create_or_destroy_menu_for_player, + update_accessibility_for_player_function = nil, + }, } -- Destroys the Creative Mode main menu for the given player if it is already opened. @@ -286,6 +296,11 @@ function gui_menu.on_player_changed_surface(event) gui_menu_cheats.on_player_changed_surface(event) end +-- Updates GUI when a new surface is created. +function gui_menu.on_surface_created(event) + gui_menu_cheats.on_surface_created(event) +end + -- Callback of the on_gui_click event, extended from gui.lua. -- Returns whether the event is consumed. function gui_menu.on_gui_click(element, element_name, player, button, alt, control, shift) @@ -328,6 +343,9 @@ function gui_menu.on_gui_click(element, element_name, player, button, alt, contr if gui_menu_admin.on_gui_click(element, element_name, player, button, alt, control, shift) then return true end + if gui_menu_surface.on_gui_click(element, element_name, player, button, alt, control, shift) then + return true + end return false end @@ -376,5 +394,8 @@ function gui_menu.on_gui_selection_state_changed(element, element_name, player) if gui_menu_magicwand.on_gui_selection_state_changed(element, element_name, player) then return true end + if gui_menu_surface.on_gui_selection_state_changed(element, element_name, player) then + return true + end return false end diff --git a/scripts/gui.lua b/scripts/gui.lua index 01e7d34..e900080 100644 --- a/scripts/gui.lua +++ b/scripts/gui.lua @@ -233,6 +233,11 @@ function gui.on_player_changed_surface(event) gui_menu.on_player_changed_surface(event) end +-- Updates GUI when a new surface is created. +function gui.on_surface_created(event) + gui_menu.on_surface_created(event) +end + function gui.on_player_cursor_stack_changed(event) gui_menu.on_player_cursor_stack_changed(event) end diff --git a/scripts/remote-interface.lua b/scripts/remote-interface.lua index 8796cb5..eb3295d 100644 --- a/scripts/remote-interface.lua +++ b/scripts/remote-interface.lua @@ -22,6 +22,20 @@ remote_interface.deregister_remote_function_name = 'remote.call("' .. creative_mode_defines.names.interface .. '", "deregister_remote_function_from_modding_ui", "", "")' +-- Resolves a LuaForce from an optional player_index. +-- If the player_index refers to a valid player, that player's force is used. +-- Otherwise (omitted or invalid, e.g. when running headless where game.player is nil), +-- the default "player" force is used as the fallback: it always exists, even on a +-- headless server with no connected players, which is precisely what lets the behavior +-- tests (verify.py) drive force-owning creation without a connected player. +local function resolve_force(player_index) + local player = player_index and game.players[player_index] + if player and player.valid then + return player.force + end + return game.forces["player"] +end + -- The event IDs for the creative mode on-enabled event and on-disabled event respectively. local on_enabled_event_id local on_disabled_event_id @@ -79,6 +93,40 @@ remote_interface.remote_functions = { end, ------ + -- Creates a new blank surface with the given name. + -- Parameters: + -- name :: string: name of the surface to create. For example, "my-surface". + -- Returns: + -- True if the surface was created. False if the name was empty, a duplicate, or creation failed. + create_blank_surface = function(name) + local success = surface_creation.create_blank_surface(name) + return success + end, + -- Creates a new space platform orbiting the given planet, initialized with a hub. (Space Age only.) + -- Parameters: + -- player_index :: uint (optional): index of the player whose force owns the platform. + -- If omitted or invalid (e.g. headless), the default "player" force is used. + -- name :: string (optional): name of the platform. If empty, the engine assigns a default. + -- planet_id :: string: id of the planet the platform orbits. For example, "nauvis". + -- Returns: + -- True if the platform was created. False on vanilla (no Space Age), an invalid planet, or creation failure. + create_space_platform = function(player_index, name, planet_id) + local force = resolve_force(player_index) + local success = surface_creation.create_space_platform(force, name, planet_id) + return success + end, + -- Creates the surface for the given planet, if not already created. (Space Age only.) + -- Parameters: + -- planet_id :: string: id of the planet whose surface to create. For example, "nauvis". + -- Returns: + -- True if the planet surface exists after the call (whether newly created or it already existed). + -- False on vanilla (no Space Age), an invalid planet, or creation failure. + create_planet_surface = function(planet_id) + local success = surface_creation.create_planet_surface(planet_id) + return success + end, + ------ + -- Registers the function in your remote interface to make it callable via the modding UI as the form of a button. -- You can provide additional data with the third optional table parameter. So far, only caption and tooltip are supported. -- This function can be used for updating the additional data as well. diff --git a/scripts/surface-creation.lua b/scripts/surface-creation.lua new file mode 100644 index 0000000..bc73e51 --- /dev/null +++ b/scripts/surface-creation.lua @@ -0,0 +1,112 @@ +-- This file contains variables and functions related to creating new surfaces on demand. +if not surface_creation then + surface_creation = {} +end + +-- Creates a new blank surface with the given name. +-- The name is trimmed; empty and duplicate names are rejected (game.create_surface errors on a duplicate name). +-- Returns: +-- on success: true, surface +-- on failure: false, localised-string error message +function surface_creation.create_blank_surface(name) + -- Trim leading/trailing whitespace. + local trimmed_name + if type(name) == "string" then + trimmed_name = name:match("^%s*(.-)%s*$") + end + + -- Reject empty names. + if not trimmed_name or trimmed_name == "" then + return false, { "message.creative-mode_surface-creation-blank-empty-name" } + end + + -- Reject duplicate names (game.create_surface would error otherwise). + if game.surfaces[trimmed_name] then + return false, { "message.creative-mode_surface-creation-blank-duplicate-name", trimmed_name } + end + + local surface = game.create_surface(trimmed_name) + if not surface then + return false, { "message.creative-mode_surface-creation-blank-failed", trimmed_name } + end + + return true, surface +end + +-- Creates a new space platform for the given force, orbiting the given planet, initialized with a hub. +-- Space-Age-only: refuses on configurations without the "space_travel" feature flag. +-- The name is trimmed; an empty name is allowed (the engine assigns a default platform name). +-- Returns: +-- on success: true, surface (the platform's surface) +-- on failure: false, localised-string error message +function surface_creation.create_space_platform(force, name, planet_id) + -- Feature-gate: space platforms only exist with Space Age. + if not script.feature_flags["space_travel"] then + return false, { "message.creative-mode_surface-creation-not-available-without-space-age" } + end + + if not (force and force.valid) then + return false, { "message.creative-mode_surface-creation-platform-failed" } + end + + if not (planet_id and game.planets[planet_id]) then + return false, { "message.creative-mode_surface-creation-platform-invalid-planet" } + end + + -- Trim the optional name; an empty name lets the engine assign its default. + local trimmed_name + if type(name) == "string" then + trimmed_name = name:match("^%s*(.-)%s*$") + end + if trimmed_name == "" then + trimmed_name = nil + end + + local platform = force.create_space_platform({ + name = trimmed_name, + planet = planet_id, + starter_pack = "space-platform-starter-pack", + }) + if not platform then + return false, { "message.creative-mode_surface-creation-platform-failed" } + end + + -- Place the hub immediately so the platform comes up ready. + platform.apply_starter_pack() + + return true, platform.surface +end + +-- Creates the surface for the given planet. +-- Space-Age-only: refuses on configurations without the "space_travel" feature flag. +-- LuaPlanet::create_surface() takes no arguments and is a no-op if the surface already exists, so +-- a re-creation is reported as a success with an informational "already existed" result rather than +-- an error. +-- Returns: +-- on success: true, { surface = surface, already_existed = boolean } +-- on failure: false, localised-string error message +function surface_creation.create_planet_surface(planet_id) + -- Feature-gate: planet surfaces only exist with Space Age. + if not script.feature_flags["space_travel"] then + return false, { "message.creative-mode_surface-creation-not-available-without-space-age" } + end + + local planet = planet_id and game.planets[planet_id] + if not planet then + return false, { "message.creative-mode_surface-creation-planet-invalid-planet" } + end + + -- A planet has at most one surface; if it already exists, create_surface() is a no-op. + local already_existed = planet.surface ~= nil + + local surface = planet.create_surface() + -- create_surface() returns the surface; fall back to planet.surface for robustness. + surface = surface or planet.surface + if not surface then + return false, { "message.creative-mode_surface-creation-planet-failed" } + end + + -- Always return the same structured shape so callers can read result.already_existed safely + -- (indexing a raw LuaSurface for a missing key errors). + return true, { surface = surface, already_existed = already_existed } +end diff --git a/verify.py b/verify.py index ac81534..3fe7f65 100644 --- a/verify.py +++ b/verify.py @@ -341,13 +341,92 @@ def cmd_behavior(args: argparse.Namespace) -> int: "false", "default_disabled", ), + # create_blank_surface: a fresh name creates the surface (true), and a + # second call with the same name is rejected as a duplicate (false). + _assert_rcon( + sb, + '/c rcon.print(tostring(remote.call("creative-mode", "create_blank_surface", "cm_verify")))', + "true", + "create_blank_surface_new", + ), + _assert_rcon( + sb, + '/c rcon.print(tostring(remote.call("creative-mode", "create_blank_surface", "cm_verify")))', + "false", + "create_blank_surface_duplicate", + ), + # create_space_platform: the sandbox has Space Age, so the happy-path is testable. + # Headless has no connected player, so pass nil and let the wrapper resolve the + # default "player" force. + _assert_rcon( + sb, + '/c rcon.print(tostring(remote.call("creative-mode", "create_space_platform", nil, "cm_platform", "nauvis")))', + "true", + "create_space_platform_new", + ), + # The created platform's surface exists and its hub is valid. + _assert_rcon( + sb, + '/c local s = nil for _, surf in pairs(game.surfaces) do if surf.platform and surf.platform.name == "cm_platform" then s = surf end end ' + "rcon.print(tostring(s ~= nil and s.platform.hub ~= nil and s.platform.hub.valid))", + "true", + "create_space_platform_hub_valid", + ), + # create_planet_surface: the sandbox has Space Age, so the happy-path is testable. + # A fresh call creates the planet's surface (true). + _assert_rcon( + sb, + '/c rcon.print(tostring(remote.call("creative-mode", "create_planet_surface", "nauvis")))', + "true", + "create_planet_surface_new", + ), + # The planet's surface now exists. + _assert_rcon( + sb, + '/c rcon.print(tostring(game.planets["nauvis"].surface ~= nil))', + "true", + "create_planet_surface_exists", + ), + # A second identical call is a no-op and still returns true. + _assert_rcon( + sb, + '/c rcon.print(tostring(remote.call("creative-mode", "create_planet_surface", "nauvis")))', + "true", + "create_planet_surface_noop", + ), + # No second surface was created: the planet still has exactly one surface, and the + # nauvis-named surface count is unchanged (1). + _assert_rcon( + sb, + '/c local n = 0 for _, surf in pairs(game.surfaces) do if surf.name == "nauvis" then n = n + 1 end end rcon.print(tostring(n))', + "1", + "create_planet_surface_no_duplicate", + ), ] finally: _terminate_server(server) if all(results): return result("behavior", True) - failed = [name for name, ok in zip(("storage_initialized", "default_disabled"), results) if not ok] + failed = [ + name + for name, ok in zip( + ( + "storage_initialized", + "default_disabled", + "create_blank_surface_new", + "create_blank_surface_duplicate", + "create_space_platform_new", + "create_space_platform_hub_valid", + "create_planet_surface_new", + "create_planet_surface_exists", + "create_planet_surface_noop", + "create_planet_surface_no_duplicate", + ), + results, + ) + if not ok + ] return result("behavior", False, "assert " + ", ".join(failed))