From fb0caf7b1609a71a7366d0ac76bccc8cdc761185 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:14:53 -0800
Subject: [PATCH 01/72] Let captains pay ship parts to save a ship in a bay
A captain whose ship sits in a player outpost's ship bay can spend ship parts
to store it in one of three save slots. Saves live in a new player_ship_saves
table (schema 5.35) with the map compressed by the database, and only small
metadata is cached in the game. Retrieval comes later.
A saved hull stores no per-machine data and comes apart into iron: walls and
mineral floors drop iron, false walls and mineral doors save as plain iron
versions, and valuable structures stay behind. Walls and floors now keep that
through shuttle moves, which rebuild each turf from its type.
Checkpoints of either kind now keep the ship's crew roster and class name.
spend_parts() gives back earlier classes when a later class fails. Setting a
new ship join password now costs ship parts; clearing it or entering the
current one again stays free.
---
SQL/database_changelog.md | 28 +-
SQL/migrations/voidcrew_ship_saves.sql | 18 +
SQL/tgstation_schema.sql | 18 +
SQL/tgstation_schema_prefixed.sql | 18 +
code/__DEFINES/subsystems.dm | 2 +-
tgstation.dme | 5 +
voidcrew/_DEFINES/ship_saves.dm | 10 +
voidcrew/edits/mobs/ship_join_menu.dm | 1 +
.../captain_management/captain_management.dm | 5 +-
.../overmap/code/modules/overmap/ship.dm | 28 +
.../outpost_checkpoint_snapshot.dm | 25 +-
.../player_outposts/outpost_checkpoints.dm | 9 +-
.../ship_purchase/ship_economy_database.dm | 68 ++-
.../modules/ship_saves/ship_save_capture.dm | 154 ++++++
.../modules/ship_saves/ship_save_console.dm | 84 +++
.../modules/ship_saves/ship_save_store.dm | 84 +++
voidcrew/modules/ship_saves/ship_saves.dm | 93 ++++
voidcrew/modules/unit_tests/_unit_tests.dm | 1 +
.../modules/unit_tests/voidcrew_ship_saves.dm | 506 ++++++++++++++++++
19 files changed, 1146 insertions(+), 11 deletions(-)
create mode 100644 SQL/migrations/voidcrew_ship_saves.sql
create mode 100644 voidcrew/_DEFINES/ship_saves.dm
create mode 100644 voidcrew/modules/ship_saves/ship_save_capture.dm
create mode 100644 voidcrew/modules/ship_saves/ship_save_console.dm
create mode 100644 voidcrew/modules/ship_saves/ship_save_store.dm
create mode 100644 voidcrew/modules/ship_saves/ship_saves.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
diff --git a/SQL/database_changelog.md b/SQL/database_changelog.md
index df9e3b5b276..882b20962a2 100644
--- a/SQL/database_changelog.md
+++ b/SQL/database_changelog.md
@@ -2,22 +2,44 @@ Any time you make a change to the schema files, remember to increment the databa
Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`.
-The latest database version is 5.34; The query to update the schema revision table is:
+The latest database version is 5.35; The query to update the schema revision table is:
```sql
-INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 34);
+INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 35);
```
or
```sql
-INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 34);
+INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 35);
```
In any query remember to add a prefix to the table names if you use one.
---
+Version 5.35, 5 November 2026, by Jackriip
+Adds the `player_ship_saves` table for Voidcrew ship saving: one row per used save slot, with the ship's
+map stored through `COMPRESS()`.
+
+```sql
+CREATE TABLE `player_ship_saves` (
+ `ckey` VARCHAR(32) NOT NULL,
+ `slot` TINYINT(3) UNSIGNED NOT NULL,
+ `ship_name` VARCHAR(128) NOT NULL,
+ `ship_class` VARCHAR(128) NULL,
+ `tile_count` SMALLINT(5) UNSIGNED NOT NULL,
+ `format_version` SMALLINT(5) UNSIGNED NOT NULL,
+ `saved_round` INT(11) NOT NULL,
+ `saved_at` DATETIME NOT NULL,
+ `raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `payload` MEDIUMBLOB NOT NULL,
+ PRIMARY KEY (`ckey`, `slot`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+```
+
+---
+
Version 5.34, 28 September 2026, by Jackriip
Adds the `round_metric` table for Voidcrew round metrics: economy, trade, cargo, missions, research,
NPC ship encounters, combat tallies and deaths, one row per event.
diff --git a/SQL/migrations/voidcrew_ship_saves.sql b/SQL/migrations/voidcrew_ship_saves.sql
new file mode 100644
index 00000000000..6bebc4b487b
--- /dev/null
+++ b/SQL/migrations/voidcrew_ship_saves.sql
@@ -0,0 +1,18 @@
+-- Ship saving (voidcrew/modules/ship_saves): one row per used save slot. Schema version 5.35.
+-- A save is removed when it is used, so this table needs no purge job.
+
+CREATE TABLE IF NOT EXISTS `player_ship_saves` (
+ `ckey` VARCHAR(32) NOT NULL,
+ `slot` TINYINT(3) UNSIGNED NOT NULL,
+ `ship_name` VARCHAR(128) NOT NULL,
+ `ship_class` VARCHAR(128) NULL,
+ `tile_count` SMALLINT(5) UNSIGNED NOT NULL,
+ `format_version` SMALLINT(5) UNSIGNED NOT NULL,
+ `saved_round` INT(11) NOT NULL,
+ `saved_at` DATETIME NOT NULL,
+ `raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `payload` MEDIUMBLOB NOT NULL,
+ PRIMARY KEY (`ckey`, `slot`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+
+INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 35);
diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql
index 0a0b1b99a94..d87432fee13 100644
--- a/SQL/tgstation_schema.sql
+++ b/SQL/tgstation_schema.sql
@@ -561,6 +561,24 @@ CREATE TABLE `round_metric` (
KEY `idx_event_datetime` (`event`, `datetime`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+--
+-- Table structure for table `player_ship_saves`
+--
+DROP TABLE IF EXISTS `player_ship_saves`;
+CREATE TABLE `player_ship_saves` (
+ `ckey` VARCHAR(32) NOT NULL,
+ `slot` TINYINT(3) UNSIGNED NOT NULL,
+ `ship_name` VARCHAR(128) NOT NULL,
+ `ship_class` VARCHAR(128) NULL,
+ `tile_count` SMALLINT(5) UNSIGNED NOT NULL,
+ `format_version` SMALLINT(5) UNSIGNED NOT NULL,
+ `saved_round` INT(11) NOT NULL,
+ `saved_at` DATETIME NOT NULL,
+ `raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `payload` MEDIUMBLOB NOT NULL,
+ PRIMARY KEY (`ckey`, `slot`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+
--
-- Table structure for table `schema_revision`
--
diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql
index e04275e4b41..49f6fb25bfa 100644
--- a/SQL/tgstation_schema_prefixed.sql
+++ b/SQL/tgstation_schema_prefixed.sql
@@ -560,6 +560,24 @@ CREATE TABLE `SS13_round_metric` (
KEY `idx_event_datetime` (`event`, `datetime`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+--
+-- Table structure for table `SS13_player_ship_saves`
+--
+DROP TABLE IF EXISTS `SS13_player_ship_saves`;
+CREATE TABLE `SS13_player_ship_saves` (
+ `ckey` VARCHAR(32) NOT NULL,
+ `slot` TINYINT(3) UNSIGNED NOT NULL,
+ `ship_name` VARCHAR(128) NOT NULL,
+ `ship_class` VARCHAR(128) NULL,
+ `tile_count` SMALLINT(5) UNSIGNED NOT NULL,
+ `format_version` SMALLINT(5) UNSIGNED NOT NULL,
+ `saved_round` INT(11) NOT NULL,
+ `saved_at` DATETIME NOT NULL,
+ `raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `payload` MEDIUMBLOB NOT NULL,
+ PRIMARY KEY (`ckey`, `slot`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+
--
-- Table structure for table `SS13_schema_revision`
--
diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm
index 5e1cd893fbf..fe0f40b5147 100644
--- a/code/__DEFINES/subsystems.dm
+++ b/code/__DEFINES/subsystems.dm
@@ -20,7 +20,7 @@
*
* make sure you add an update to the schema_version stable in the db changelog
*/
-#define DB_MINOR_VERSION 34 // VOIDCREW EDIT - 34 adds round_metric (voidcrew/modules/metrics)
+#define DB_MINOR_VERSION 35 // VOIDCREW EDIT - 34 adds round_metric (voidcrew/modules/metrics), 35 adds player_ship_saves (voidcrew/modules/ship_saves)
//! ## Timing subsystem
diff --git a/tgstation.dme b/tgstation.dme
index 22c33b7caa9..49bd4fdfa48 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -6736,6 +6736,7 @@
#include "voidcrew\_DEFINES\ship_combat.dm"
#include "voidcrew\_DEFINES\ship_construction.dm"
#include "voidcrew\_DEFINES\ship_defines.dm"
+#include "voidcrew\_DEFINES\ship_saves.dm"
#include "voidcrew\_DEFINES\ship_signals.dm"
#include "voidcrew\_DEFINES\shuttles.dm"
#include "voidcrew\_DEFINES\sound.dm"
@@ -7579,6 +7580,10 @@
#include "voidcrew\modules\ship_combat\electronic_warfare\ew_suite.dm"
#include "voidcrew\modules\ship_purchase\ship_economy_database.dm"
#include "voidcrew\modules\ship_purchase\transaction_helpers.dm"
+#include "voidcrew\modules\ship_saves\ship_save_capture.dm"
+#include "voidcrew\modules\ship_saves\ship_save_console.dm"
+#include "voidcrew\modules\ship_saves\ship_save_store.dm"
+#include "voidcrew\modules\ship_saves\ship_saves.dm"
#include "voidcrew\modules\ship_upgrades\_ship_upgrades.dm"
#include "voidcrew\modules\ship_upgrades\admin_verbs.dm"
#include "voidcrew\modules\ship_upgrades\modular_map_root_ship.dm"
diff --git a/voidcrew/_DEFINES/ship_saves.dm b/voidcrew/_DEFINES/ship_saves.dm
new file mode 100644
index 00000000000..033cf74a44b
--- /dev/null
+++ b/voidcrew/_DEFINES/ship_saves.dm
@@ -0,0 +1,10 @@
+/// Bump this when the payload layout or the list of saved vars changes. Older saves are then refused.
+#define SHIP_SAVE_FORMAT 1
+/// How many ships one player can have saved at once.
+#define SHIP_SAVE_SLOTS 3
+/// The least a save can cost, in ship parts.
+#define SHIP_SAVE_PRICE_MIN 2
+/// The most a save can cost, in ship parts.
+#define SHIP_SAVE_PRICE_MAX 15
+/// What setting a new ship join password costs, in ship parts.
+#define SHIP_PASSWORD_PRICE 3
diff --git a/voidcrew/edits/mobs/ship_join_menu.dm b/voidcrew/edits/mobs/ship_join_menu.dm
index cfd1b6f135f..2b3b1eddbce 100644
--- a/voidcrew/edits/mobs/ship_join_menu.dm
+++ b/voidcrew/edits/mobs/ship_join_menu.dm
@@ -66,6 +66,7 @@
/datum/ship_join_menu/New(mob/dead/new_player/player)
. = ..()
user = player
+ GLOB.ship_saves.refresh(player?.ckey)
/datum/ship_join_menu/Destroy()
user = null
diff --git a/voidcrew/modules/captain_management/captain_management.dm b/voidcrew/modules/captain_management/captain_management.dm
index 3d1aa301718..cf3e256454d 100644
--- a/voidcrew/modules/captain_management/captain_management.dm
+++ b/voidcrew/modules/captain_management/captain_management.dm
@@ -129,6 +129,7 @@
data["joining_allowed"] = ship.joining_allowed
data["join_password"] = ship.join_password || ""
data["can_set_password"] = ship.can_have_join_password()
+ data["password_price"] = SHIP_PASSWORD_PRICE
data["crew_only_airlocks"] = ship.crew_only_airlocks
// Check if user is still captain
@@ -263,8 +264,8 @@
return TRUE
if("set_password")
- // set_join_password handles validation, the fleet-hull refusal, feedback, and logging
- ship.set_join_password(params["password"], captain)
+ // buy_join_password takes the payment, and sleeps on the database doing so
+ INVOKE_ASYNC(ship, TYPE_PROC_REF(/obj/structure/overmap/ship, buy_join_password), params["password"], captain)
return TRUE
if("reset_join_access")
diff --git a/voidcrew/modules/overmap/code/modules/overmap/ship.dm b/voidcrew/modules/overmap/code/modules/overmap/ship.dm
index d15ad4ed61c..7a866223f29 100644
--- a/voidcrew/modules/overmap/code/modules/overmap/ship.dm
+++ b/voidcrew/modules/overmap/code/modules/overmap/ship.dm
@@ -25,6 +25,10 @@
var/obj/docking_port/mobile/voidcrew/shuttle
///The map template the shuttle was spawned from, if it was indeed created from a template. CAN BE NULL (ex. custom-built ships).
var/datum/map_template/shuttle/voidcrew/source_template
+ /// The crew roster this hull was set up with, as job definitions. Ship saves and checkpoints keep it.
+ var/list/job_definitions
+ /// A password is being paid for, so a double click can't charge twice.
+ var/buying_join_password = FALSE
/**
* Ship states and deletion.
@@ -721,6 +725,7 @@
var/list/slot_ids = selected_theme?.upgrade_slot_ids || source_template.upgrade_slot_ids
job_slot_definitions += get_module_job_definitions(source_template.type, upgrade_selections, slot_ids, selected_theme?.id)
+ job_definitions = job_slot_definitions
job_slots = assemble_job_slots_from_list(job_slot_definitions)
for(var/datum/job/crew_job as anything in job_slots)
crew_job.crew_ship_ref = WEAKREF(src)
@@ -1311,6 +1316,29 @@
log_game("[key_name(user)] set a join password on ship [name]")
return TRUE
+/**
+ * What the captain's panel calls. A new password costs ship parts; clearing it or entering the
+ * current one again is free. set_join_password() itself stays free. Sleeps on the database.
+ */
+/obj/structure/overmap/ship/proc/buy_join_password(new_password, mob/user)
+ new_password = trim("[new_password || ""]", SHIP_JOIN_PASSWORD_MAX_LEN + 1)
+ // A fleet hull is refused in there, before anything is charged.
+ if(!length(new_password) || !can_have_join_password() || (join_password && check_join_password(new_password)))
+ return set_join_password(new_password, user)
+ var/player_ckey = user?.ckey
+ if(!player_ckey || buying_join_password)
+ return FALSE
+ buying_join_password = TRUE
+ var/list/plan = GLOB.ship_economy_db.spend_any_parts(player_ckey, SHIP_PASSWORD_PRICE)
+ buying_join_password = FALSE
+ if(!plan)
+ to_chat(user, span_warning("You need [SHIP_PASSWORD_PRICE] ship parts."))
+ return FALSE
+ if(QDELETED(src) || !is_ship_captain(user) || !set_join_password(new_password, user))
+ GLOB.ship_economy_db.refund_parts(player_ckey, plan)
+ return FALSE
+ return TRUE
+
/**
* Revokes remembered passwords, invitations, and application approvals without
* removing anyone from the crew roster. Authorization is the caller's responsibility.
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
index aad06503529..9c6100f1a36 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
@@ -96,6 +96,12 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
var/list/machines = list()
var/busy = FALSE
var/error
+ /// Saved across rounds: stores no per-machine data and comes apart into iron (ship_save_capture.dm).
+ var/persistent = FALSE
+ /// The hull's class, shown wherever it is listed.
+ var/class_name
+ /// The crew roster as template job definitions. Null leaves the commissioned default.
+ var/list/jobs
/datum/ship_checkpoint/Destroy()
if(outpost)
@@ -108,10 +114,13 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
tgm = null
rooms = null
machines = null
+ jobs = null
return ..()
/// Ports are handled separately and cannot carry NPC types.
/datum/ship_checkpoint/proc/includes_object(obj/object)
+ if(persistent && ship_save_refuses_object(object))
+ return FALSE
return outpost_checkpoint_saves(object)
/// Preserve placement and construction settings; inventories are never serialized.
@@ -122,8 +131,10 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
if(ismachinery(object))
var/obj/machinery/machine = object
saved_type = machine.checkpoint_type()
- machines += list(capture_machine(machine))
- properties += "checkpoint_load_id = [length(machines)]"
+ // A saved ship's machines come back at default tier, so there is nothing to restore.
+ if(!persistent)
+ machines += list(capture_machine(machine))
+ properties += "checkpoint_load_id = [length(machines)]"
if(istype(machine, /obj/machinery/power/smes))
keys += list("input_level", "output_level")
if(istype(machine, /obj/machinery/power))
@@ -155,6 +166,8 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
keys += "open_ports"
if(istype(object, /obj/machinery/duct))
keys += list("duct_layer", "duct_color", "connects")
+ if(persistent)
+ saved_type = strip_salvage(object, saved_type, keys, properties)
for(var/key in keys)
var/value = object.vars[key]
// Always encode direction: an oriented source must survive a round trip.
@@ -162,6 +175,8 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
continue
if(istext(value))
value = replacetext(value, "\\", "")
+ if(persistent)
+ value = ship_save_plain_text(value)
var/encoded = tgm_encode(value)
if(encoded)
properties += "[key] = [encoded]"
@@ -250,6 +265,8 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
source_ship = WEAKREF(ship)
captain_ckey = owner_ckey
ship_name = ship.name
+ class_name = ship.source_template?.short_name
+ jobs = ship.job_definitions?.Copy()
saved_at = world.time
return null
@@ -271,6 +288,10 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
height = snapshot.height
port_x_offset = snapshot.port_x
port_y_offset = snapshot.port_y
+ if(length(snapshot.jobs))
+ job_slots = snapshot.jobs
+ if(snapshot.class_name)
+ short_name = snapshot.class_name
/// Restore distinct player-built rooms before machines initialize their APC links.
/// A plain TGM load otherwise merges rooms sharing one /area type.
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoints.dm b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
index fe1f2cc8ccb..d7fb5eb5d50 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoints.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
@@ -81,6 +81,7 @@
user_ref = WEAKREF(user)
host_turf = get_turf(host)
RegisterSignal(home, COMSIG_QDELETING, PROC_REF(on_outpost_deleted))
+ GLOB.ship_saves.refresh(user?.ckey)
/datum/ship_checkpoint_ui/Destroy()
SStgui.close_uis(src)
@@ -196,8 +197,8 @@
return "The ship account has insufficient credits."
return null
-/datum/ship_checkpoint_ui/proc/confirm_save(mob/user, prompt_text)
- return tgui_alert(user, prompt_text, "Ship checkpoint", list("Confirm", "Cancel")) == "Confirm"
+/datum/ship_checkpoint_ui/proc/confirm_save(mob/user, prompt_text, title = "Ship checkpoint")
+ return tgui_alert(user, prompt_text, title, list("Confirm", "Cancel")) == "Confirm"
/datum/ship_checkpoint_ui/proc/save_quote(mob/living/user)
if(working || !quote)
@@ -259,6 +260,7 @@
"update_cost" = OUTPOST_CHECKPOINT_UPDATE_COST,
"has_checkpoint" = !!outpost.checkpoint_for(user),
"shop" = shop_data(user),
+ "saved_ships" = saved_ships_data(user),
)
/datum/ship_checkpoint_ui/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
@@ -282,6 +284,9 @@
if("rebuild")
var/datum/ship_checkpoint/snapshot = locate(params["ref"]) in outpost.checkpoints
rebuild(user, snapshot)
+ if("ship_save_store")
+ var/datum/outpost_berth/ship_bay/bay = locate(params["ref"]) in outpost.bay_berths
+ save_ship(user, bay)
else
shop_act(user, action, params)
return TRUE
diff --git a/voidcrew/modules/ship_purchase/ship_economy_database.dm b/voidcrew/modules/ship_purchase/ship_economy_database.dm
index 8db629a1524..2c9aba7cc14 100644
--- a/voidcrew/modules/ship_purchase/ship_economy_database.dm
+++ b/voidcrew/modules/ship_purchase/ship_economy_database.dm
@@ -258,7 +258,8 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new)
log_game("SHIP_ECONOMY: [ckey] insufficient [part_class] parts (have: [have], need: [needed])")
return FALSE
- // Deduct each class
+ // Deduct each class. If a later class fails, the earlier ones go back.
+ var/list/deducted = list()
for(var/part_class in requirements)
var/amount = requirements[part_class]
@@ -272,9 +273,12 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new)
if(!query.Execute() || query.affected < 1)
qdel(query)
log_game("SHIP_ECONOMY: [ckey] failed to deduct [amount] [part_class] parts (transaction failed)")
+ refund_parts(ckey, deducted)
+ invalidate_cache(ckey, "parts")
return FALSE
qdel(query)
+ deducted[part_class] = amount
// Invalidate cache
invalidate_cache(ckey, "parts")
@@ -287,6 +291,68 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new)
return TRUE
+/**
+ * Spend a total number of parts from any class, largest stack first.
+ * Reads fresh from the database, and can sleep.
+ *
+ * @param ckey - The player's ckey
+ * @param total - How many parts to take
+ * @return The class -> quantity list taken, for refund_parts(). Null if it could not be paid.
+ */
+/datum/ship_economy_db/proc/spend_any_parts(ckey, total)
+ if(!ckey || total <= 0)
+ return null
+
+ ckey = ckey(ckey) // Normalize ckey
+
+ invalidate_cache(ckey, "parts")
+ var/list/plan = plan_part_spend(get_parts(ckey), total)
+ if(!plan || !spend_parts(ckey, plan))
+ return null
+ return plan
+
+/// Which classes pay a total: all of the largest stack, then the next. Null if there aren't enough.
+/datum/ship_economy_db/proc/plan_part_spend(list/held, total)
+ if(!held)
+ return null
+ var/list/remaining = held.Copy()
+ var/list/plan = list()
+ while(total > 0)
+ var/largest
+ for(var/part_class in remaining)
+ if(remaining[part_class] > (largest ? remaining[largest] : 0))
+ largest = part_class
+ if(!largest)
+ return null
+ var/taken = min(remaining[largest], total)
+ plan[largest] = taken
+ total -= taken
+ remaining -= largest
+ return plan
+
+/**
+ * Give back parts taken by spend_any_parts() or spend_parts()
+ *
+ * @param ckey - The player's ckey
+ * @param plan - Class -> quantity list to give back
+ * @return TRUE if every class went back
+ */
+/datum/ship_economy_db/proc/refund_parts(ckey, list/plan)
+ . = TRUE
+ for(var/part_class in plan)
+ if(!add_part(ckey, part_class, plan[part_class], "refund"))
+ log_game("SHIP_ECONOMY: [ckey] LOST [plan[part_class]] [part_class] parts: the refund failed")
+ . = FALSE
+
+/// Total parts held, from the cache alone so it never sleeps. Null until something has loaded them.
+/datum/ship_economy_db/proc/cached_parts_total(ckey)
+ var/list/parts = ckey ? parts_cache[ckey(ckey)] : null
+ if(!parts)
+ return null
+ . = 0
+ for(var/part_class in parts)
+ . += parts[part_class]
+
/**
* Check if a ship template is unlocked for a player
*
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
new file mode 100644
index 00000000000..376004e28fa
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -0,0 +1,154 @@
+/// Hull pieces a saved ship always keeps, whatever they are made of. Windows and grilles stay as
+/// they are; false walls and mineral doors are saved as their plain iron versions.
+GLOBAL_LIST_INIT(ship_save_sealing_structures, typecacheof(list(
+ /obj/structure/window,
+ /obj/structure/grille,
+ /obj/structure/falsewall,
+ /obj/structure/mineral_door,
+)))
+
+/// Build a statue of gold and save it for two parts, and you could break it down next round.
+/proc/ship_save_refuses_object(obj/object)
+ if(!isstructure(object) || is_type_in_typecache(object, GLOB.ship_save_sealing_structures))
+ return FALSE
+ for(var/material_key in object.custom_materials)
+ var/datum/material/material = GET_MATERIAL_REF(material_key)
+ if(!istype(material, /datum/material/iron) && !istype(material, /datum/material/glass))
+ return TRUE
+ return FALSE
+
+/// Text macros are raw bytes the database connection would mangle. A saved name goes without them.
+/proc/ship_save_plain_text(text)
+ return replacetext(replacetext(text, "\improper", ""), "\proper", "")
+
+/// Tiles in the rooms this hull owns, which is what a save is priced on.
+/proc/hull_owned_tile_count(obj/docking_port/mobile/port)
+ . = 0
+ if(QDELETED(port) || !port.z)
+ return
+ for(var/area/room as anything in hull_owned_areas(port))
+ . += length(room.get_turfs_by_zlevel(port.z))
+
+/datum/ship_checkpoint/proc/owned_tile_count()
+ . = 0
+ for(var/list/room as anything in rooms)
+ var/list/tiles = room["tiles"]
+ . += length(tiles)
+
+/**
+ * A saved hull keeps its look but only ever comes apart into iron, so nothing valuable carries
+ * into the next round. Takes the keys and properties atom_text() is building.
+ * Returns the type to save the atom as.
+ */
+/datum/ship_checkpoint/proc/strip_salvage(atom/object, saved_type, list/keys, list/properties)
+ var/plain_type
+ if(istype(object, /turf/closed/wall))
+ var/turf/closed/wall/wall = object
+ if(wall.sheet_type != /obj/item/stack/sheet/iron)
+ properties += "sheet_type = /obj/item/stack/sheet/iron"
+ else if(istype(object, /turf/open/floor/mineral))
+ properties += "floor_tile = /obj/item/stack/tile/iron"
+ else if(istype(object, /obj/structure/falsewall))
+ plain_type = /obj/structure/falsewall
+ else if(istype(object, /obj/structure/mineral_door))
+ plain_type = /obj/structure/mineral_door/iron
+ if(!plain_type || plain_type == object.type)
+ return saved_type
+ // Not "gold door" on a plain iron door, or a state the plain type doesn't have.
+ keys -= list("name", "icon_state")
+ return plain_type
+
+// A shuttle move rebuilds every turf from its type, which would undo strip_salvage() the first
+// time a saved hull is placed or flown.
+/turf/closed/wall/copyTurf(turf/closed/wall/copy_to_turf, copy_air = FALSE, flags = null)
+ . = ..()
+ if(istype(copy_to_turf))
+ copy_to_turf.sheet_type = sheet_type
+
+/turf/open/floor/copyTurf(turf/open/floor/copy_to_turf, copy_air = FALSE, flags = null)
+ . = ..()
+ if(istype(copy_to_turf))
+ copy_to_turf.floor_tile = floor_tile
+
+/// The text stored for a captured ship. Paths become text so saves outlive code changes.
+/datum/ship_saves/proc/encode(datum/ship_checkpoint/snapshot)
+ var/list/rooms = list()
+ for(var/list/room as anything in snapshot.rooms)
+ rooms += list(list("name" = ship_save_plain_text(room["name"]), "type" = "[room["type"]]", "tiles" = room["tiles"]))
+ var/list/jobs = list()
+ for(var/list/definition in snapshot.jobs)
+ var/list/job = list()
+ for(var/key in definition)
+ var/value = definition[key]
+ job[key] = ispath(value) ? "[value]" : value
+ jobs += list(job)
+ return json_encode(list(
+ "format" = SHIP_SAVE_FORMAT,
+ "name" = snapshot.ship_name,
+ "class" = snapshot.class_name,
+ "width" = snapshot.width,
+ "height" = snapshot.height,
+ "port_x" = snapshot.port_x,
+ "port_y" = snapshot.port_y,
+ "rooms" = rooms,
+ "jobs" = jobs,
+ "tgm" = snapshot.tgm,
+ ))
+
+/**
+ * Turns stored text back into a checkpoint, or null if this build can't use it. Can sleep. Types
+ * removed since the save fall back where they can: unknown rooms become plain ship rooms, a job
+ * whose outfit is gone is dropped, and the map reader skips unknown objects.
+ */
+/datum/ship_saves/proc/decode(text)
+ var/list/data = safe_json_decode(text)
+ if(!islist(data) || data["format"] != SHIP_SAVE_FORMAT)
+ return null
+ var/width = data["width"]
+ var/height = data["height"]
+ if(!isnum(width) || !isnum(height) || min(width, height) < 1)
+ return null
+ if(max(width, height) > RESERVE_DOCK_MAX_SIZE_LONG || min(width, height) > RESERVE_DOCK_MAX_SIZE_SHORT)
+ return null
+ if(!istext(data["tgm"]) || !isnum(data["port_x"]) || !isnum(data["port_y"]) || !islist(data["rooms"]))
+ return null
+ var/datum/ship_checkpoint/snapshot = new
+ snapshot.persistent = TRUE
+ snapshot.ship_name = data["name"]
+ snapshot.class_name = data["class"]
+ snapshot.width = width
+ snapshot.height = height
+ snapshot.port_x = data["port_x"]
+ snapshot.port_y = data["port_y"]
+ snapshot.tgm = data["tgm"]
+ for(var/list/room in data["rooms"])
+ var/room_type = text2path(room["type"])
+ if(!ispath(room_type, /area))
+ room_type = /area/shuttle/voidcrew
+ snapshot.rooms += list(list("name" = room["name"], "type" = room_type, "tiles" = room["tiles"]))
+ var/list/jobs = list()
+ for(var/list/definition in data["jobs"])
+ var/list/job = definition.Copy()
+ job["outfit"] = text2path(job["outfit"])
+ job["borg_model"] = text2path(job["borg_model"])
+ if(!ship_job_definition_error(job))
+ jobs += list(job)
+ // With none left, the commissioned default applies.
+ snapshot.jobs = length(jobs) ? jobs : null
+ log_missing_types(snapshot)
+ return snapshot
+
+/// The map reader skips types removed since the save. Say which, so a missing fitting is explainable.
+/datum/ship_saves/proc/log_missing_types(datum/ship_checkpoint/snapshot)
+ var/list/seen = list()
+ var/list/missing = list()
+ var/regex/atom_path = new(@"^/\w[\w/]*", "gm")
+ while(atom_path.Find(snapshot.tgm))
+ var/path_text = atom_path.match
+ if(!seen[path_text])
+ seen[path_text] = TRUE
+ if(!text2path(path_text))
+ missing += path_text
+ CHECK_TICK
+ if(length(missing))
+ log_game("Ship save [snapshot.ship_name] has types that no longer exist: [missing.Join(", ")]")
diff --git a/voidcrew/modules/ship_saves/ship_save_console.dm b/voidcrew/modules/ship_saves/ship_save_console.dm
new file mode 100644
index 00000000000..3fbc51c79c7
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_console.dm
@@ -0,0 +1,84 @@
+/// Why this user can't save the ship in this bay, or null. Only checks caches, so ui_data can use it.
+/datum/ship_checkpoint_ui/proc/saved_ship_denial(mob/living/user, datum/outpost_berth/ship_bay/bay)
+ if(QDELETED(src) || ui_status(user, GLOB.always_state) != UI_INTERACTIVE)
+ return "Shipyard console unavailable."
+ if(QDELETED(bay) || bay.outpost != outpost || !(bay in outpost.bay_berths) || !bay.is_ship_present())
+ return "Dock your ship in a ship bay first."
+ var/obj/structure/overmap/ship/ship = bay.ship
+ if(!ship.is_ship_captain(user) || ship.abandoned || ship.retired_by_checkpoint)
+ return "Only the ship's current captain can save it."
+ if(ship.checkpoint_rebuilding)
+ return "Recovery is already in progress."
+ if(!length(ship.shuttle.spawn_points))
+ return "This hull has no cryopods."
+ // Unloaded is not the same as empty: a failed read must not look like three free slots.
+ if(!GLOB.ship_save_store.online() || isnull(GLOB.ship_saves.saves_for(user.ckey)))
+ return "Shipyard records are offline."
+ if(!GLOB.ship_saves.free_slot(user.ckey))
+ return "All three slots are full."
+ return null
+
+/// The denial, plus whether the ship we captured is still the one in the bay.
+/datum/ship_checkpoint_ui/proc/saved_ship_changed_denial(mob/living/user, datum/outpost_berth/ship_bay/bay, obj/structure/overmap/ship/ship)
+ var/denial = saved_ship_denial(user, bay)
+ if(denial)
+ return denial
+ if(bay.ship != ship)
+ return "The docked ship changed. Try again."
+ return null
+
+/// Captures the ship, asks the captain, and charges them. Sleeps at every step, so everything is
+/// checked again afterwards.
+/datum/ship_checkpoint_ui/proc/save_ship(mob/living/user, datum/outpost_berth/ship_bay/bay)
+ error = saved_ship_denial(user, bay)
+ if(error)
+ return FALSE
+ working = TRUE
+ notice = null
+ var/obj/structure/overmap/ship/ship = bay.ship
+ var/ship_name = ship.name
+ var/player_ckey = user.ckey
+ var/datum/ship_checkpoint/snapshot = new
+ snapshot.persistent = TRUE
+ var/failure = snapshot.capture(ship, user) || saved_ship_changed_denial(user, bay, ship)
+ if(!failure)
+ var/price = GLOB.ship_saves.price_for(snapshot.owned_tile_count())
+ if(!confirm_save(user, "Save [ship_name] for [price] ship parts?", "Shipyard"))
+ qdel(snapshot)
+ working = FALSE
+ return FALSE
+ failure = saved_ship_changed_denial(user, bay, ship)
+ if(!failure)
+ failure = GLOB.ship_saves.commit_save(player_ckey, snapshot, user)
+ qdel(snapshot)
+ if(QDELETED(src))
+ return !failure
+ working = FALSE
+ error = failure
+ if(failure)
+ return FALSE
+ notice = "[ship_name] saved."
+ return TRUE
+
+/// What the Saved Ships tab shows. Reads only caches; ui_data must not touch the database.
+/datum/ship_checkpoint_ui/proc/saved_ships_data(mob/user)
+ var/list/saves = list()
+ for(var/list/row as anything in GLOB.ship_saves.saves_for(user.ckey))
+ saves += list(list("slot" = row["slot"], "name" = row["ship_name"], "class" = row["ship_class"], "denial" = null))
+ var/list/ships = list()
+ for(var/datum/outpost_berth/ship_bay/bay as anything in outpost.bay_berths)
+ if(!bay?.ship?.is_ship_captain(user))
+ continue
+ ships += list(list(
+ "ref" = REF(bay),
+ "name" = bay.ship.name,
+ "price" = GLOB.ship_saves.price_for(hull_owned_tile_count(bay.ship.shuttle)),
+ "denial" = saved_ship_denial(user, bay),
+ ))
+ return list(
+ "online" = !!GLOB.ship_save_store.online(),
+ "parts" = GLOB.ship_economy_db.cached_parts_total(user.ckey),
+ "slots" = SHIP_SAVE_SLOTS,
+ "saves" = saves,
+ "ships" = ships,
+ )
diff --git a/voidcrew/modules/ship_saves/ship_save_store.dm b/voidcrew/modules/ship_saves/ship_save_store.dm
new file mode 100644
index 00000000000..331ae40db59
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_store.dm
@@ -0,0 +1,84 @@
+GLOBAL_DATUM_INIT(ship_save_store, /datum/ship_save_store, new)
+
+/**
+ * The player_ship_saves table, and nothing else touches it. Every query is async and can sleep.
+ * The map is compressed by the database, so that work stays off the game thread.
+ */
+/datum/ship_save_store
+
+/// Whether the table can be reached. Does not sleep.
+/datum/ship_save_store/proc/online()
+ return SSdbcore.IsConnected()
+
+/// The player's saves without their maps. An empty list means none; null means the query failed.
+/datum/ship_save_store/proc/read_meta(ckey)
+ var/datum/db_query/query = SSdbcore.NewQuery(
+ "SELECT slot, ship_name, ship_class, tile_count, format_version, saved_round, saved_at, raw_bytes \
+ FROM [format_table_name("player_ship_saves")] WHERE ckey = :ckey ORDER BY slot",
+ list("ckey" = ckey)
+ )
+ if(!query.Execute())
+ qdel(query)
+ return null
+ var/list/rows = list()
+ while(query.NextRow())
+ rows += list(list(
+ "slot" = text2num(query.item[1]),
+ "ship_name" = query.item[2],
+ "ship_class" = query.item[3],
+ "tile_count" = text2num(query.item[4]),
+ "format_version" = text2num(query.item[5]),
+ "saved_round" = text2num(query.item[6]),
+ "saved_at" = query.item[7],
+ "raw_bytes" = text2num(query.item[8]),
+ ))
+ qdel(query)
+ return rows
+
+/// One save's map text. Null if the query failed or the row is gone.
+/datum/ship_save_store/proc/read_payload(ckey, slot)
+ var/datum/db_query/query = SSdbcore.NewQuery(
+ "SELECT CONVERT(UNCOMPRESS(payload) USING utf8mb4) FROM [format_table_name("player_ship_saves")] \
+ WHERE ckey = :ckey AND slot = :slot",
+ list("ckey" = ckey, "slot" = slot)
+ )
+ if(!query.Execute())
+ qdel(query)
+ return null
+ var/payload
+ if(query.NextRow())
+ payload = query.item[1]
+ qdel(query)
+ return payload
+
+/// A plain INSERT: a duplicate key fails rather than overwriting a save someone paid for.
+/datum/ship_save_store/proc/write(ckey, slot, list/meta, payload)
+ var/datum/db_query/query = SSdbcore.NewQuery(
+ "INSERT INTO [format_table_name("player_ship_saves")] \
+ (ckey, slot, ship_name, ship_class, tile_count, format_version, saved_round, saved_at, raw_bytes, payload) \
+ VALUES (:ckey, :slot, :ship_name, :ship_class, :tile_count, :format_version, :saved_round, NOW(), :raw_bytes, COMPRESS(:payload))",
+ list(
+ "ckey" = ckey,
+ "slot" = slot,
+ "ship_name" = meta["ship_name"],
+ "ship_class" = meta["ship_class"],
+ "tile_count" = meta["tile_count"],
+ "format_version" = meta["format_version"],
+ "saved_round" = meta["saved_round"],
+ "raw_bytes" = meta["raw_bytes"],
+ "payload" = payload,
+ )
+ )
+ var/success = query.Execute()
+ qdel(query)
+ return success
+
+/// TRUE only if a row really was removed, so two callers can't both use one save.
+/datum/ship_save_store/proc/remove(ckey, slot)
+ var/datum/db_query/query = SSdbcore.NewQuery(
+ "DELETE FROM [format_table_name("player_ship_saves")] WHERE ckey = :ckey AND slot = :slot",
+ list("ckey" = ckey, "slot" = slot)
+ )
+ var/success = query.Execute() && query.affected > 0
+ qdel(query)
+ return success
diff --git a/voidcrew/modules/ship_saves/ship_saves.dm b/voidcrew/modules/ship_saves/ship_saves.dm
new file mode 100644
index 00000000000..05b77a02aab
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_saves.dm
@@ -0,0 +1,93 @@
+GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
+
+/**
+ * Each player's saved ships. Only small metadata is cached here: the map itself is never kept in
+ * the game, because every player's saves together would count against the 4 GB limit.
+ */
+/datum/ship_saves
+ /// ckey -> list of metadata rows. A player is absent until a load has succeeded.
+ var/list/cache = list()
+ /// ckey -> number of the newest load, so a slow read can't undo a save made after it started.
+ var/list/load_tickets = list()
+
+/// The player's cached rows, or null if they aren't loaded. This is the only read ui_data may use.
+/datum/ship_saves/proc/saves_for(ckey)
+ SHOULD_NOT_SLEEP(TRUE)
+ return ckey ? cache[ckey(ckey)] : null
+
+/// Loads a player's saves and parts total in the background, then updates their open windows.
+/datum/ship_saves/proc/refresh(ckey)
+ set waitfor = FALSE
+ ckey = ckey(ckey)
+ if(!ckey)
+ return
+ var/ticket = new_load_ticket(ckey)
+ var/list/rows = GLOB.ship_save_store.read_meta(ckey)
+ // A failed read leaves what we had, and never reads as "no saves".
+ if(rows && load_tickets[ckey] == ticket)
+ cache[ckey] = rows
+ GLOB.ship_economy_db.get_parts(ckey)
+ var/client/player = GLOB.directory[ckey]
+ if(player?.mob)
+ SStgui.update_user_uis(player.mob)
+
+/datum/ship_saves/proc/new_load_ticket(ckey)
+ load_tickets[ckey] = (load_tickets[ckey] || 0) + 1
+ return load_tickets[ckey]
+
+/// The lowest empty slot number. Null if they are all taken or the player isn't loaded.
+/datum/ship_saves/proc/free_slot(ckey)
+ var/list/rows = saves_for(ckey)
+ if(!rows)
+ return null
+ var/list/taken = list()
+ for(var/list/row as anything in rows)
+ taken += row["slot"]
+ for(var/slot in 1 to SHIP_SAVE_SLOTS)
+ if(!(slot in taken))
+ return slot
+ return null
+
+/// What a save costs in ship parts: the hull price curve, scaled so the biggest hull costs the most.
+/datum/ship_saves/proc/price_for(tiles)
+ return clamp(floor(6 * ((tiles / 200) ** 1.6) / 4), SHIP_SAVE_PRICE_MIN, SHIP_SAVE_PRICE_MAX)
+
+/**
+ * Takes payment and writes a captured ship to the player's lowest free slot. Sleeps.
+ * The payment goes back if the write fails. Returns an error for the player, or null on success.
+ */
+/datum/ship_saves/proc/commit_save(ckey, datum/ship_checkpoint/snapshot, mob/user)
+ ckey = ckey(ckey)
+ var/slot = free_slot(ckey)
+ if(!slot)
+ return "All three slots are full."
+ var/tiles = snapshot.owned_tile_count()
+ var/price = price_for(tiles)
+ var/name = snapshot.ship_name
+ var/class = snapshot.class_name
+ var/obj/structure/overmap/ship/ship = snapshot.source_ship?.resolve()
+ var/payload = encode(snapshot)
+ var/list/plan = GLOB.ship_economy_db.spend_any_parts(ckey, price)
+ if(!plan)
+ return "You need [price] ship parts."
+ var/raw_bytes = length(payload)
+ var/list/row = list(
+ "ship_name" = name,
+ "ship_class" = class,
+ "tile_count" = tiles,
+ "format_version" = SHIP_SAVE_FORMAT,
+ "saved_round" = text2num(GLOB.round_id),
+ "raw_bytes" = raw_bytes,
+ )
+ if(!GLOB.ship_save_store.write(ckey, slot, row, payload))
+ GLOB.ship_economy_db.refund_parts(ckey, plan)
+ return "Shipyard records are offline."
+ row["slot"] = slot
+ new_load_ticket(ckey)
+ var/list/rows = cache[ckey] || list()
+ rows += list(row)
+ cache[ckey] = rows
+ log_game("[key_name(user)] saved [name] to ship save slot [slot] for [price] ship parts, [raw_bytes] bytes.")
+ record_metric(METRIC_SHIP, "ship_saved", ckey = ckey, ship = ship, zone = ship_metric_zone(ship), subject = class, quantity = price, details = list("tiles" = tiles, "raw_bytes" = raw_bytes))
+ refresh(ckey)
+ return null
diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm
index c06795ee683..6910a7a02d9 100644
--- a/voidcrew/modules/unit_tests/_unit_tests.dm
+++ b/voidcrew/modules/unit_tests/_unit_tests.dm
@@ -194,6 +194,7 @@
#include "voidcrew_ship_integrity.dm"
#include "voidcrew_ship_modules.dm"
#include "voidcrew_ship_orders.dm"
+#include "voidcrew_ship_saves.dm"
#include "voidcrew_ship_silicons.dm"
#include "voidcrew_ship_turret_targeting.dm"
#include "voidcrew_shop_catalog.dm"
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
new file mode 100644
index 00000000000..9ebbc013960
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -0,0 +1,506 @@
+// Voidcrew defines load after the unit tests, so these mirror them.
+#define SHIP_SAVE_TEST_SLOTS 3 // SHIP_SAVE_SLOTS
+#define SHIP_SAVE_TEST_PRICE_MIN 2 // SHIP_SAVE_PRICE_MIN
+#define SHIP_SAVE_TEST_PRICE_MAX 15 // SHIP_SAVE_PRICE_MAX
+#define SHIP_SAVE_TEST_PASSWORD_PRICE 3 // SHIP_PASSWORD_PRICE
+/// One more than RESERVE_DOCK_MAX_SIZE_LONG.
+#define SHIP_SAVE_TEST_TOO_LONG 57
+
+/// The save table, in memory. Nothing here needs a database.
+/datum/ship_save_store/fake
+ /// "ckey|slot" -> list("meta", "payload")
+ var/list/saved = list()
+ var/fail_writes = FALSE
+
+/datum/ship_save_store/fake/online()
+ return TRUE
+
+/datum/ship_save_store/fake/read_meta(ckey)
+ var/list/rows = list()
+ for(var/slot in 1 to SHIP_SAVE_TEST_SLOTS)
+ var/list/entry = saved["[ckey]|[slot]"]
+ if(entry)
+ var/list/row = entry["meta"]
+ row = row.Copy()
+ row["slot"] = slot
+ rows += list(row)
+ return rows
+
+/datum/ship_save_store/fake/read_payload(ckey, slot)
+ var/list/entry = saved["[ckey]|[slot]"]
+ return entry?["payload"]
+
+/datum/ship_save_store/fake/write(ckey, slot, list/meta, payload)
+ if(fail_writes || saved["[ckey]|[slot]"])
+ return FALSE
+ saved["[ckey]|[slot]"] = list("meta" = meta.Copy(), "payload" = payload)
+ return TRUE
+
+/datum/ship_save_store/fake/remove(ckey, slot)
+ if(!saved["[ckey]|[slot]"])
+ return FALSE
+ saved -= "[ckey]|[slot]"
+ return TRUE
+
+/// Parts in memory. The real spend_parts() is a pair of SQL statements with no database to run on.
+/datum/ship_economy_db/fake
+ var/list/held = list()
+ var/spends = 0
+
+/datum/ship_economy_db/fake/get_parts(ckey)
+ return held.Copy()
+
+/datum/ship_economy_db/fake/spend_parts(ckey, list/requirements)
+ for(var/part_class in requirements)
+ if(held[part_class] < requirements[part_class])
+ return FALSE
+ for(var/part_class in requirements)
+ held[part_class] -= requirements[part_class]
+ spends++
+ return TRUE
+
+/datum/ship_economy_db/fake/add_part(ckey, part_class, quantity = 1, method = "unspecified")
+ held[part_class] += quantity
+ return TRUE
+
+/datum/ship_economy_db/fake/proc/total()
+ . = 0
+ for(var/part_class in held)
+ . += held[part_class]
+
+/// A stale type from an older deploy, as the database would hand it back.
+#define SHIP_SAVE_TEST_GONE_PATH "/datum/outfit/job/this_was_removed_long_ago"
+
+/datum/unit_test/voidcrew_ship_saves
+ var/datum/ship_save_store/old_store
+ var/datum/ship_saves/old_saves
+ var/datum/ship_economy_db/old_economy
+ var/datum/ship_save_store/fake/store
+ var/datum/ship_economy_db/fake/economy
+
+/datum/unit_test/voidcrew_ship_saves/New()
+ . = ..()
+ old_store = GLOB.ship_save_store
+ old_saves = GLOB.ship_saves
+ old_economy = GLOB.ship_economy_db
+ store = new
+ economy = new
+ GLOB.ship_save_store = store
+ GLOB.ship_saves = new /datum/ship_saves
+ GLOB.ship_economy_db = economy
+
+/datum/unit_test/voidcrew_ship_saves/Destroy()
+ GLOB.ship_save_store = old_store
+ GLOB.ship_saves = old_saves
+ GLOB.ship_economy_db = old_economy
+ return ..()
+
+/datum/unit_test/voidcrew_ship_saves/proc/set_parts(combat = 0, science = 0, trade = 0, misc = 0)
+ economy.held = list("combat" = combat, "science" = science, "trade" = trade, "misc" = misc)
+
+/// A snapshot that needs no hull, with one room of the given size.
+/datum/unit_test/voidcrew_ship_saves/proc/make_snapshot(tiles = 10)
+ var/datum/ship_checkpoint/snapshot = allocate(/datum/ship_checkpoint)
+ snapshot.persistent = TRUE
+ snapshot.ship_name = "Test Hull"
+ snapshot.class_name = "Test Class"
+ snapshot.width = 3
+ snapshot.height = 2
+ snapshot.port_x = 1
+ snapshot.port_y = 2
+ var/list/room_tiles = list()
+ for(var/i in 1 to tiles)
+ room_tiles += list(list(1, 1))
+ snapshot.rooms = list(list("name" = "Bridge", "type" = /area/shuttle/voidcrew, "tiles" = room_tiles))
+ snapshot.jobs = list(list("name" = "Captain", "officer" = TRUE, "outfit" = /datum/outfit/job/assistant, "category" = "Assistant", "slots" = 2))
+ snapshot.tgm = "//[DMM2TGM_MESSAGE]\n\"aaa\" = (\n/turf/template_noop,\n/area/template_noop)\n\n(1,1,1) = {\"\naaa\n\"}"
+ return snapshot
+
+/datum/unit_test/voidcrew_ship_saves/Run()
+ check_round_trip()
+ check_stale_types()
+ check_rejected_payloads()
+ check_salvage()
+ check_saved_vars()
+ check_prices()
+ check_part_plans()
+ check_commit()
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_round_trip()
+ var/datum/ship_checkpoint/snapshot = make_snapshot()
+ var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(GLOB.ship_saves.encode(snapshot))
+ TEST_ASSERT_NOTNULL(decoded, "A fresh payload did not decode")
+ TEST_ASSERT(decoded.persistent, "A decoded save is not marked persistent")
+ TEST_ASSERT_EQUAL(decoded.ship_name, snapshot.ship_name, "Decoding lost the ship's name")
+ TEST_ASSERT_EQUAL(decoded.class_name, snapshot.class_name, "Decoding lost the ship's class")
+ TEST_ASSERT_EQUAL(decoded.width, snapshot.width, "Decoding lost the width")
+ TEST_ASSERT_EQUAL(decoded.height, snapshot.height, "Decoding lost the height")
+ TEST_ASSERT_EQUAL(decoded.port_x, snapshot.port_x, "Decoding lost the port's x")
+ TEST_ASSERT_EQUAL(decoded.port_y, snapshot.port_y, "Decoding lost the port's y")
+ TEST_ASSERT_EQUAL(decoded.tgm, snapshot.tgm, "Decoding changed the map text")
+ TEST_ASSERT_EQUAL(decoded.owned_tile_count(), snapshot.owned_tile_count(), "Decoding lost room tiles")
+ var/list/room = decoded.rooms[1]
+ TEST_ASSERT_EQUAL(room["type"], /area/shuttle/voidcrew, "A room's type did not come back as a path")
+ var/list/job = decoded.jobs[1]
+ TEST_ASSERT_EQUAL(job["outfit"], /datum/outfit/job/assistant, "A job's outfit did not come back as a path")
+ TEST_ASSERT_EQUAL(job["slots"], 2, "A job's slots changed")
+ TEST_ASSERT_NULL(ship_job_definition_error(job), "A decoded job is not a valid definition")
+ qdel(decoded)
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_stale_types()
+ var/datum/ship_checkpoint/snapshot = make_snapshot()
+ var/list/room = snapshot.rooms[1]
+ room["type"] = "/area/shuttle/voidcrew/this_was_removed_long_ago"
+ snapshot.jobs += list(list("name" = "Ghost", "outfit" = SHIP_SAVE_TEST_GONE_PATH, "category" = "Assistant", "slots" = 1))
+ var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(GLOB.ship_saves.encode(snapshot))
+ var/list/decoded_room = decoded.rooms[1]
+ TEST_ASSERT_EQUAL(decoded_room["type"], /area/shuttle/voidcrew, "A removed room type did not fall back to a plain ship room")
+ TEST_ASSERT_EQUAL(length(decoded.jobs), 1, "A job with a removed outfit was not dropped")
+ qdel(decoded)
+ snapshot.jobs = list(list("name" = "Ghost", "outfit" = SHIP_SAVE_TEST_GONE_PATH, "category" = "Assistant", "slots" = 1))
+ decoded = GLOB.ship_saves.decode(GLOB.ship_saves.encode(snapshot))
+ TEST_ASSERT_NULL(decoded.jobs, "With no jobs left the commissioned default should apply")
+ qdel(decoded)
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_rejected_payloads()
+ var/datum/ship_checkpoint/snapshot = make_snapshot()
+ TEST_ASSERT_NULL(GLOB.ship_saves.decode("not json"), "Junk text decoded")
+ var/list/data = json_decode(GLOB.ship_saves.encode(snapshot))
+ var/real_format = data["format"]
+ data["format"] = real_format + 1
+ TEST_ASSERT_NULL(GLOB.ship_saves.decode(json_encode(data)), "An unknown format decoded")
+ data["format"] = real_format
+ data["width"] = SHIP_SAVE_TEST_TOO_LONG
+ TEST_ASSERT_NULL(GLOB.ship_saves.decode(json_encode(data)), "An oversized hull decoded")
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_salvage()
+ var/datum/ship_checkpoint/saved = make_snapshot()
+ var/datum/ship_checkpoint/in_round = allocate(/datum/ship_checkpoint)
+
+ // Machines carry nothing per machine, and the in-round kind is untouched.
+ var/obj/machinery/autolathe/lathe = allocate(/obj/machinery/autolathe)
+ TEST_ASSERT(!findtext(saved.atom_text(lathe), "checkpoint_load_id"), "A saved machine stored a load id")
+ TEST_ASSERT_EQUAL(length(saved.machines), 0, "A saved machine stored its parts")
+ TEST_ASSERT(findtext(in_round.atom_text(lathe), "checkpoint_load_id"), "An in-round checkpoint lost its load id")
+
+ // Valuable structures stay behind unless they seal the hull.
+ var/obj/structure/statue/gold/hos/statue = allocate(/obj/structure/statue/gold/hos)
+ TEST_ASSERT(!saved.includes_object(statue), "A gold statue was saved")
+ TEST_ASSERT(in_round.includes_object(statue), "An in-round checkpoint stopped keeping statues")
+ var/obj/structure/window/plasma/window = allocate(/obj/structure/window/plasma)
+ TEST_ASSERT(saved.includes_object(window), "A plasma window was refused: it seals the hull")
+ var/obj/structure/grille/grille = allocate(/obj/structure/grille)
+ TEST_ASSERT(saved.includes_object(grille), "A grille was refused")
+
+ // False walls and mineral doors stay, as plain iron.
+ var/obj/structure/falsewall/gold/fake_wall = allocate(/obj/structure/falsewall/gold)
+ TEST_ASSERT(saved.includes_object(fake_wall), "A gold false wall was refused: it seals the hull")
+ var/wall_text = saved.atom_text(fake_wall)
+ TEST_ASSERT(findtext(wall_text, "/obj/structure/falsewall{"), "A gold false wall was not saved as a plain one: [wall_text]")
+ TEST_ASSERT(!findtext(wall_text, "gold"), "A gold false wall kept its name or look: [wall_text]")
+ var/obj/structure/mineral_door/gold/door = allocate(/obj/structure/mineral_door/gold)
+ TEST_ASSERT(saved.includes_object(door), "A gold door was refused: it seals the hull")
+ var/door_text = saved.atom_text(door)
+ TEST_ASSERT(findtext(door_text, "/obj/structure/mineral_door/iron{"), "A gold door was not saved as an iron one: [door_text]")
+ TEST_ASSERT(!findtext(door_text, "gold"), "A gold door kept its name or look: [door_text]")
+
+ // An emergency closet saves as a plain closet but keeps its look: only false walls and doors go plain.
+ var/obj/structure/closet/emcloset/closet = allocate(/obj/structure/closet/emcloset)
+ TEST_ASSERT(findtext(saved.atom_text(closet), "name = "), "An emergency closet lost its name")
+
+ // A mineral wall and floor keep their look but come apart into iron, even after a shuttle move.
+ var/turf/wall_tile = run_loc_floor_bottom_left
+ var/turf/copy_tile = get_step(wall_tile, EAST)
+ var/wall_tile_type = wall_tile.type
+ var/copy_tile_type = copy_tile.type
+ wall_tile.ChangeTurf(/turf/closed/wall/mineral/diamond)
+ var/turf/closed/wall/mineral/diamond/diamond_wall = wall_tile
+ TEST_ASSERT(findtext(saved.atom_text(diamond_wall), "sheet_type = /obj/item/stack/sheet/iron"), "A diamond wall was saved with its own salvage")
+ TEST_ASSERT(!findtext(in_round.atom_text(diamond_wall), "sheet_type"), "An in-round checkpoint changed a wall's salvage")
+ diamond_wall.sheet_type = /obj/item/stack/sheet/iron
+ diamond_wall.copyTurf(copy_tile)
+ var/turf/closed/wall/moved_wall = copy_tile
+ TEST_ASSERT_EQUAL(moved_wall.type, /turf/closed/wall/mineral/diamond, "A moved wall changed type")
+ TEST_ASSERT_EQUAL(moved_wall.sheet_type, /obj/item/stack/sheet/iron, "A shuttle move gave a saved wall its salvage back")
+ moved_wall.break_wall()
+ TEST_ASSERT_NOTNULL(locate(/obj/item/stack/sheet/iron) in moved_wall, "A saved wall dropped no iron")
+ TEST_ASSERT_NULL(locate(/obj/item/stack/sheet/mineral/diamond) in moved_wall, "A saved wall dropped diamond")
+ for(var/obj/thing in moved_wall)
+ qdel(thing)
+
+ wall_tile.ChangeTurf(/turf/open/floor/mineral/gold)
+ var/turf/open/floor/mineral/gold/gold_floor = wall_tile
+ TEST_ASSERT(findtext(saved.atom_text(gold_floor), "floor_tile = /obj/item/stack/tile/iron"), "A gold floor was saved with its own tile")
+ TEST_ASSERT(!findtext(in_round.atom_text(gold_floor), "floor_tile"), "An in-round checkpoint changed a floor's tile")
+ gold_floor.floor_tile = /obj/item/stack/tile/iron
+ gold_floor.copyTurf(copy_tile)
+ var/turf/open/floor/moved_floor = copy_tile
+ TEST_ASSERT_EQUAL(moved_floor.floor_tile, /obj/item/stack/tile/iron, "A shuttle move gave a saved floor its tile back")
+ wall_tile.ChangeTurf(wall_tile_type)
+ copy_tile.ChangeTurf(copy_tile_type)
+
+/// Loading a map with a var its type doesn't have throws, so each one a save writes must exist.
+/datum/unit_test/voidcrew_ship_saves/proc/check_saved_vars()
+ var/list/saved_vars = list(
+ /obj/machinery/power/smes = list("dir", "color", "pixel_x", "pixel_y", "name", "icon_state", "anchored", "req_access", "id_tag", "input_level", "output_level", "cable_layer"),
+ /obj/machinery/light = list("start_with_cell"),
+ /obj/machinery/atmospherics/pipe/smart = list("piping_layer", "pipe_color"),
+ /obj/machinery/atmospherics/components/trinary/filter = list("flipped"),
+ /obj/machinery/atmospherics/components/tank = list("open_ports"),
+ /obj/machinery/duct = list("duct_layer", "duct_color", "connects"),
+ /obj/structure/closet/crate = list("lid_icon", "lid_icon_state", "icon", "icon_door", "base_icon_state", "enable_door_overlay", "has_opened_overlay", "has_closed_overlay", "wall_mounted", "horizontal", "locked", "req_one_access", "contents_initialized"),
+ )
+ for(var/thing_type in saved_vars)
+ var/datum/thing = allocate(thing_type)
+ for(var/var_name in saved_vars[thing_type])
+ TEST_ASSERT(var_name in thing.vars, "A saved hull writes [var_name], which [thing_type] does not have")
+ // Allocating a mobile port would register it with the shuttle subsystem, so check it by compiling.
+ var/obj/docking_port/mobile/voidcrew/port_vars = /obj/docking_port/mobile/voidcrew
+ TEST_ASSERT(isnull(initial(port_vars.area_type)) || ispath(initial(port_vars.area_type)), "A port's area_type has moved")
+ TEST_ASSERT(isnum(initial(port_vars.preferred_direction)) || isnull(initial(port_vars.preferred_direction)), "A port's preferred_direction has moved")
+ TEST_ASSERT(isnum(initial(port_vars.port_direction)) || isnull(initial(port_vars.port_direction)), "A port's port_direction has moved")
+ var/turf/closed/wall/wall_vars = /turf/closed/wall
+ TEST_ASSERT_EQUAL(initial(wall_vars.sheet_type), /obj/item/stack/sheet/iron, "A wall's sheet_type has moved")
+ var/turf/open/floor/mineral/gold/floor_vars = /turf/open/floor/mineral/gold
+ TEST_ASSERT_NOTNULL(initial(floor_vars.floor_tile), "A floor's floor_tile has moved")
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_prices()
+ var/datum/ship_saves/saves = GLOB.ship_saves
+ TEST_ASSERT_EQUAL(saves.price_for(275), 2, "Wrong price for a Goon-size hull")
+ TEST_ASSERT_EQUAL(saves.price_for(340), 3, "Wrong price for a Kilo-size hull")
+ TEST_ASSERT_EQUAL(saves.price_for(500), 6, "Wrong price for a Delta-size hull")
+ TEST_ASSERT_EQUAL(saves.price_for(630), 9, "Wrong price for a Scarab-size hull")
+ TEST_ASSERT_EQUAL(saves.price_for(850), 15, "Wrong price for a Phalanx-size hull")
+ TEST_ASSERT_EQUAL(saves.price_for(1), SHIP_SAVE_TEST_PRICE_MIN, "The smallest hull went under the lowest price")
+ TEST_ASSERT_EQUAL(saves.price_for(3000), SHIP_SAVE_TEST_PRICE_MAX, "The biggest hull went over the highest price")
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_part_plans()
+ set_parts(combat = 2, science = 9, trade = 4)
+ var/list/plan = economy.spend_any_parts("savetester", 12)
+ TEST_ASSERT_NOTNULL(plan, "12 parts could not be paid from 15")
+ TEST_ASSERT_EQUAL(plan["science"], 9, "The largest stack was not taken first")
+ TEST_ASSERT_EQUAL(plan["trade"], 3, "The next largest stack was not taken second")
+ TEST_ASSERT(!("combat" in plan), "A stack the price didn't reach was charged")
+ TEST_ASSERT_EQUAL(economy.total(), 3, "The plan and the charge disagree")
+ economy.refund_parts("savetester", plan)
+ TEST_ASSERT_EQUAL(economy.total(), 15, "A refund did not put the parts back")
+ TEST_ASSERT_NULL(economy.spend_any_parts("savetester", 16), "A price over everything held was paid")
+ TEST_ASSERT_EQUAL(economy.total(), 15, "A refused price still took parts")
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_commit()
+ var/player_ckey = "savetester"
+ var/datum/ship_saves/saves = GLOB.ship_saves
+ saves.refresh(player_ckey)
+ TEST_ASSERT_EQUAL(length(saves.saves_for(player_ckey)), 0, "A new player was not loaded with no saves")
+ TEST_ASSERT_EQUAL(saves.free_slot(player_ckey), 1, "The first free slot is not 1")
+
+ // Not enough parts: nothing is written.
+ set_parts()
+ TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A save with no parts went through")
+ TEST_ASSERT_EQUAL(length(store.saved), 0, "A save nobody paid for reached the table")
+
+ // A failed write takes nothing.
+ set_parts(combat = 20)
+ store.fail_writes = TRUE
+ TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A failed write reported success")
+ TEST_ASSERT_EQUAL(economy.total(), 20, "A failed write kept the payment")
+ TEST_ASSERT_EQUAL(length(saves.saves_for(player_ckey)), 0, "A failed write reached the cache")
+ store.fail_writes = FALSE
+
+ // A failed read is not "no saves".
+ var/list/loaded = saves.saves_for(player_ckey)
+ TEST_ASSERT_NOTNULL(loaded, "The cache was lost")
+ TEST_ASSERT_NULL(saves.saves_for("nobodyatall"), "An unloaded player reads as having no saves")
+ TEST_ASSERT_NULL(saves.free_slot("nobodyatall"), "An unloaded player has a free slot")
+
+ // Three saves fill the slots in order, charging each one.
+ for(var/slot in 1 to SHIP_SAVE_TEST_SLOTS)
+ TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(275), null), "Save [slot] failed")
+ TEST_ASSERT_NOTNULL(store.saved["[player_ckey]|[slot]"], "Save [slot] went to the wrong slot")
+ TEST_ASSERT_EQUAL(economy.total(), 20 - 2 * slot, "Save [slot] charged the wrong amount")
+ var/list/entry = store.saved["[player_ckey]|1"]
+ var/list/meta = entry["meta"]
+ TEST_ASSERT_EQUAL(meta["tile_count"], 275, "The row has the wrong tile count")
+ TEST_ASSERT_EQUAL(meta["ship_name"], "Test Hull", "The row has the wrong name")
+ var/list/stored = json_decode(entry["payload"])
+ TEST_ASSERT_EQUAL(meta["format_version"], stored["format"], "The row has the wrong format")
+ TEST_ASSERT_EQUAL(meta["raw_bytes"], length(entry["payload"]), "The row has the wrong size")
+ TEST_ASSERT_NULL(saves.free_slot(player_ckey), "Three saves left a slot free")
+
+ // Slots full: refused before any charge.
+ var/parts_before = economy.total()
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null), "All three slots are full.", "A fourth save was not refused")
+ TEST_ASSERT_EQUAL(economy.total(), parts_before, "A refused save was charged")
+
+ // A paid save is never overwritten: a write to a taken slot fails, and refunds.
+ saves.cache[player_ckey] = list()
+ TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A taken slot was overwritten")
+ TEST_ASSERT_EQUAL(economy.total(), parts_before, "A duplicate key kept the payment")
+
+/// The panel and the password, on a real hull in a real bay.
+/datum/unit_test/voidcrew_ship_saves_hull
+ parent_type = /datum/unit_test/voidcrew_outpost_management
+ var/datum/ship_save_store/old_store
+ var/datum/ship_saves/old_saves
+ var/datum/ship_economy_db/old_economy
+ var/datum/ship_save_store/fake/store
+ var/datum/ship_economy_db/fake/economy
+ var/obj/structure/overmap/ship/test_ship
+
+/datum/unit_test/voidcrew_ship_saves_hull/New()
+ . = ..()
+ old_store = GLOB.ship_save_store
+ old_saves = GLOB.ship_saves
+ old_economy = GLOB.ship_economy_db
+ store = new
+ economy = new
+ GLOB.ship_save_store = store
+ GLOB.ship_saves = new /datum/ship_saves
+ GLOB.ship_economy_db = economy
+
+/datum/unit_test/voidcrew_ship_saves_hull/Destroy()
+ if(!QDELETED(test_ship))
+ qdel(test_ship)
+ GLOB.ship_save_store = old_store
+ GLOB.ship_saves = old_saves
+ GLOB.ship_economy_db = old_economy
+ return ..()
+
+/datum/unit_test/voidcrew_ship_saves_hull/Run()
+ var/obj/structure/overmap/dynamic/player_outpost/registry_test/home = allocate(__IMPLIED_TYPE__)
+ home.shell_template = allocate(/datum/map_template/player_outpost/test_fixture)
+ home.founder_ckey = "savecaptain"
+ TEST_ASSERT(home.load_level(), "The outpost did not load")
+ TEST_ASSERT_NULL(home.enable_ship_bays(), "The ship bay did not load")
+ var/obj/machinery/computer/ship_checkpoint/terminal = outpost_bay_shipyard_console(LAZYACCESS(home.bay_berths, 1))
+ TEST_ASSERT_NOTNULL(terminal, "The ship bay has no shipyard console")
+ var/turf/terminal_turf = get_turf(terminal)
+ var/mob/living/carbon/human/captain = make_player(terminal_turf, "savecaptain")
+ var/mob/living/carbon/human/deckhand = make_player(terminal_turf, "savedeckhand")
+ test_ship = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
+ TEST_ASSERT_NOTNULL(test_ship, "Could not create the hull")
+ test_ship.enlist_crewmember(captain)
+ test_ship.claimed_captain = captain.mind
+ test_ship.enlist_crewmember(deckhand)
+ var/datum/ship_checkpoint_ui/registry_test/panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
+ var/datum/ship_checkpoint_ui/registry_test/deckhand_panel = allocate(__IMPLIED_TYPE__, home, terminal, deckhand)
+ TEST_ASSERT_NOTNULL(GLOB.ship_saves.saves_for(captain.ckey), "Opening the panel did not load the captain's saves")
+
+ // Not in the bay, whatever else is true. A berth is not in bay_berths, so it arrives as null.
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, null), "Dock your ship in a ship bay first.", "A berthed ship could be saved")
+ var/datum/outpost_berth/ship_bay/bay = home.allocate_ship_bay(test_ship)
+ TEST_ASSERT_NOTNULL(bay, "The hull was not given a bay")
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "Dock your ship in a ship bay first.", "A ship that hasn't landed could be saved")
+ adjust_reserve_dock_to_shuttle(bay.dock, test_ship.shuttle)
+ test_ship.shuttle.mode = SHUTTLE_PREARRIVAL
+ test_ship.shuttle.initiate_docking(bay.dock)
+ test_ship.shuttle.mode = SHUTTLE_IDLE
+ test_ship.docked = home
+ test_ship.forceMove(home)
+ test_ship.state = "idle"
+ bay.on_ship_docked(test_ship)
+ TEST_ASSERT(bay.is_ship_present(), "The hull did not land in the bay")
+
+ TEST_ASSERT_NULL(panel.saved_ship_denial(captain, bay), "The captain could not save a docked ship")
+ TEST_ASSERT_EQUAL(deckhand_panel.saved_ship_denial(deckhand, bay), "Only the ship's current captain can save it.", "A deckhand could save the ship")
+
+ // No cryopods, no way to spawn on it later.
+ var/list/spawn_points = test_ship.shuttle.spawn_points
+ test_ship.shuttle.spawn_points = list()
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "This hull has no cryopods.", "A hull without cryopods could be saved")
+ test_ship.shuttle.spawn_points = spawn_points
+
+ // Declining costs nothing; accepting charges once and saves the real hull.
+ set_parts(40)
+ panel.accept_save = FALSE
+ TEST_ASSERT(!panel.save_ship(captain, bay), "Declining still saved the ship")
+ TEST_ASSERT_EQUAL(length(store.saved), 0, "Declining reached the table")
+ TEST_ASSERT_EQUAL(parts_held(), 40, "Declining was charged")
+ panel.accept_save = TRUE
+ TEST_ASSERT(panel.save_ship(captain, bay), "Saving failed: [panel.error]")
+ TEST_ASSERT_NULL(panel.error, "A successful save left an error")
+ TEST_ASSERT_EQUAL(panel.notice, "[test_ship.name] saved.", "A successful save said nothing")
+ TEST_ASSERT(!panel.working, "The panel stayed busy after saving")
+ var/list/entry = store.saved["savecaptain|1"]
+ TEST_ASSERT_NOTNULL(entry, "The save did not reach slot 1")
+
+ // What was stored is a usable copy with nothing per machine in it.
+ var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(entry["payload"])
+ TEST_ASSERT_NOTNULL(decoded, "The stored hull did not decode")
+ TEST_ASSERT_EQUAL(parts_held(), 40 - GLOB.ship_saves.price_for(decoded.owned_tile_count()), "The hull was charged the wrong price")
+ TEST_ASSERT_EQUAL(hull_owned_tile_count(test_ship.shuttle), decoded.owned_tile_count(), "The price on the screen would differ from the price charged")
+ TEST_ASSERT(!findtext(decoded.tgm, "checkpoint_load_id"), "A stored hull has a machine load id")
+ TEST_ASSERT_EQUAL(length(decoded.machines), 0, "A stored hull has per-machine data")
+ TEST_ASSERT_EQUAL(decoded.class_name, test_ship.source_template.short_name, "The stored hull lost its class")
+ TEST_ASSERT_EQUAL(length(decoded.jobs), length(test_ship.job_definitions), "The stored hull lost its roster")
+ var/datum/parsed_map/parsed = new(decoded.tgm)
+ TEST_ASSERT_EQUAL(parsed.bounds?[MAP_MAXX], decoded.width, "The stored map does not read back")
+ qdel(parsed)
+ var/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/template = new(decoded)
+ TEST_ASSERT_EQUAL(template.short_name, test_ship.source_template.short_name, "The rebuilt template lost its class")
+ TEST_ASSERT_EQUAL(length(template.job_slots), length(decoded.jobs), "The rebuilt template lost its roster")
+ qdel(template)
+ qdel(decoded)
+
+ // Slots full, and records offline, are refused by name.
+ var/list/rows = GLOB.ship_saves.saves_for(captain.ckey)
+ for(var/slot in 2 to SHIP_SAVE_TEST_SLOTS)
+ rows += list(list("slot" = slot, "ship_name" = "Filler", "ship_class" = null))
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "All three slots are full.", "Full slots were not refused")
+ GLOB.ship_saves.cache -= captain.ckey
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "Shipyard records are offline.", "An unloaded player read as having slots")
+
+ // The panel's data never needs the database, and matches what the screen is promised.
+ GLOB.ship_saves.refresh(captain.ckey)
+ var/list/saved_ships = panel.saved_ships_data(captain)
+ TEST_ASSERT_EQUAL(saved_ships["slots"], SHIP_SAVE_TEST_SLOTS, "The panel has the wrong slot count")
+ TEST_ASSERT_EQUAL(length(saved_ships["saves"]), 1, "The panel did not list the save")
+ var/list/listed_ships = saved_ships["ships"]
+ TEST_ASSERT_EQUAL(length(listed_ships), 1, "The panel did not list the docked ship")
+ var/list/deckhand_view = panel.saved_ships_data(deckhand)
+ TEST_ASSERT_EQUAL(length(deckhand_view["ships"]), 0, "The panel offered a deckhand the ship")
+
+ check_password(captain, deckhand)
+
+/datum/unit_test/voidcrew_ship_saves_hull/proc/set_parts(misc)
+ economy.held = list("combat" = 0, "science" = 0, "trade" = 0, "misc" = misc)
+
+/datum/unit_test/voidcrew_ship_saves_hull/proc/parts_held()
+ return economy.total()
+
+/datum/unit_test/voidcrew_ship_saves_hull/proc/check_password(mob/living/carbon/human/captain, mob/living/carbon/human/deckhand)
+ // Fleet hulls stay public; the test hull must not be one, or nothing here is charged.
+ TEST_ASSERT(test_ship.can_have_join_password(), "The test hull cannot carry a password")
+ set_parts(10)
+ test_ship.join_password = null
+ TEST_ASSERT(test_ship.buy_join_password("hunter", captain), "A paid password was refused")
+ TEST_ASSERT_EQUAL(test_ship.join_password, "hunter", "The password was not set")
+ TEST_ASSERT_EQUAL(parts_held(), 10 - SHIP_SAVE_TEST_PASSWORD_PRICE, "A new password was not charged once")
+ TEST_ASSERT(test_ship.buy_join_password(" HUNTER ", captain), "Entering the same password failed")
+ TEST_ASSERT_EQUAL(parts_held(), 10 - SHIP_SAVE_TEST_PASSWORD_PRICE, "An unchanged password was charged")
+ TEST_ASSERT(test_ship.buy_join_password("", captain), "Clearing the password failed")
+ TEST_ASSERT_NULL(test_ship.join_password, "The password was not cleared")
+ TEST_ASSERT_EQUAL(parts_held(), 10 - SHIP_SAVE_TEST_PASSWORD_PRICE, "Clearing the password was charged")
+
+ // Can't pay: nothing changes.
+ set_parts(SHIP_SAVE_TEST_PASSWORD_PRICE - 1)
+ TEST_ASSERT(!test_ship.buy_join_password("hunter", captain), "A password nobody could pay for was set")
+ TEST_ASSERT_NULL(test_ship.join_password, "An unpaid password took effect")
+ TEST_ASSERT_EQUAL(parts_held(), SHIP_SAVE_TEST_PASSWORD_PRICE - 1, "A failed payment took parts")
+
+ // Not the captain by the time the payment clears: the parts go back.
+ set_parts(10)
+ TEST_ASSERT(!test_ship.buy_join_password("hunter", deckhand), "A deckhand set a password")
+ TEST_ASSERT_NULL(test_ship.join_password, "A deckhand's password took effect")
+ TEST_ASSERT_EQUAL(parts_held(), 10, "The refund for a lost captaincy did not happen")
+
+ // set_join_password itself stays free.
+ TEST_ASSERT(test_ship.set_join_password("direct", captain), "The free setter was refused")
+ TEST_ASSERT_EQUAL(parts_held(), 10, "The free setter charged")
+
+#undef SHIP_SAVE_TEST_GONE_PATH
+#undef SHIP_SAVE_TEST_SLOTS
+#undef SHIP_SAVE_TEST_PRICE_MIN
+#undef SHIP_SAVE_TEST_PRICE_MAX
+#undef SHIP_SAVE_TEST_PASSWORD_PRICE
+#undef SHIP_SAVE_TEST_TOO_LONG
From a443c9f3f9ba067d180b6d9fb3da5c8ca02b5efd Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:16:48 -0800
Subject: [PATCH 02/72] Only leave precious fittings out of a saved ship
---
.../modules/ship_saves/ship_save_capture.dm | 29 +++++++++++++++----
1 file changed, 24 insertions(+), 5 deletions(-)
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index 376004e28fa..73bc4b7038a 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -1,19 +1,38 @@
-/// Hull pieces a saved ship always keeps, whatever they are made of. Windows and grilles stay as
-/// they are; false walls and mineral doors are saved as their plain iron versions.
-GLOBAL_LIST_INIT(ship_save_sealing_structures, typecacheof(list(
+/// Fittings a saved ship always keeps, whatever they are made of. Windows and grilles seal the hull; false
+/// walls and mineral doors are saved as their plain iron versions; a med bay needs its operating table.
+GLOBAL_LIST_INIT(ship_save_kept_structures, typecacheof(list(
/obj/structure/window,
/obj/structure/grille,
/obj/structure/falsewall,
/obj/structure/mineral_door,
+ /obj/structure/table/optable,
+)))
+
+/// Materials worth carrying into the next round by breaking a saved fitting down. Everything a ship is ordinarily
+/// furnished in stays out of this list: iron, glass, wood, plastic, titanium and the alloys.
+GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
+ /datum/material/silver,
+ /datum/material/gold,
+ /datum/material/diamond,
+ /datum/material/uranium,
+ /datum/material/plasma,
+ /datum/material/bluespace,
+ /datum/material/bananium,
+ /datum/material/runite,
+ /datum/material/adamantine,
+ /datum/material/mythril,
+ /datum/material/hot_ice,
+ /datum/material/metalhydrogen,
+ /datum/material/zaukerite,
)))
/// Build a statue of gold and save it for two parts, and you could break it down next round.
/proc/ship_save_refuses_object(obj/object)
- if(!isstructure(object) || is_type_in_typecache(object, GLOB.ship_save_sealing_structures))
+ if(!isstructure(object) || is_type_in_typecache(object, GLOB.ship_save_kept_structures))
return FALSE
for(var/material_key in object.custom_materials)
var/datum/material/material = GET_MATERIAL_REF(material_key)
- if(!istype(material, /datum/material/iron) && !istype(material, /datum/material/glass))
+ if(is_type_in_typecache(material, GLOB.ship_save_precious_materials))
return TRUE
return FALSE
From f8fe948d859e70132637a3ed05f103b0a86afeec Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:39:17 -0800
Subject: [PATCH 03/72] Retrieve saved ships from the lobby and from a ship bay
A save is used once and costs nothing to retrieve. The lobby loads the
ship whole and seats the player; a shipyard console has drones rebuild it
in the outpost's bay. A lock covers both paths, the console spends the
save at the first piece and the lobby once the ship has loaded.
The stock scrub and machine restore are now global procs so the lobby can
use them. Saved ships come back at default tier with no plasma, no credits,
vendors and console matter zeroed, and a default APC cell.
---
tgstation.dme | 2 +
voidcrew/edits/mobs/ship_join_menu.dm | 11 +
.../code/controllers/subsystem/shuttle.dm | 5 +
.../outpost_checkpoint_machinery.dm | 77 ++--
.../outpost_checkpoint_rebuild.dm | 8 +-
.../player_outposts/outpost_checkpoints.dm | 2 +
.../modules/ship_saves/ship_save_capture.dm | 8 +
.../modules/ship_saves/ship_save_console.dm | 62 +++-
.../ship_saves/ship_save_construction.dm | 46 +++
.../modules/ship_saves/ship_save_lobby.dm | 91 +++++
voidcrew/modules/ship_saves/ship_saves.dm | 92 +++++
.../modules/unit_tests/voidcrew_ship_saves.dm | 330 +++++++++++++++++-
12 files changed, 702 insertions(+), 32 deletions(-)
create mode 100644 voidcrew/modules/ship_saves/ship_save_construction.dm
create mode 100644 voidcrew/modules/ship_saves/ship_save_lobby.dm
diff --git a/tgstation.dme b/tgstation.dme
index 49bd4fdfa48..188e8d7b9a1 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -7582,6 +7582,8 @@
#include "voidcrew\modules\ship_purchase\transaction_helpers.dm"
#include "voidcrew\modules\ship_saves\ship_save_capture.dm"
#include "voidcrew\modules\ship_saves\ship_save_console.dm"
+#include "voidcrew\modules\ship_saves\ship_save_construction.dm"
+#include "voidcrew\modules\ship_saves\ship_save_lobby.dm"
#include "voidcrew\modules\ship_saves\ship_save_store.dm"
#include "voidcrew\modules\ship_saves\ship_saves.dm"
#include "voidcrew\modules\ship_upgrades\_ship_upgrades.dm"
diff --git a/voidcrew/edits/mobs/ship_join_menu.dm b/voidcrew/edits/mobs/ship_join_menu.dm
index 2b3b1eddbce..212e316befc 100644
--- a/voidcrew/edits/mobs/ship_join_menu.dm
+++ b/voidcrew/edits/mobs/ship_join_menu.dm
@@ -133,6 +133,10 @@
data["outposts"] = homes
data["ships"] = ships
data["can_requisition"] = can_requisition_hull(user)
+ var/list/saved_ships = list()
+ for(var/list/row as anything in GLOB.ship_saves.saves_for(user.ckey))
+ saved_ships += list(list("slot" = row["slot"], "name" = row["ship_name"], "class" = row["ship_class"], "denial" = GLOB.ship_saves.retrieve_denial(user.ckey, row["slot"])))
+ data["saved_ships"] = saved_ships
return data
/// Both the requester and the receiving ship must be ready for another ping.
@@ -214,6 +218,13 @@
var/datum/ship_upgrade_selector/selector = new(user, null, cb)
selector.ui_interact(user)
+ if("launch_saved_ship")
+ if(user != ui.user)
+ return FALSE
+ // Reading the save sleeps, and so does the shipyard's queue.
+ ui.close()
+ INVOKE_ASYNC(user, TYPE_PROC_REF(/mob/dead/new_player, launch_saved_ship), text2num(params["slot"]))
+
if("requisition_hull")
// Re-checked in requisition_free_hull() too - the UI is never the authority
// on this, and the fleet can fill up while the menu sits open
diff --git a/voidcrew/modules/overmap/code/controllers/subsystem/shuttle.dm b/voidcrew/modules/overmap/code/controllers/subsystem/shuttle.dm
index 6fdfbf489b1..667e25ee1e6 100644
--- a/voidcrew/modules/overmap/code/controllers/subsystem/shuttle.dm
+++ b/voidcrew/modules/overmap/code/controllers/subsystem/shuttle.dm
@@ -128,6 +128,9 @@
worldgen_end(probe, "spawn-failed")
return FALSE
+ if(template_instance.no_starting_funds)
+ ship_to_spawn.starting_credits = 0
+
// Store upgrade selections and theme BEFORE setup_from_template (module job_slots_add
// reads them) and BEFORE the map loads (modular_map_root reads them)
if(length(upgrade_selections))
@@ -147,6 +150,8 @@
SSair.can_fire = FALSE
var/obj/docking_port/mobile/voidcrew/loaded = action_load(ship_to_spawn.source_template, load_owner = load_owner)
+ if(loaded)
+ template_instance.finish_loaded_copy(loaded)
SSair.can_fire = load_owner.previous_air_can_fire
// Clear loading_ship now that map is loaded
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
index 22e35143522..5d6c8ecd7da 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
@@ -58,37 +58,56 @@
/// Restore parts before RefreshParts calculates capacity, speed and efficiency.
/datum/checkpoint_construction/proc/restore_machine(obj/machinery/machine, mob/living/operator)
+ if(snapshot.persistent)
+ checkpoint_restore_machine(machine, null, operator)
+ return
if(!machine.checkpoint_load_id || machine.checkpoint_load_id > length(snapshot.machines))
return
var/list/data = snapshot.machines[machine.checkpoint_load_id]
machine.checkpoint_load_id = 0
+ checkpoint_restore_machine(machine, data, operator)
+
+/// Empties a construction part of whatever it came with and fills its battery.
+/proc/checkpoint_scrub_part(obj/item/part)
+ part.reagents?.clear_reagents()
+ if(istype(part, /obj/item/tank))
+ var/obj/item/tank/tank = part
+ tank.air_contents.gases.Cut()
+ if(istype(part, /obj/item/stock_parts/power_store))
+ var/obj/item/stock_parts/power_store/battery = part
+ battery.charge = battery.maxcharge
+ if(istype(part, /obj/item/vending_refill))
+ var/obj/item/vending_refill/refill = part
+ refill.products = list()
+ refill.product_categories = list()
+ refill.contraband = list()
+ refill.premium = list()
+
+/**
+ * Puts a loaded machine's parts and supplies right. `data` is what capture_machine() kept for it.
+ * A saved ship keeps nothing per machine, so its machines stay at the default tier they initialized with.
+ */
+/proc/checkpoint_restore_machine(obj/machinery/machine, list/data, mob/living/operator)
if(machine.circuit)
// Retain the newly initialized circuit and its machine-specific configuration.
var/obj/item/circuitboard/board = machine.circuit
- var/list/old_parts = machine.component_parts?.Copy() || list()
- machine.component_parts = list(board)
- for(var/obj/item/part in old_parts)
- if(part != board)
- qdel(part)
- for(var/part_type in data["parts"])
- if(ispath(part_type, /datum/stock_part))
- machine.component_parts += GLOB.stock_part_datums[part_type]
- else
- var/obj/item/part = new part_type(machine)
- machine.component_parts += part
- part.reagents?.clear_reagents()
- if(istype(part, /obj/item/tank))
- var/obj/item/tank/tank = part
- tank.air_contents.gases.Cut()
- if(istype(part, /obj/item/stock_parts/power_store))
- var/obj/item/stock_parts/power_store/battery = part
- battery.charge = battery.maxcharge
- if(istype(part, /obj/item/vending_refill))
- var/obj/item/vending_refill/refill = part
- refill.products = list()
- refill.product_categories = list()
- refill.contraband = list()
- refill.premium = list()
+ if(data)
+ var/list/old_parts = machine.component_parts?.Copy() || list()
+ machine.component_parts = list(board)
+ for(var/obj/item/part in old_parts)
+ if(part != board)
+ qdel(part)
+ for(var/part_type in data["parts"])
+ if(ispath(part_type, /datum/stock_part))
+ machine.component_parts += GLOB.stock_part_datums[part_type]
+ else
+ var/obj/item/part = new part_type(machine)
+ machine.component_parts += part
+ checkpoint_scrub_part(part)
+ else
+ for(var/obj/item/part in machine.component_parts)
+ if(part != board)
+ checkpoint_scrub_part(part)
machine.RefreshParts()
if(istype(machine, /obj/machinery/vending))
var/obj/machinery/vending/vendor = machine
@@ -97,13 +116,13 @@
if(istype(machine, /obj/machinery/power/apc))
var/obj/machinery/power/apc/controller = machine
QDEL_NULL(controller.cell)
- var/cell_type = data["cell"]
+ var/cell_type = data?["cell"] || controller.cell_type || /obj/item/stock_parts/power_store/battery/upgraded
controller.cell = new cell_type(controller)
controller.cell.charge = controller.cell.maxcharge
controller.update()
if(istype(machine, /obj/machinery/computer/camera_advanced/base_construction/ship))
var/obj/machinery/computer/camera_advanced/base_construction/ship/builder = machine
- for(var/upgrade_type in data["construction_upgrades"])
+ for(var/upgrade_type in data?["construction_upgrades"])
if(!ispath(upgrade_type, /obj/item/ship_construction_upgrade) && !ispath(upgrade_type, /obj/item/rcd_upgrade) && !ispath(upgrade_type, /obj/item/rpd_upgrade))
continue
var/obj/item/disk = new upgrade_type(builder)
@@ -121,7 +140,7 @@
/// Construction components and built-in radios are fittings, not cargo. Their
/// contents are still visited separately, so a component cannot smuggle stock.
-/datum/checkpoint_construction/proc/is_machine_fitting(obj/item/item)
+/proc/checkpoint_is_machine_fitting(obj/item/item)
if(ismachinery(item.loc))
var/obj/machinery/machine = item.loc
if(istype(machine, /obj/machinery/computer/camera_advanced/base_construction/ship))
@@ -133,6 +152,10 @@
/// Top off as each machine is placed; the hidden copy may have drawn power while it waited.
/datum/checkpoint_construction/proc/provision_machine(obj/machinery/machine)
+ checkpoint_provision_machine(machine)
+
+/// Charged batteries and engine fuel, whatever the machine started with.
+/proc/checkpoint_provision_machine(obj/machinery/machine)
for(var/obj/item/stock_parts/power_store/battery in machine.component_parts)
battery.charge = battery.maxcharge
if(istype(machine, /obj/machinery/power/apc))
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
index 8fc65d60759..acfa01212f0 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
@@ -62,16 +62,20 @@ GLOBAL_LIST_INIT(outpost_checkpoint_restocked, typecacheof(list(
)))
/datum/checkpoint_construction/proc/clear_stock(obj/docking_port/mobile/voidcrew/source_port)
+ checkpoint_clear_stock(source_port, snapshot?.persistent ? ship_save_restocked() : GLOB.outpost_checkpoint_restocked)
+
+/// Empties a loaded copy of everything but its fittings. Types in `restocked_types` keep what they loaded with.
+/proc/checkpoint_clear_stock(obj/docking_port/mobile/voidcrew/source_port, list/restocked_types)
for(var/turf/tile as anything in source_port.return_turfs())
if(!(get_area(tile) in source_port.shuttle_areas))
continue
for(var/atom/movable/object as anything in tile.get_all_contents())
if(QDELETED(object))
continue
- var/restocked = is_type_in_typecache(object, GLOB.outpost_checkpoint_restocked)
+ var/restocked = is_type_in_typecache(object, restocked_types)
if(!restocked)
object.reagents?.clear_reagents()
- if(isitem(object) && is_machine_fitting(object))
+ if(isitem(object) && checkpoint_is_machine_fitting(object))
continue
if(isitem(object) || ismob(object) || istype(object, /obj/structure/disposalholder))
qdel(object)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoints.dm b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
index d7fb5eb5d50..5b3d99c0605 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoints.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
@@ -287,6 +287,8 @@
if("ship_save_store")
var/datum/outpost_berth/ship_bay/bay = locate(params["ref"]) in outpost.bay_berths
save_ship(user, bay)
+ if("ship_save_retrieve")
+ retrieve_ship(user, text2num(params["slot"]))
else
shop_act(user, action, params)
return TRUE
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index 73bc4b7038a..d976eacfed9 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -36,6 +36,14 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
return TRUE
return FALSE
+/// What a saved ship keeps as it loads: a rebuild's basics without plasma tanks, or a hull full of
+/// them would be gas to sell next round. Its engines run on the heater reserve instead.
+/proc/ship_save_restocked()
+ var/static/list/restocked
+ if(!restocked)
+ restocked = GLOB.outpost_checkpoint_restocked - typecacheof(/obj/machinery/atmospherics/components/tank/plasma)
+ return restocked
+
/// Text macros are raw bytes the database connection would mangle. A saved name goes without them.
/proc/ship_save_plain_text(text)
return replacetext(replacetext(text, "\improper", ""), "\proper", "")
diff --git a/voidcrew/modules/ship_saves/ship_save_console.dm b/voidcrew/modules/ship_saves/ship_save_console.dm
index 3fbc51c79c7..5a413a4492f 100644
--- a/voidcrew/modules/ship_saves/ship_save_console.dm
+++ b/voidcrew/modules/ship_saves/ship_save_console.dm
@@ -60,11 +60,71 @@
notice = "[ship_name] saved."
return TRUE
+/// Why this save can't be built in this outpost's bay, or null. Only checks caches, so ui_data can use it.
+/datum/ship_checkpoint_ui/proc/saved_ship_retrieve_denial(mob/living/user, slot, allow_busy = FALSE)
+ if(QDELETED(src) || QDELETED(outpost) || ui_status(user, GLOB.always_state) != UI_INTERACTIVE)
+ return "Shipyard console unavailable."
+ var/denial = GLOB.ship_saves.retrieve_denial(user.ckey, slot, allow_busy)
+ if(denial)
+ return denial
+ if(!outpost.available_ship_bay())
+ return "The ship bay is occupied."
+ return null
+
+/datum/ship_checkpoint_ui/proc/create_retrieval_job(mob/living/user, datum/ship_checkpoint/snapshot, slot)
+ return new /datum/checkpoint_construction/saved(src, snapshot, user, slot)
+
+/// Reads a saved ship back and starts the drones on it. The save is spent at the first piece.
+/// Sleeps at the read and in the loader's queue, so everything is checked again afterwards.
+/datum/ship_checkpoint_ui/proc/retrieve_ship(mob/living/user, slot)
+ if(working)
+ return FALSE
+ error = saved_ship_retrieve_denial(user, slot)
+ if(error)
+ return FALSE
+ var/player_ckey = user.ckey
+ if(!GLOB.ship_saves.begin_retrieval(player_ckey, slot))
+ error = "Already under way."
+ return FALSE
+ working = TRUE
+ notice = null
+ var/datum/ship_checkpoint/snapshot = GLOB.ship_saves.read_save(player_ckey, slot)
+ var/failure
+ if(istext(snapshot))
+ failure = snapshot
+ snapshot = null
+ else
+ failure = saved_ship_retrieve_denial(user, slot, allow_busy = TRUE)
+ if(failure)
+ qdel(snapshot)
+ GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
+ if(!QDELETED(src))
+ working = FALSE
+ error = failure
+ return FALSE
+ // The job claims the bay before waiting for the shared shuttle loader.
+ var/datum/checkpoint_construction/saved/job = create_retrieval_job(user, snapshot, slot)
+ var/started = FALSE
+ if(job.bay)
+ started = job.prepare()
+ else
+ job.error = "The ship bay is occupied."
+ GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
+ qdel(job)
+ if(!QDELETED(src))
+ working = FALSE
+ if(started)
+ error = null
+ notice = "Construction started."
+ else
+ error = job.error || "Construction failed. [job.unspent_note()]"
+ return started
+
/// What the Saved Ships tab shows. Reads only caches; ui_data must not touch the database.
/datum/ship_checkpoint_ui/proc/saved_ships_data(mob/user)
var/list/saves = list()
for(var/list/row as anything in GLOB.ship_saves.saves_for(user.ckey))
- saves += list(list("slot" = row["slot"], "name" = row["ship_name"], "class" = row["ship_class"], "denial" = null))
+ saves += list(list("slot" = row["slot"], "name" = row["ship_name"], "class" = row["ship_class"], "denial" = saved_ship_retrieve_denial(user, row["slot"])))
var/list/ships = list()
for(var/datum/outpost_berth/ship_bay/bay as anything in outpost.bay_berths)
if(!bay?.ship?.is_ship_captain(user))
diff --git a/voidcrew/modules/ship_saves/ship_save_construction.dm b/voidcrew/modules/ship_saves/ship_save_construction.dm
new file mode 100644
index 00000000000..322380f9867
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_construction.dm
@@ -0,0 +1,46 @@
+/// Drones build a saved ship in a ship bay. Everything but the source is the checkpoint rebuild's.
+/datum/checkpoint_construction/saved
+ status_verb = "Building"
+ build_noun = "Construction"
+ /// The player's save slot this ship is being built from.
+ var/save_slot
+
+/datum/checkpoint_construction/saved/New(datum/ship_checkpoint_ui/terminal, datum/ship_checkpoint/blueprint, mob/living/user, slot, manual_drive = FALSE)
+ save_slot = slot
+ blueprint.outpost = terminal.outpost
+ // There is no hull to retire: this one was saved rounds ago.
+ ..(terminal, blueprint, user, manual_drive, TRUE)
+
+/// The snapshot is this job's own decoded copy. The save itself is in the table.
+/datum/checkpoint_construction/saved/Destroy(force)
+ var/datum/ship_checkpoint/transient = snapshot
+ . = ..()
+ if(. == QDEL_HINT_LETMELIVE)
+ return
+ if(transient)
+ qdel(transient)
+
+/datum/checkpoint_construction/saved/source_denial()
+ if(QDELETED(snapshot))
+ return "This record can't be read."
+ return null
+
+/datum/checkpoint_construction/saved/unspent_note()
+ return "Your saved ship is safe."
+
+/// The parent clears the template's blueprint and deletes the snapshot; there is no original to retire.
+/datum/checkpoint_construction/saved/consume_source()
+ . = ..()
+ GLOB.ship_saves.spend(captain_ckey, save_slot)
+ log_game("[captain_ckey] spent ship save slot [save_slot] on [ship_name] at [home.name].")
+
+/// A build stopped before its first piece leaves the save as it was. After that it stays spent.
+/datum/checkpoint_construction/saved/release_source()
+ if(!committed)
+ GLOB.ship_saves.cancel_retrieval(captain_ckey, save_slot)
+
+/datum/checkpoint_construction/saved/after_ship_loaded()
+ place_ship_landmarks(port)
+
+/datum/checkpoint_construction/saved/finished_text()
+ return "launched from a saved ship"
diff --git a/voidcrew/modules/ship_saves/ship_save_lobby.dm b/voidcrew/modules/ship_saves/ship_save_lobby.dm
new file mode 100644
index 00000000000..d9cc3b1e403
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_lobby.dm
@@ -0,0 +1,91 @@
+/datum/map_template/shuttle/voidcrew
+ /// The ship's account starts empty rather than with the usual commissioning funds.
+ var/no_starting_funds = FALSE
+
+/// Runs in create_ship() on the loaded ship, with atmos still paused. Must not sleep.
+/datum/map_template/shuttle/voidcrew/proc/finish_loaded_copy(obj/docking_port/mobile/voidcrew/loaded)
+ return
+
+/datum/map_template/shuttle/voidcrew/commissioned/checkpoint
+ no_starting_funds = TRUE
+
+/// What a console rebuild does to its hidden copy, for a saved ship loaded whole from the lobby. It
+/// stays in one tick, for the reason given at /datum/checkpoint_construction/proc/prepare_copy().
+/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/finish_loaded_copy(obj/docking_port/mobile/voidcrew/loaded)
+ if(!blueprint)
+ return
+ // A saved port stores no name, and create_ship() names the ship after its port.
+ loaded.name = name
+ checkpoint_clear_stock(loaded, ship_save_restocked())
+ for(var/turf/tile as anything in loaded.return_turfs())
+ if(!(get_area(tile) in loaded.shuttle_areas))
+ continue
+ for(var/obj/machinery/machine in tile)
+ checkpoint_restore_machine(machine)
+ checkpoint_provision_machine(machine)
+ // Player ships carry no door access.
+ if(istype(machine, /obj/machinery/door))
+ var/obj/machinery/door/door = machine
+ door.req_access = null
+ door.req_one_access = null
+ blueprint = null
+
+/**
+ * Loads a saved ship and seats the player on it, the way buying a hull does. The save is spent once
+ * the ship has loaded. Sleeps at the read and in the shipyard's queue; the player can leave during either.
+ */
+/mob/dead/new_player/proc/launch_saved_ship(slot)
+ if(!SSticker?.IsRoundInProgress())
+ to_chat(src, span_danger("The round is either not ready, or has already finished..."))
+ return
+ // Prevent double-click spawning
+ if(spawning_ship)
+ to_chat(src, span_warning("Your ship is already being prepared. Please wait..."))
+ return
+ var/player_ckey = ckey
+ var/denial = GLOB.ship_saves.retrieve_denial(player_ckey, slot)
+ if(denial)
+ to_chat(src, span_warning(denial))
+ return select_ship()
+ if(!GLOB.ship_saves.begin_retrieval(player_ckey, slot))
+ to_chat(src, span_warning("Already under way."))
+ return select_ship()
+ spawning_ship = TRUE
+
+ var/datum/ship_checkpoint/snapshot = GLOB.ship_saves.read_save(player_ckey, slot)
+ if(istext(snapshot) || QDELETED(src))
+ GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
+ if(QDELETED(src))
+ if(!istext(snapshot))
+ qdel(snapshot)
+ return
+ spawning_ship = FALSE
+ to_chat(src, span_warning(snapshot))
+ return select_ship()
+
+ to_chat(src, span_notice("[snapshot.ship_name] is being prepared. Please be patient!"))
+ var/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/template = new(snapshot)
+ var/obj/structure/overmap/ship/target = SSshuttle.create_ship(template)
+ qdel(snapshot)
+ if(!istype(target))
+ qdel(template)
+ GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
+ if(QDELETED(src))
+ return
+ spawning_ship = FALSE
+ // A refusal at the map-volume ceiling is transient, as in spawn_ship_with_upgrades().
+ if(SSmapping.at_z_level_ceiling())
+ to_chat(src, span_warning("The shipyard is congested right now - hull assembly space frees up as ships move. Try again in a minute."))
+ else
+ to_chat(src, span_danger("There was an error loading the ship. Please contact admins!"))
+ return select_ship()
+
+ // The ship exists now, whether or not its captain stayed to board it.
+ GLOB.ship_saves.spend(player_ckey, slot)
+ log_game("[player_ckey] launched [target.name] from ship save slot [slot].")
+ target.password_cleared_ckeys[player_ckey] = TRUE
+ if(QDELETED(src))
+ return
+ ship_metric_purchased(target, src)
+ if(!AttemptSpawnOnShip(target.job_slots[1], target))
+ to_chat(src, span_danger("Ship spawned, but you were unable to be spawned. You can likely try to spawn in the ship through joining normally, but if not, please contact an admin."))
diff --git a/voidcrew/modules/ship_saves/ship_saves.dm b/voidcrew/modules/ship_saves/ship_saves.dm
index 05b77a02aab..1dd3909b4ea 100644
--- a/voidcrew/modules/ship_saves/ship_saves.dm
+++ b/voidcrew/modules/ship_saves/ship_saves.dm
@@ -9,6 +9,11 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
var/list/cache = list()
/// ckey -> number of the newest load, so a slow read can't undo a save made after it started.
var/list/load_tickets = list()
+ /// slot_key() -> TRUE for each save being retrieved, from the lobby or any console.
+ var/list/retrievals = list()
+ /// slot_key() -> TRUE for each used save whose row hasn't been deleted yet. Reads leave these out,
+ /// so a slow or failed delete can't let one save be used twice.
+ var/list/spent_rows = list()
/// The player's cached rows, or null if they aren't loaded. This is the only read ui_data may use.
/datum/ship_saves/proc/saves_for(ckey)
@@ -25,6 +30,9 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
var/list/rows = GLOB.ship_save_store.read_meta(ckey)
// A failed read leaves what we had, and never reads as "no saves".
if(rows && load_tickets[ckey] == ticket)
+ for(var/list/row as anything in rows.Copy())
+ if(spent_rows[slot_key(ckey, row["slot"])])
+ rows -= list(row)
cache[ckey] = rows
GLOB.ship_economy_db.get_parts(ckey)
var/client/player = GLOB.directory[ckey]
@@ -83,6 +91,8 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
GLOB.ship_economy_db.refund_parts(ckey, plan)
return "Shipyard records are offline."
row["slot"] = slot
+ // The insert worked, so nothing older is left in this slot.
+ spent_rows -= slot_key(ckey, slot)
new_load_ticket(ckey)
var/list/rows = cache[ckey] || list()
rows += list(row)
@@ -91,3 +101,85 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
record_metric(METRIC_SHIP, "ship_saved", ckey = ckey, ship = ship, zone = ship_metric_zone(ship), subject = class, quantity = price, details = list("tiles" = tiles, "raw_bytes" = raw_bytes))
refresh(ckey)
return null
+
+/datum/ship_saves/proc/slot_key(ckey, slot)
+ return "[ckey(ckey)]:[slot]"
+
+/// One cached row, or null.
+/datum/ship_saves/proc/saved_row(ckey, slot)
+ SHOULD_NOT_SLEEP(TRUE)
+ for(var/list/row as anything in saves_for(ckey))
+ if(row["slot"] == slot)
+ return row
+ return null
+
+/**
+ * Why a save can't be retrieved now, or null. Only reads caches, so ui_data can use it.
+ * allow_busy: the caller holds this save's lock and is checking again after a wait.
+ */
+/datum/ship_saves/proc/retrieve_denial(ckey, slot, allow_busy = FALSE)
+ SHOULD_NOT_SLEEP(TRUE)
+ if(!GLOB.ship_save_store.online())
+ return "Shipyard records are offline."
+ var/list/row = saved_row(ckey, slot)
+ if(!row)
+ return "No such record."
+ if(row["format_version"] != SHIP_SAVE_FORMAT)
+ return "This record can't be read."
+ if(row["saved_round"] == text2num(GLOB.round_id))
+ return "Ready next round."
+ if(!allow_busy && retrievals[slot_key(ckey, slot)])
+ return "Already under way."
+ return null
+
+/// Claims a save for one retrieval, across the lobby and every console. FALSE if it is already claimed.
+/datum/ship_saves/proc/begin_retrieval(ckey, slot)
+ var/key = slot_key(ckey, slot)
+ if(retrievals[key])
+ return FALSE
+ retrievals[key] = TRUE
+ return TRUE
+
+/// The retrieval stopped before the save was used. It stays where it is.
+/datum/ship_saves/proc/cancel_retrieval(ckey, slot)
+ retrievals -= slot_key(ckey, slot)
+
+/**
+ * Reads a save out of the table. Sleeps. Returns a snapshot, which the caller owns and must delete,
+ * or an error for the player. Nothing is spent either way.
+ */
+/datum/ship_saves/proc/read_save(ckey, slot)
+ ckey = ckey(ckey)
+ var/payload = GLOB.ship_save_store.read_payload(ckey, slot)
+ if(!payload)
+ return "Shipyard records are offline."
+ var/datum/ship_checkpoint/snapshot = decode(payload)
+ if(!snapshot)
+ return "This record can't be read."
+ snapshot.captain_ckey = ckey
+ return snapshot
+
+/// The save has been used. It leaves the cache now and the table in the background, because a console
+/// build calls this mid-placement and must not sleep.
+/datum/ship_saves/proc/spend(ckey, slot)
+ SHOULD_NOT_SLEEP(TRUE)
+ ckey = ckey(ckey)
+ var/key = slot_key(ckey, slot)
+ retrievals -= key
+ spent_rows[key] = TRUE
+ new_load_ticket(ckey)
+ var/list/row = saved_row(ckey, slot)
+ if(row)
+ var/list/rows = cache[ckey]
+ rows -= list(row)
+ record_metric(METRIC_SHIP, "ship_retrieved", ckey = ckey, subject = row?["ship_class"], quantity = row?["tile_count"])
+ INVOKE_ASYNC(src, PROC_REF(delete_spent), ckey, slot)
+
+/datum/ship_saves/proc/delete_spent(ckey, slot)
+ if(GLOB.ship_save_store.remove(ckey, slot))
+ spent_rows -= slot_key(ckey, slot)
+ else
+ // It stays out of reads this round. Next round it would be back, so an admin should delete the row.
+ log_game("Used ship save [ckey] slot [slot] could not be deleted from the table.")
+ message_admins("Used ship save [ckey] slot [slot] could not be deleted from the table.")
+ refresh(ckey)
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 9ebbc013960..c275592f15c 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -3,6 +3,9 @@
#define SHIP_SAVE_TEST_PRICE_MIN 2 // SHIP_SAVE_PRICE_MIN
#define SHIP_SAVE_TEST_PRICE_MAX 15 // SHIP_SAVE_PRICE_MAX
#define SHIP_SAVE_TEST_PASSWORD_PRICE 3 // SHIP_PASSWORD_PRICE
+#define SHIP_SAVE_TEST_FORMAT 1 // SHIP_SAVE_FORMAT
+/// GLOB.round_id for the length of these tests. It is null on a world with no database.
+#define SHIP_SAVE_TEST_ROUND 777
/// One more than RESERVE_DOCK_MAX_SIZE_LONG.
#define SHIP_SAVE_TEST_TOO_LONG 57
@@ -11,9 +14,12 @@
/// "ckey|slot" -> list("meta", "payload")
var/list/saved = list()
var/fail_writes = FALSE
+ var/fail_reads = FALSE
+ var/fail_removes = FALSE
+ var/offline = FALSE
/datum/ship_save_store/fake/online()
- return TRUE
+ return !offline
/datum/ship_save_store/fake/read_meta(ckey)
var/list/rows = list()
@@ -27,6 +33,8 @@
return rows
/datum/ship_save_store/fake/read_payload(ckey, slot)
+ if(fail_reads)
+ return null
var/list/entry = saved["[ckey]|[slot]"]
return entry?["payload"]
@@ -37,7 +45,7 @@
return TRUE
/datum/ship_save_store/fake/remove(ckey, slot)
- if(!saved["[ckey]|[slot]"])
+ if(fail_removes || !saved["[ckey]|[slot]"])
return FALSE
saved -= "[ckey]|[slot]"
return TRUE
@@ -77,9 +85,12 @@
var/datum/ship_economy_db/old_economy
var/datum/ship_save_store/fake/store
var/datum/ship_economy_db/fake/economy
+ var/old_round_id
/datum/unit_test/voidcrew_ship_saves/New()
. = ..()
+ old_round_id = GLOB.round_id
+ GLOB.round_id = "[SHIP_SAVE_TEST_ROUND]"
old_store = GLOB.ship_save_store
old_saves = GLOB.ship_saves
old_economy = GLOB.ship_economy_db
@@ -90,6 +101,7 @@
GLOB.ship_economy_db = economy
/datum/unit_test/voidcrew_ship_saves/Destroy()
+ GLOB.round_id = old_round_id
GLOB.ship_save_store = old_store
GLOB.ship_saves = old_saves
GLOB.ship_economy_db = old_economy
@@ -125,6 +137,7 @@
check_prices()
check_part_plans()
check_commit()
+ check_retrieval_rules()
/datum/unit_test/voidcrew_ship_saves/proc/check_round_trip()
var/datum/ship_checkpoint/snapshot = make_snapshot()
@@ -192,6 +205,14 @@
var/obj/structure/grille/grille = allocate(/obj/structure/grille)
TEST_ASSERT(saved.includes_object(grille), "A grille was refused")
+ // What a ship is ordinarily furnished in stays, and a med bay keeps its table whatever it is made of.
+ var/obj/structure/table/wood/wood_table = allocate(/obj/structure/table/wood)
+ TEST_ASSERT(saved.includes_object(wood_table), "A wood table was refused")
+ var/obj/structure/chair/comfy/shuttle/shuttle_chair = allocate(/obj/structure/chair/comfy/shuttle)
+ TEST_ASSERT(saved.includes_object(shuttle_chair), "A titanium shuttle chair was refused")
+ var/obj/structure/table/optable/operating_table = allocate(/obj/structure/table/optable)
+ TEST_ASSERT(saved.includes_object(operating_table), "A silver operating table was refused")
+
// False walls and mineral doors stay, as plain iron.
var/obj/structure/falsewall/gold/fake_wall = allocate(/obj/structure/falsewall/gold)
TEST_ASSERT(saved.includes_object(fake_wall), "A gold false wall was refused: it seals the hull")
@@ -337,6 +358,90 @@
TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A taken slot was overwritten")
TEST_ASSERT_EQUAL(economy.total(), parts_before, "A duplicate key kept the payment")
+/// Puts a row in the table, as if saved in an earlier round unless told otherwise.
+/datum/unit_test/voidcrew_ship_saves/proc/add_stored_save(player_ckey, slot, saved_round = SHIP_SAVE_TEST_ROUND - 1, format = SHIP_SAVE_TEST_FORMAT)
+ store.saved["[player_ckey]|[slot]"] = list(
+ "meta" = list("ship_name" = "Test Hull", "ship_class" = "Test Class", "tile_count" = 10, "format_version" = format, "saved_round" = saved_round, "raw_bytes" = 1),
+ "payload" = GLOB.ship_saves.encode(make_snapshot()),
+ )
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_retrieval_rules()
+ var/player_ckey = "retrievetester"
+ var/datum/ship_saves/saves = GLOB.ship_saves
+ add_stored_save(player_ckey, 1)
+ add_stored_save(player_ckey, 2, saved_round = SHIP_SAVE_TEST_ROUND)
+ add_stored_save(player_ckey, 3, format = SHIP_SAVE_TEST_FORMAT + 1)
+ saves.refresh(player_ckey)
+
+ // Every denial.
+ TEST_ASSERT_NULL(saves.retrieve_denial(player_ckey, 1), "A save from an earlier round was refused")
+ TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 2), "Ready next round.", "A save made this round was offered")
+ TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 3), "This record can't be read.", "An unknown format was offered")
+ TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, SHIP_SAVE_TEST_SLOTS + 1), "No such record.", "An empty slot was offered")
+ TEST_ASSERT_EQUAL(saves.retrieve_denial("nobodyatall", 1), "No such record.", "An unloaded player was offered a save")
+ store.offline = TRUE
+ TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 1), "Shipyard records are offline.", "A save was offered with the records down")
+ store.offline = FALSE
+
+ // One retrieval at a time, whoever asks.
+ TEST_ASSERT(saves.begin_retrieval(player_ckey, 1), "A free save could not be claimed")
+ TEST_ASSERT(!saves.begin_retrieval(player_ckey, 1), "One save was claimed twice")
+ TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 1), "Already under way.", "A claimed save was offered")
+ TEST_ASSERT_NULL(saves.retrieve_denial(player_ckey, 1, allow_busy = TRUE), "The holder of a claim was refused by it")
+ TEST_ASSERT(saves.begin_retrieval(player_ckey, 2), "A claim on one slot blocked another")
+ saves.cancel_retrieval(player_ckey, 2)
+ saves.cancel_retrieval(player_ckey, 1)
+ TEST_ASSERT_NULL(saves.retrieve_denial(player_ckey, 1), "A cancelled retrieval kept its claim")
+
+ // A read that can't be used costs nothing and says why.
+ store.fail_reads = TRUE
+ TEST_ASSERT_EQUAL(saves.read_save(player_ckey, 1), "Shipyard records are offline.", "A failed read was not reported")
+ store.fail_reads = FALSE
+ var/list/entry = store.saved["[player_ckey]|1"]
+ var/good_payload = entry["payload"]
+ entry["payload"] = "not json"
+ TEST_ASSERT_EQUAL(saves.read_save(player_ckey, 1), "This record can't be read.", "A junk payload was not reported")
+ entry["payload"] = good_payload
+ var/datum/ship_checkpoint/read_back = saves.read_save(player_ckey, 1)
+ TEST_ASSERT(istype(read_back), "A good payload did not read: [read_back]")
+ TEST_ASSERT_EQUAL(read_back.captain_ckey, player_ckey, "A read save does not know its owner")
+ qdel(read_back)
+ TEST_ASSERT_NOTNULL(store.saved["[player_ckey]|1"], "Reading a save spent it")
+
+ // Spending clears the row, the cache and the claim, and frees the slot.
+ TEST_ASSERT(saves.begin_retrieval(player_ckey, 1), "A free save could not be claimed")
+ saves.spend(player_ckey, 1)
+ TEST_ASSERT_NULL(saves.saved_row(player_ckey, 1), "A spent save stayed in the cache")
+ TEST_ASSERT_NULL(store.saved["[player_ckey]|1"], "A spent save stayed in the table")
+ TEST_ASSERT_EQUAL(saves.free_slot(player_ckey), 1, "A spent save did not free its slot")
+ TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 1), "No such record.", "A spent save was still offered")
+ TEST_ASSERT(saves.begin_retrieval(player_ckey, 1), "Spending kept the claim")
+ saves.cancel_retrieval(player_ckey, 1)
+
+ // A delete that fails must not hand the save back on the next read.
+ store.fail_removes = TRUE
+ saves.spend(player_ckey, 2)
+ store.fail_removes = FALSE
+ TEST_ASSERT_NOTNULL(store.saved["[player_ckey]|2"], "The failed delete removed the row anyway")
+ saves.refresh(player_ckey)
+ TEST_ASSERT_NULL(saves.saved_row(player_ckey, 2), "A later read gave back a spent save")
+
+/// A lobby player with nobody connected: joining a ship needs a real client, so seating only records.
+/mob/dead/new_player/ship_save_test
+ var/obj/structure/overmap/ship/seated_on
+ var/datum/job/seated_as
+
+/mob/dead/new_player/ship_save_test/select_ship()
+ return
+
+/mob/dead/new_player/ship_save_test/AttemptSpawnOnShip(datum/job/job, obj/structure/overmap/ship/joined_ship)
+ seated_on = joined_ship
+ seated_as = job
+ return TRUE
+
+/datum/ship_checkpoint_ui/registry_test/create_retrieval_job(mob/living/user, datum/ship_checkpoint/snapshot, slot)
+ return new /datum/checkpoint_construction/saved(src, snapshot, user, slot, manual_jobs)
+
/// The panel and the password, on a real hull in a real bay.
/datum/unit_test/voidcrew_ship_saves_hull
parent_type = /datum/unit_test/voidcrew_outpost_management
@@ -498,6 +603,227 @@
TEST_ASSERT(test_ship.set_join_password("direct", captain), "The free setter was refused")
TEST_ASSERT_EQUAL(parts_held(), 10, "The free setter charged")
+/// Getting a saved ship back: built by drones in a bay, and loaded whole from the lobby.
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval
+ var/old_round_id
+ var/old_ticker_state
+ var/list/obj/structure/overmap/ship/retrieved_ships = list()
+ var/saved_name
+ var/saved_class
+ var/saved_jobs
+
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/New()
+ . = ..()
+ old_round_id = GLOB.round_id
+ GLOB.round_id = "[SHIP_SAVE_TEST_ROUND]"
+ old_ticker_state = SSticker.current_state
+
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/Destroy()
+ GLOB.round_id = old_round_id
+ SSticker.current_state = old_ticker_state
+ for(var/obj/structure/overmap/ship/ship as anything in retrieved_ships)
+ if(!QDELETED(ship))
+ qdel(ship)
+ return ..()
+
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/Run()
+ var/obj/structure/overmap/dynamic/player_outpost/registry_test/home = allocate(__IMPLIED_TYPE__)
+ home.shell_template = allocate(/datum/map_template/player_outpost/test_fixture)
+ home.founder_ckey = "savecaptain"
+ TEST_ASSERT(home.load_level(), "The outpost did not load")
+ TEST_ASSERT_NULL(home.enable_ship_bays(), "The ship bay did not load")
+ var/obj/machinery/computer/ship_checkpoint/terminal = outpost_bay_shipyard_console(LAZYACCESS(home.bay_berths, 1))
+ TEST_ASSERT_NOTNULL(terminal, "The ship bay has no shipyard console")
+ var/mob/living/carbon/human/captain = make_player(get_turf(terminal), "savecaptain")
+ test_ship = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
+ TEST_ASSERT_NOTNULL(test_ship, "Could not create the hull")
+ test_ship.enlist_crewmember(captain)
+ test_ship.claimed_captain = captain.mind
+ fit_source(test_ship.shuttle)
+ var/datum/ship_checkpoint_ui/registry_test/panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
+ var/datum/outpost_berth/ship_bay/bay = home.allocate_ship_bay(test_ship)
+ TEST_ASSERT_NOTNULL(bay, "The hull was not given a bay")
+ adjust_reserve_dock_to_shuttle(bay.dock, test_ship.shuttle)
+ test_ship.shuttle.mode = SHUTTLE_PREARRIVAL
+ test_ship.shuttle.initiate_docking(bay.dock)
+ test_ship.shuttle.mode = SHUTTLE_IDLE
+ test_ship.docked = home
+ test_ship.forceMove(home)
+ test_ship.state = "idle"
+ bay.on_ship_docked(test_ship)
+
+ // A save made this round waits for the next one.
+ set_parts(40)
+ TEST_ASSERT(panel.save_ship(captain, bay), "Saving failed: [panel.error]")
+ saved_name = test_ship.name
+ saved_class = test_ship.source_template.short_name
+ saved_jobs = length(test_ship.job_definitions)
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "Ready next round.", "A ship was offered in the round it was saved")
+
+ // The same save as made last round, in every slot here and in a lobby player's first.
+ var/list/first = store.saved["savecaptain|1"]
+ var/list/first_meta = first["meta"]
+ first_meta["saved_round"] = SHIP_SAVE_TEST_ROUND - 1
+ for(var/slot in 2 to SHIP_SAVE_TEST_SLOTS)
+ store.saved["savecaptain|[slot]"] = list("meta" = first_meta.Copy(), "payload" = first["payload"])
+ store.saved["savelobby|1"] = list("meta" = first_meta.Copy(), "payload" = first["payload"])
+ GLOB.ship_saves.refresh("savecaptain")
+ GLOB.ship_saves.refresh("savelobby")
+
+ // The source is still in the bay.
+ var/list/panel_data = panel.saved_ships_data(captain)
+ var/list/listed_saves = panel_data["saves"]
+ var/list/first_listed = listed_saves[1]
+ TEST_ASSERT_EQUAL(first_listed["denial"], "The ship bay is occupied.", "The panel offered a save with the bay full")
+ TEST_ASSERT(!panel.retrieve_ship(captain, 1), "A save was built with the bay full")
+ TEST_ASSERT_EQUAL(panel.error, "The ship bay is occupied.", "A full bay gave the wrong refusal")
+ vacate_bay(home)
+ TEST_ASSERT_NULL(panel.saved_ship_retrieve_denial(captain, 1), "The panel refused a save with the bay empty")
+
+ // Stopped before the first piece: the save is untouched and can be tried again.
+ TEST_ASSERT(panel.retrieve_ship(captain, 1), "Could not start building a save: [panel.error]")
+ TEST_ASSERT_EQUAL(panel.notice, "Construction started.", "Starting a build said nothing")
+ TEST_ASSERT(!panel.working, "The panel stayed busy after starting a build")
+ var/datum/checkpoint_construction/saved/job = home.checkpoint_jobs[1]
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "Already under way.", "A save was offered while it was being built")
+ TEST_ASSERT(!panel.retrieve_ship(captain, 1), "A second click started another build")
+ TEST_ASSERT_NOTNULL(store.saved["savecaptain|1"], "The save was spent before the first piece")
+ job.abort("Stopped for the test.")
+ TEST_ASSERT(QDELETED(job), "A stopped build kept running")
+ TEST_ASSERT_NOTNULL(store.saved["savecaptain|1"], "A build stopped before its first piece spent the save")
+ TEST_ASSERT_NOTNULL(GLOB.ship_saves.saved_row("savecaptain", 1), "A build stopped before its first piece lost the cached save")
+ TEST_ASSERT_NULL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "A stopped build kept its claim on the save")
+ TEST_ASSERT(bay.is_available(), "A stopped build kept the bay")
+
+ // Past the first piece the save is gone, and stopping the build does not bring it back.
+ TEST_ASSERT(panel.retrieve_ship(captain, 2), "Could not start building a save: [panel.error]")
+ job = home.checkpoint_jobs[1]
+ job.fast_forward(1)
+ TEST_ASSERT(job.committed, "The first piece did not commit the build")
+ TEST_ASSERT_NULL(store.saved["savecaptain|2"], "The first piece did not spend the save")
+ TEST_ASSERT_NULL(GLOB.ship_saves.saved_row("savecaptain", 2), "The first piece left the save in the cache")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.free_slot("savecaptain"), 2, "The first piece did not free the slot")
+ job.abort("Stopped for the test.")
+ TEST_ASSERT_NULL(store.saved["savecaptain|2"], "Stopping a build gave back a spent save")
+ TEST_ASSERT_NULL(GLOB.ship_saves.saved_row("savecaptain", 2), "Stopping a build put a spent save back in the cache")
+ var/deadline = world.time + 30 SECONDS
+ UNTIL(bay.is_available() || world.time > deadline)
+ TEST_ASSERT(bay.is_available(), "A terminated build never gave the bay back")
+
+ // A whole build.
+ TEST_ASSERT(panel.retrieve_ship(captain, 1), "Could not start building a save: [panel.error]")
+ job = home.checkpoint_jobs[1]
+ job.fast_forward()
+ TEST_ASSERT(QDELETED(job), "The build did not finish")
+ TEST_ASSERT_NULL(store.saved["savecaptain|1"], "A finished build left its save in the table")
+ var/obj/structure/overmap/ship/rebuilt = bay.ship
+ TEST_ASSERT_NOTNULL(rebuilt, "The finished build is not in the bay")
+ retrieved_ships += rebuilt
+ TEST_ASSERT(rebuilt.is_ship_captain(captain), "The saving captain does not command the finished ship")
+ check_retrieved(rebuilt, "Console")
+
+ // The lobby: a read that fails costs nothing.
+ SSticker.current_state = GAME_STATE_PLAYING
+ var/mob/dead/new_player/ship_save_test/lobby = allocate(__IMPLIED_TYPE__)
+ lobby.key = "savelobby"
+ store.fail_reads = TRUE
+ lobby.launch_saved_ship(1)
+ store.fail_reads = FALSE
+ TEST_ASSERT_NULL(lobby.seated_on, "A failed read seated the player on something")
+ TEST_ASSERT(!lobby.spawning_ship, "A failed read left the player stuck preparing a ship")
+ TEST_ASSERT_NOTNULL(store.saved["savelobby|1"], "A failed read spent the save")
+ TEST_ASSERT_NULL(GLOB.ship_saves.retrieve_denial("savelobby", 1), "A failed read kept its claim on the save")
+
+ // The lobby: the ship loads, the save goes, and the player is seated as the first job.
+ lobby.launch_saved_ship(1)
+ var/obj/structure/overmap/ship/launched = lobby.seated_on
+ TEST_ASSERT_NOTNULL(launched, "The lobby did not seat the player on the saved ship")
+ retrieved_ships += launched
+ TEST_ASSERT_EQUAL(lobby.seated_as, launched.job_slots[1], "The player was not seated in the ship's first job")
+ TEST_ASSERT(launched.password_cleared_ckeys["savelobby"], "The player is not cleared to board their own ship")
+ TEST_ASSERT_NULL(store.saved["savelobby|1"], "The lobby did not spend the save")
+ TEST_ASSERT_NULL(GLOB.ship_saves.saved_row("savelobby", 1), "The lobby left the save in the cache")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.free_slot("savelobby"), 1, "The lobby did not free the slot")
+ check_retrieved(launched, "Lobby")
+
+/// What a player could try to carry between rounds: an upgraded machine, a tank of plasma beside a tank of air.
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/fit_source(obj/docking_port/mobile/voidcrew/port)
+ var/list/fittings = list(/obj/machinery/autolathe, /obj/machinery/atmospherics/components/tank/plasma, /obj/machinery/atmospherics/components/tank/air)
+ for(var/turf/open/floor/tile in port.return_turfs())
+ if(!length(fittings))
+ break
+ if(!(get_area(tile) in port.shuttle_areas) || tile.is_blocked_turf(exclude_mobs = TRUE))
+ continue
+ var/fitting_type = fittings[1]
+ fittings.Cut(1, 2)
+ var/obj/machinery/fitting = new fitting_type(tile)
+ if(istype(fitting, /obj/machinery/autolathe))
+ var/datum/stock_part/old_bin = locate(/datum/stock_part/matter_bin) in fitting.component_parts
+ TEST_ASSERT_NOTNULL(old_bin, "A new autolathe has no matter bin to upgrade")
+ fitting.component_parts -= old_bin
+ fitting.component_parts += GLOB.stock_part_datums[/datum/stock_part/matter_bin/tier4]
+ fitting.RefreshParts()
+ TEST_ASSERT(!length(fittings), "The test hull had no room for [length(fittings)] fittings")
+
+/// As lose_original() does for the checkpoint tests: flies the source away and deletes it.
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/vacate_bay(obj/structure/overmap/dynamic/player_outpost/home)
+ var/obj/docking_port/stationary/transit/transit = test_ship.shuttle.assigned_transit || SSshuttle.generate_transit_dock(test_ship.shuttle)
+ test_ship.shuttle.mode = SHUTTLE_PREARRIVAL
+ test_ship.shuttle.initiate_docking(transit)
+ test_ship.shuttle.mode = SHUTTLE_IDLE
+ test_ship.docked = null
+ test_ship.forceMove(get_turf(home))
+ test_ship.state = "flying"
+ home.on_ship_undock_complete(test_ship)
+ test_ship.shuttle.admin_delete_shuttle()
+
+/// Everything a retrieved ship must be, however it got here.
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/check_retrieved(obj/structure/overmap/ship/ship, where)
+ TEST_ASSERT_EQUAL(ship.name, saved_name, "[where]: the ship lost its name")
+ TEST_ASSERT_EQUAL(ship.source_template.short_name, saved_class, "[where]: the ship lost its class")
+ TEST_ASSERT_EQUAL(length(ship.job_definitions), saved_jobs, "[where]: the ship lost its roster")
+ TEST_ASSERT_EQUAL(ship.ship_account.account_balance, 0, "[where]: the ship came with money")
+ TEST_ASSERT(length(ship.shuttle.spawn_points), "[where]: nobody can spawn on the ship")
+ var/lathes = 0
+ var/plasma_tanks = 0
+ var/air_tanks = 0
+ var/obj/docking_port/mobile/voidcrew/port = ship.shuttle
+ for(var/turf/tile as anything in port.return_turfs())
+ if(!(get_area(tile) in port.shuttle_areas))
+ continue
+ for(var/obj/item/loose in tile)
+ TEST_FAIL("[where]: the ship came with a loose [loose.type]")
+ for(var/obj/machinery/machine in tile)
+ if(istype(machine, /obj/machinery/autolathe))
+ lathes++
+ TEST_ASSERT_NULL(locate(/datum/stock_part/matter_bin/tier4) in machine.component_parts, "[where]: an upgraded part came back")
+ if(istype(machine, /obj/machinery/atmospherics/components/tank/plasma))
+ plasma_tanks++
+ var/obj/machinery/atmospherics/components/tank/plasma_tank = machine
+ TEST_ASSERT_EQUAL(plasma_tank.air_contents.total_moles(), 0, "[where]: a plasma tank came back with gas in it")
+ if(istype(machine, /obj/machinery/atmospherics/components/tank/air))
+ air_tanks++
+ var/obj/machinery/atmospherics/components/tank/air_tank = machine
+ TEST_ASSERT(air_tank.air_contents.total_moles() > 0, "[where]: an air tank came back empty")
+ if(istype(machine, /obj/machinery/power/apc))
+ var/obj/machinery/power/apc/controller = machine
+ TEST_ASSERT(controller.cell?.charge > 0, "[where]: an APC came back with no charged cell")
+ if(istype(machine, /obj/machinery/vending))
+ var/obj/machinery/vending/vendor = machine
+ for(var/datum/data/vending_product/product as anything in vendor.product_records + vendor.hidden_records + vendor.coin_records)
+ TEST_ASSERT_EQUAL(product.amount, 0, "[where]: a vending machine came back stocked")
+ if(istype(machine, /obj/machinery/door))
+ var/obj/machinery/door/door = machine
+ TEST_ASSERT(!length(door.req_access) && !length(door.req_one_access), "[where]: a door kept its access")
+ if(istype(machine, /obj/machinery/atmospherics/components/unary/shuttle/heater))
+ var/obj/machinery/atmospherics/components/unary/shuttle/heater/heater = machine
+ TEST_ASSERT(heater.fuel_tank?.air_contents.total_moles() > 0, "[where]: an engine heater came back with no fuel")
+ TEST_ASSERT(lathes, "[where]: the upgraded autolathe did not come back")
+ TEST_ASSERT(plasma_tanks, "[where]: the plasma tank did not come back")
+ TEST_ASSERT(air_tanks, "[where]: the air tank did not come back")
+
+#undef SHIP_SAVE_TEST_ROUND
+#undef SHIP_SAVE_TEST_FORMAT
#undef SHIP_SAVE_TEST_GONE_PATH
#undef SHIP_SAVE_TEST_SLOTS
#undef SHIP_SAVE_TEST_PRICE_MIN
From 5499e60c898efe9f3b717bb51581e975cb503e45 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:50:37 -0800
Subject: [PATCH 04/72] Add the saved ships screens and wiki section
---
.../interfaces/CaptainManagement.tsx | 13 ++-
.../interfaces/ShipCheckpoint.tsx | 104 +++++++++++++++++-
.../voidcrew_tgui/interfaces/ShipJoinMenu.tsx | 59 ++++++++++
wiki/content/player-outposts.md | 20 +++-
wiki/content/ship-parts.md | 2 +
5 files changed, 194 insertions(+), 4 deletions(-)
diff --git a/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx b/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx
index b9ff94c0444..52afcc62087 100644
--- a/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx
+++ b/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx
@@ -52,6 +52,7 @@ type Data = {
joining_allowed: BooleanLike;
join_password: string;
can_set_password: BooleanLike;
+ password_price: number;
crew_only_airlocks: BooleanLike;
is_captain: BooleanLike;
crew: CrewMember[];
@@ -361,12 +362,18 @@ const SettingsTab = () => {
joining_allowed,
join_password,
can_set_password,
+ password_price,
crew_only_airlocks,
can_rename,
} = data;
const [newName, setNewName] = useState(ship_name ?? '');
const [newMemo, setNewMemo] = useState(memo ?? '');
const [newPassword, setNewPassword] = useState(join_password ?? '');
+ // Same comparison as check_join_password(): trimmed, any case.
+ const enteredPassword = newPassword.trim().toLowerCase();
+ const passwordCosts =
+ !!enteredPassword &&
+ enteredPassword !== (join_password ?? '').trim().toLowerCase();
return (
@@ -449,7 +456,11 @@ const SettingsTab = () => {
act('set_password', { password: newPassword })
}
>
- {newPassword ? 'Set Password' : 'Clear'}
+ {passwordCosts
+ ? `Set Password · ${password_price} parts`
+ : newPassword
+ ? 'Set Password'
+ : 'Clear'}
diff --git a/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx b/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx
index f5105d933a3..f41ce7e8394 100644
--- a/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx
+++ b/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx
@@ -96,10 +96,30 @@ type Data = {
}[];
catalog: ShopHull[];
shop: Shop | null;
+ saved_ships: {
+ online: BooleanLike;
+ parts: number | null;
+ slots: number;
+ saves: {
+ slot: number;
+ name: string;
+ class: string | null;
+ denial: string | null;
+ }[];
+ ships: {
+ ref: string;
+ name: string;
+ price: number;
+ denial: string | null;
+ }[];
+ };
};
const credits = (amount: number) => `${amount.toLocaleString()} cr`;
+const shipParts = (amount: number) =>
+ `${amount} ${amount === 1 ? 'part' : 'parts'}`;
+
export const ShipCheckpoint = () => {
const { data } = useBackend();
const [tab, setTab] = useState('checkpoints');
@@ -135,6 +155,13 @@ export const ShipCheckpoint = () => {
>
Checkpoints
+ setTab('saved')}
+ >
+ Saved ships
+ {
New ship
- {tab === 'shop' ? : }
+ {tab === 'shop' && }
+ {tab === 'saved' && }
+ {tab === 'checkpoints' && }
);
@@ -213,6 +242,79 @@ const CheckpointsTab = () => {
);
};
+const SavedShipsTab = () => {
+ const { data, act } = useBackend();
+ const { online, parts, slots, saves, ships } = data.saved_ships;
+ if (!online) {
+ return (
+
+ Shipyard records are offline.
+
+ );
+ }
+ return (
+ <>
+
+ {parts === null ? '' : `${shipParts(parts)} · `}
+ {saves.length}/{slots}
+
+ }
+ >
+ {saves.length === 0 && None saved.}
+ {saves.map((save) => (
+
+
+ {save.name}
+ {!!save.class && {save.class}}
+
+
+
+
+
+ ))}
+
+
+ {ships.length === 0 && (
+ Dock a ship you command.
+ )}
+ {ships.map((ship) => {
+ const short =
+ parts !== null && parts < ship.price
+ ? `You have ${shipParts(parts)}.`
+ : null;
+ return (
+
+
+ {ship.name}
+
+
+
+
+
+ );
+ })}
+
+ >
+ );
+};
+
/** Matches is_module_available_for_theme(): themeless hulls take only themeless modules. */
const fitsTheme = (module: ShopModule, theme: string) =>
theme ? module.themes.includes(theme) : module.themes.length === 0;
diff --git a/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx b/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx
index 05aea78b63a..4b351699896 100644
--- a/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx
+++ b/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx
@@ -42,11 +42,20 @@ type ShipJoinMenuData = {
}[];
can_requisition: BooleanLike;
wiki_url: string | null;
+ saved_ships: SavedShip[];
+};
+
+type SavedShip = {
+ slot: number;
+ name: string;
+ class: string | null;
+ denial: string | null;
};
export const ShipJoinMenu = () => {
const { act, data } = useBackend();
const { player_name, ships, can_requisition, wiki_url } = data;
+ const savedShips = data.saved_ships || [];
return (
{
+ {/* Saved Ships Section */}
+ {savedShips.length > 0 && (
+
+
+
+ )}
+
{/* Free Hull Section */}
@@ -151,6 +167,49 @@ const PurchaseShipSection = () => {
);
};
+const SavedShipsSection = (props: { saves: SavedShip[] }) => {
+ const { act } = useBackend();
+
+ return (
+
+
+ Saved Ships
+
+ }
+ >
+
+ {props.saves.map((save) => (
+
+
+
+ {save.name}
+ {!!save.class && (
+
+ {save.class}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
const RequisitionSection = (props: { canRequisition: boolean }) => {
const { act } = useBackend();
const { canRequisition } = props;
diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md
index df0db64fd33..f5c4d414656 100644
--- a/wiki/content/player-outposts.md
+++ b/wiki/content/player-outposts.md
@@ -162,10 +162,26 @@ A hull defense turret bolted to the outpost answers only to its owner, stewards,
The **Rooms** tab sells a permanent **ship bay** for 10,000 credits, 100 iron and 50 glass from the outpost silo; it needs a working hangar elevator. Ships reach it through the **Ship Bay** option at the helm, and people by elevator. When a ship in the bay asks to use the outpost silo, the request appears on its bay row. An evicted ship has three minutes to undock, and gets its docking fee back if it paid in the last 30 minutes.
-The bay's own **shipyard console**, found only in the bay itself, has two tabs:
+The bay's own **shipyard console**, found only in the bay itself, has three tabs:
-- **Shop** orders a new ship, built at the bay by your delivery drones and stocked with its usual starting supplies, paid from the outpost bank or your own account.
- **Checkpoints** saves a captain's docked ship: **10,000 credits** the first time, **5,000 credits** to update it later. If that ship is ever lost or its hull abandoned, the same captain can pay to have it rebuilt from the saved checkpoint. The delivery drones do the rebuilding in one visible pass, then the ship launches into the bay.
+- **Saved ships** keeps a ship you command for a later round.
+- **New ship** orders a new ship, built at the bay by your delivery drones and stocked with its usual starting supplies, paid from the outpost bank or your own account.
+
+### Saved ships
+
+You have three save slots. Dock a ship you command in a ship bay and pay [ship parts](ship-parts.md) to save it: bigger ships cost more, up to 15 parts, and any class of part will do. The hull needs at least one cryopod. A full set of slots means no more saves until you use one.
+
+A saved ship is ready from the next round. Get it back for free, either from **Saved Ships** in the lobby's join menu, which launches it with you aboard, or from the **Saved ships** tab of a shipyard console in any ship bay, where the delivery drones build it. Each save works once: using it uses it up and frees the slot.
+
+| Comes back | Stays behind |
+|---|---|
+| Hull and rooms | Cargo and supplies |
+| Machines, with standard parts | Upgraded parts |
+| Crew jobs | Credits |
+| Charged batteries and engine fuel | Statues and other fittings of precious materials |
+
+Walls and floors of precious materials keep their look, but only give iron when taken apart.
## Advertising
diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md
index 39f7a8c9270..2a6910a4ea0 100644
--- a/wiki/content/ship-parts.md
+++ b/wiki/content/ship-parts.md
@@ -80,3 +80,5 @@ Give the item to the recipient so they can put it in their case and extract it.
Open the **Shipyard** from the join menu to see your balance and the prices of hulls, themes and modules. Purchases use your banked parts; you do not carry physical parts into the shipyard. Free options need no parts.
Unlocks are permanent. Buying a theme or module lets you select it for future launches of that hull without paying again, and losing a ship does not erase your purchases. See [Buying & Upgrading Ships](ship-upgrades.md) for choosing a loadout and [Ship Catalog](ship-catalog.md) for the available hulls.
+
+Parts of any class also pay for two things that are not unlocks: [saving a ship](player-outposts.md) in a ship bay, and a ship's join password. Setting or changing the password costs **3 parts**; clearing it is free.
From c7c479247f2b2191ddcc19dca15b6998af7633da Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 13:18:10 -0800
Subject: [PATCH 05/72] Close the ship save salvage and load holes found in
review
A second save or an in-round checkpoint no longer gives walls and floors their
salvage back, mineral airlocks come apart into plain assemblies, and unfinished
mineral airlock assemblies are not saved. The construction drone refunds a
stripped wall or floor as iron.
A room or turf type removed since a save is replaced in the map text with a
plain ship room or plating, so the tile still loads.
The lobby retrieval now runs on the save controller, so a player who leaves
during a long wait cannot stop it before the save is spent. It refuses under the
late join lag switch before starting, and reopens the menu if seating fails.
A failed save insert logs a short line instead of the whole map. The full-slots
text no longer says how many slots there are.
Adds an in-game database test for the save store, and tests for the above.
---
voidcrew/edits/mobs/ship_join_menu.dm | 2 +-
.../outpost_checkpoint_snapshot.dm | 7 +
.../modules/ship_saves/ship_save_capture.dm | 50 ++++-
.../modules/ship_saves/ship_save_console.dm | 2 +-
.../modules/ship_saves/ship_save_lobby.dm | 80 ++++---
.../modules/ship_saves/ship_save_store.dm | 5 +-
voidcrew/modules/ship_saves/ship_saves.dm | 2 +-
.../construction/construction_console.dm | 8 +
.../modules/unit_tests/voidcrew_ship_saves.dm | 205 +++++++++++++++++-
9 files changed, 313 insertions(+), 48 deletions(-)
diff --git a/voidcrew/edits/mobs/ship_join_menu.dm b/voidcrew/edits/mobs/ship_join_menu.dm
index 212e316befc..eeaab2ac7de 100644
--- a/voidcrew/edits/mobs/ship_join_menu.dm
+++ b/voidcrew/edits/mobs/ship_join_menu.dm
@@ -223,7 +223,7 @@
return FALSE
// Reading the save sleeps, and so does the shipyard's queue.
ui.close()
- INVOKE_ASYNC(user, TYPE_PROC_REF(/mob/dead/new_player, launch_saved_ship), text2num(params["slot"]))
+ INVOKE_ASYNC(GLOB.ship_saves, TYPE_PROC_REF(/datum/ship_saves, launch), user, text2num(params["slot"]))
if("requisition_hull")
// Re-checked in requisition_free_hull() too - the UI is never the authority
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
index 9c6100f1a36..8b4ab7ad871 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
@@ -168,6 +168,13 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
keys += list("duct_layer", "duct_color", "connects")
if(persistent)
saved_type = strip_salvage(object, saved_type, keys, properties)
+ else
+ // A wall or floor a saved ship brought in holds iron where its type holds more; keep it
+ // through an in-round checkpoint. Equal to the type's own value, it is left out as usual.
+ if(istype(object, /turf/closed/wall))
+ keys += "sheet_type"
+ if(istype(object, /turf/open/floor))
+ keys += "floor_tile"
for(var/key in keys)
var/value = object.vars[key]
// Always encode direction: an oriented source must survive a round trip.
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index d976eacfed9..3c620ac0048 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -30,6 +30,11 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
/proc/ship_save_refuses_object(obj/object)
if(!isstructure(object) || is_type_in_typecache(object, GLOB.ship_save_kept_structures))
return FALSE
+ // An unfinished mineral airlock holds its sheets outside custom_materials.
+ if(istype(object, /obj/structure/door_assembly))
+ var/obj/structure/door_assembly/assembly = object
+ if(assembly.mineral)
+ return TRUE
for(var/material_key in object.custom_materials)
var/datum/material/material = GET_MATERIAL_REF(material_key)
if(is_type_in_typecache(material, GLOB.ship_save_precious_materials))
@@ -71,7 +76,8 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
var/plain_type
if(istype(object, /turf/closed/wall))
var/turf/closed/wall/wall = object
- if(wall.sheet_type != /obj/item/stack/sheet/iron)
+ // The type's own salvage, not the wall's: one already stripped in an earlier round holds iron.
+ if(initial(wall.sheet_type) != /obj/item/stack/sheet/iron)
properties += "sheet_type = /obj/item/stack/sheet/iron"
else if(istype(object, /turf/open/floor/mineral))
properties += "floor_tile = /obj/item/stack/tile/iron"
@@ -79,6 +85,12 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
plain_type = /obj/structure/falsewall
else if(istype(object, /obj/structure/mineral_door))
plain_type = /obj/structure/mineral_door/iron
+ else if(istype(object, /obj/machinery/door/airlock))
+ // A mineral airlock keeps its look but comes apart into a plain assembly, with no sheets in it.
+ var/obj/machinery/door/airlock/airlock = object
+ var/obj/structure/door_assembly/assembly = airlock.assemblytype
+ if(assembly && initial(assembly.mineral))
+ properties += "assemblytype = /obj/structure/door_assembly"
if(!plain_type || plain_type == object.type)
return saved_type
// Not "gold door" on a plain iron door, or a state the plain type doesn't have.
@@ -165,17 +177,43 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
log_missing_types(snapshot)
return snapshot
-/// The map reader skips types removed since the save. Say which, so a missing fitting is explainable.
+/// What a removed room or turf becomes in a saved map.
+#define SHIP_SAVE_FALLBACK_AREA "/area/shuttle/voidcrew"
+#define SHIP_SAVE_FALLBACK_TURF "/turf/open/floor/plating"
+
+/**
+ * The map reader skips types removed since the save, which is fine for an object. A tile's turf and
+ * area it can't do without: it reads the next member in their place and the tile is lost. So a removed
+ * room becomes a plain ship room and a removed turf plain plating, and every removed type is logged,
+ * so a missing fitting is explainable.
+ */
/datum/ship_saves/proc/log_missing_types(datum/ship_checkpoint/snapshot)
var/list/seen = list()
var/list/missing = list()
- var/regex/atom_path = new(@"^/\w[\w/]*", "gm")
+ // A path starting a line, or the port's area_type.
+ var/regex/atom_path = new(@"^(\tarea_type = )?(/\w[\w/]*)", "gm")
while(atom_path.Find(snapshot.tgm))
- var/path_text = atom_path.match
+ var/path_text = atom_path.group[2]
if(!seen[path_text])
seen[path_text] = TRUE
if(!text2path(path_text))
missing += path_text
CHECK_TICK
- if(length(missing))
- log_game("Ship save [snapshot.ship_name] has types that no longer exist: [missing.Join(", ")]")
+ if(!length(missing))
+ return
+ log_game("Ship save [snapshot.ship_name] has types that no longer exist: [missing.Join(", ")]")
+ for(var/path_text in missing)
+ // Whole paths only: a delimiter, never more path, must follow, or /turf/a would match in /turf/a/b.
+ if(findtext(path_text, "/area/") == 1)
+ var/regex/gone_room = new(@"^" + path_text + @"([^\w/]|$)", "gm")
+ snapshot.tgm = gone_room.Replace(snapshot.tgm, SHIP_SAVE_FALLBACK_AREA + "$1")
+ var/regex/gone_port_room = new(@"^(\tarea_type = )" + path_text + @"([^\w/]|$)", "gm")
+ snapshot.tgm = gone_port_room.Replace(snapshot.tgm, "$1" + SHIP_SAVE_FALLBACK_AREA + "$2")
+ else if(findtext(path_text, "/turf/") == 1)
+ // What it was saved with belongs to the old type, and plating may not have it.
+ var/regex/gone_turf = new(@"^" + path_text + @"(\{[^}]*\})?([^\w/{]|$)", "gm")
+ snapshot.tgm = gone_turf.Replace(snapshot.tgm, SHIP_SAVE_FALLBACK_TURF + "$2")
+ CHECK_TICK
+
+#undef SHIP_SAVE_FALLBACK_AREA
+#undef SHIP_SAVE_FALLBACK_TURF
diff --git a/voidcrew/modules/ship_saves/ship_save_console.dm b/voidcrew/modules/ship_saves/ship_save_console.dm
index 5a413a4492f..5e9b72c0c51 100644
--- a/voidcrew/modules/ship_saves/ship_save_console.dm
+++ b/voidcrew/modules/ship_saves/ship_save_console.dm
@@ -15,7 +15,7 @@
if(!GLOB.ship_save_store.online() || isnull(GLOB.ship_saves.saves_for(user.ckey)))
return "Shipyard records are offline."
if(!GLOB.ship_saves.free_slot(user.ckey))
- return "All three slots are full."
+ return "All slots are full."
return null
/// The denial, plus whether the ship we captured is still the one in the bay.
diff --git a/voidcrew/modules/ship_saves/ship_save_lobby.dm b/voidcrew/modules/ship_saves/ship_save_lobby.dm
index d9cc3b1e403..b5e5da0d25c 100644
--- a/voidcrew/modules/ship_saves/ship_save_lobby.dm
+++ b/voidcrew/modules/ship_saves/ship_save_lobby.dm
@@ -31,61 +31,73 @@
blueprint = null
/**
- * Loads a saved ship and seats the player on it, the way buying a hull does. The save is spent once
- * the ship has loaded. Sleeps at the read and in the shipyard's queue; the player can leave during either.
+ * Loads a saved ship and seats the player on it, the way buying a hull does. The save is spent once the
+ * ship has loaded. Sleeps at the read and in the shipyard's queue, and the player can leave during either:
+ * this runs here rather than on their mob, which a logout deletes, so nothing stops it before the spend.
*/
-/mob/dead/new_player/proc/launch_saved_ship(slot)
+/datum/ship_saves/proc/launch(mob/dead/new_player/player, slot)
+ if(QDELETED(player) || !player.ckey)
+ return
if(!SSticker?.IsRoundInProgress())
- to_chat(src, span_danger("The round is either not ready, or has already finished..."))
+ to_chat(player, span_danger("The round is either not ready, or has already finished..."))
+ return
+ if(SSlag_switch.measures[DISABLE_NON_OBSJOBS])
+ alert(player, "An administrator has disabled late join spawning.")
+ if(!QDELETED(player))
+ player.select_ship()
return
// Prevent double-click spawning
- if(spawning_ship)
- to_chat(src, span_warning("Your ship is already being prepared. Please wait..."))
+ if(player.spawning_ship)
+ to_chat(player, span_warning("Your ship is already being prepared. Please wait..."))
return
- var/player_ckey = ckey
- var/denial = GLOB.ship_saves.retrieve_denial(player_ckey, slot)
+ var/player_ckey = player.ckey
+ var/denial = retrieve_denial(player_ckey, slot)
if(denial)
- to_chat(src, span_warning(denial))
- return select_ship()
- if(!GLOB.ship_saves.begin_retrieval(player_ckey, slot))
- to_chat(src, span_warning("Already under way."))
- return select_ship()
- spawning_ship = TRUE
+ to_chat(player, span_warning(denial))
+ return player.select_ship()
+ if(!begin_retrieval(player_ckey, slot))
+ to_chat(player, span_warning("Already under way."))
+ return player.select_ship()
+ player.spawning_ship = TRUE
- var/datum/ship_checkpoint/snapshot = GLOB.ship_saves.read_save(player_ckey, slot)
- if(istext(snapshot) || QDELETED(src))
- GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
- if(QDELETED(src))
+ var/datum/ship_checkpoint/snapshot = read_save(player_ckey, slot)
+ if(istext(snapshot) || QDELETED(player))
+ cancel_retrieval(player_ckey, slot)
+ if(QDELETED(player))
if(!istext(snapshot))
qdel(snapshot)
return
- spawning_ship = FALSE
- to_chat(src, span_warning(snapshot))
- return select_ship()
+ player.spawning_ship = FALSE
+ to_chat(player, span_warning(snapshot))
+ return player.select_ship()
- to_chat(src, span_notice("[snapshot.ship_name] is being prepared. Please be patient!"))
+ to_chat(player, span_notice("[snapshot.ship_name] is being prepared. Please be patient!"))
var/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/template = new(snapshot)
var/obj/structure/overmap/ship/target = SSshuttle.create_ship(template)
qdel(snapshot)
if(!istype(target))
qdel(template)
- GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
- if(QDELETED(src))
+ cancel_retrieval(player_ckey, slot)
+ if(QDELETED(player))
return
- spawning_ship = FALSE
+ player.spawning_ship = FALSE
// A refusal at the map-volume ceiling is transient, as in spawn_ship_with_upgrades().
if(SSmapping.at_z_level_ceiling())
- to_chat(src, span_warning("The shipyard is congested right now - hull assembly space frees up as ships move. Try again in a minute."))
+ to_chat(player, span_warning("The shipyard is congested right now - hull assembly space frees up as ships move. Try again in a minute."))
else
- to_chat(src, span_danger("There was an error loading the ship. Please contact admins!"))
- return select_ship()
+ to_chat(player, span_danger("There was an error loading the ship. Please contact admins!"))
+ return player.select_ship()
- // The ship exists now, whether or not its captain stayed to board it.
- GLOB.ship_saves.spend(player_ckey, slot)
+ // The ship exists now, whether or not its captain stayed to board it. Left, they can join it from the menu.
+ spend(player_ckey, slot)
log_game("[player_ckey] launched [target.name] from ship save slot [slot].")
target.password_cleared_ckeys[player_ckey] = TRUE
- if(QDELETED(src))
+ if(QDELETED(player))
return
- ship_metric_purchased(target, src)
- if(!AttemptSpawnOnShip(target.job_slots[1], target))
- to_chat(src, span_danger("Ship spawned, but you were unable to be spawned. You can likely try to spawn in the ship through joining normally, but if not, please contact an admin."))
+ ship_metric_purchased(target, player)
+ if(!player.AttemptSpawnOnShip(target.job_slots[1], target))
+ if(QDELETED(player))
+ return
+ to_chat(player, span_danger("Ship spawned, but you were unable to be spawned. You can likely try to spawn in the ship through joining normally, but if not, please contact an admin."))
+ player.spawning_ship = FALSE
+ player.select_ship()
diff --git a/voidcrew/modules/ship_saves/ship_save_store.dm b/voidcrew/modules/ship_saves/ship_save_store.dm
index 331ae40db59..f1c67c9af6c 100644
--- a/voidcrew/modules/ship_saves/ship_save_store.dm
+++ b/voidcrew/modules/ship_saves/ship_save_store.dm
@@ -69,7 +69,10 @@ GLOBAL_DATUM_INIT(ship_save_store, /datum/ship_save_store, new)
"payload" = payload,
)
)
- var/success = query.Execute()
+ // A failed insert would log all its arguments, and one of them is the whole map.
+ var/success = query.Execute(log_error = FALSE)
+ if(!success)
+ log_game("Ship save [ckey] slot [slot] could not be written: [copytext(query.ErrorMsg(), 1, 300)]")
qdel(query)
return success
diff --git a/voidcrew/modules/ship_saves/ship_saves.dm b/voidcrew/modules/ship_saves/ship_saves.dm
index 1dd3909b4ea..d59ea5f4025 100644
--- a/voidcrew/modules/ship_saves/ship_saves.dm
+++ b/voidcrew/modules/ship_saves/ship_saves.dm
@@ -68,7 +68,7 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
ckey = ckey(ckey)
var/slot = free_slot(ckey)
if(!slot)
- return "All three slots are full."
+ return "All slots are full."
var/tiles = snapshot.owned_tile_count()
var/price = price_for(tiles)
var/name = snapshot.ship_name
diff --git a/voidcrew/modules/shuttle/construction/construction_console.dm b/voidcrew/modules/shuttle/construction/construction_console.dm
index 4b6f51321bb..b4834593448 100644
--- a/voidcrew/modules/shuttle/construction/construction_console.dm
+++ b/voidcrew/modules/shuttle/construction/construction_console.dm
@@ -277,12 +277,20 @@ GLOBAL_LIST_INIT(ship_rcd_hull_designs, list(
/// Standard RCD salvage is capped at the cheapest rebuild, including RCD memory discounts.
/obj/item/construction/rcd/internal/ship/proc/get_deconstruction_materials(atom/target)
if(iswallturf(target))
+ // A wall that comes apart into iron (a retrieved saved ship) refunds as one, whatever its type.
+ var/turf/closed/wall/target_wall = target
+ if(target_wall.sheet_type == /obj/item/stack/sheet/iron)
+ return wall_types["Iron Wall"]["materials"].Copy()
// Check the specific mineral types before the base iron wall.
for(var/wall_name in list("Plastitanium Wall", "Titanium Wall", "Iron Wall"))
var/list/wall_info = wall_types[wall_name]
if(istype(target, wall_info["path"]))
return wall_info["materials"].Copy()
if(isfloorturf(target))
+ // The same for a floor that gives iron tiles.
+ var/turf/open/floor/target_floor = target
+ if(target_floor.floor_tile == /obj/item/stack/tile/iron)
+ return list(/datum/material/iron = SHIP_RTD_TILE_IRON)
for(var/floor_name in list("Plastitanium Floor", "Titanium Floor"))
var/list/floor_info = floor_types[floor_name]
if(istype(target, floor_info["path"]))
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index c275592f15c..252996c3468 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -4,6 +4,7 @@
#define SHIP_SAVE_TEST_PRICE_MAX 15 // SHIP_SAVE_PRICE_MAX
#define SHIP_SAVE_TEST_PASSWORD_PRICE 3 // SHIP_PASSWORD_PRICE
#define SHIP_SAVE_TEST_FORMAT 1 // SHIP_SAVE_FORMAT
+#define SHIP_SAVE_TEST_TILE_IRON 100 // SHIP_RTD_TILE_IRON
/// GLOB.round_id for the length of these tests. It is null on a world with no database.
#define SHIP_SAVE_TEST_ROUND 777
/// One more than RESERVE_DOCK_MAX_SIZE_LONG.
@@ -131,8 +132,10 @@
/datum/unit_test/voidcrew_ship_saves/Run()
check_round_trip()
check_stale_types()
+ check_stale_map()
check_rejected_payloads()
check_salvage()
+ check_second_generation()
check_saved_vars()
check_prices()
check_part_plans()
@@ -175,6 +178,17 @@
TEST_ASSERT_NULL(decoded.jobs, "With no jobs left the commissioned default should apply")
qdel(decoded)
+/// A removed area or turf on a tile would lose the tile, so decoding swaps in plain ones, whole paths only.
+/datum/unit_test/voidcrew_ship_saves/proc/check_stale_map()
+ var/datum/ship_checkpoint/snapshot = make_snapshot()
+ // "plati" is a turf path that is missing, and the start of one that isn't.
+ snapshot.tgm = "//[DMM2TGM_MESSAGE]\n\"aaa\" = (\n/obj/docking_port/mobile/voidcrew{\n\tarea_type = /area/shuttle/voidcrew/this_was_removed_long_ago;\n\tdir = 1\n\t},\n/turf/open/floor/plati{\n\tdir = 2;\n\tsheet_type = /obj/item/stack/sheet/iron\n\t},\n/area/shuttle/voidcrew/this_was_removed_long_ago)\n\"aab\" = (\n/turf/open/floor/plating,\n/area/shuttle/voidcrew)\n\n(1,1,1) = {\"\naaa\naab\n\"}"
+ var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(GLOB.ship_saves.encode(snapshot))
+ TEST_ASSERT_NOTNULL(decoded, "A map with removed types did not decode")
+ var/expected = "//[DMM2TGM_MESSAGE]\n\"aaa\" = (\n/obj/docking_port/mobile/voidcrew{\n\tarea_type = /area/shuttle/voidcrew;\n\tdir = 1\n\t},\n/turf/open/floor/plating,\n/area/shuttle/voidcrew)\n\"aab\" = (\n/turf/open/floor/plating,\n/area/shuttle/voidcrew)\n\n(1,1,1) = {\"\naaa\naab\n\"}"
+ TEST_ASSERT(decoded.tgm == expected, "Removed types were not replaced in the map text: [decoded.tgm]")
+ qdel(decoded)
+
/datum/unit_test/voidcrew_ship_saves/proc/check_rejected_payloads()
var/datum/ship_checkpoint/snapshot = make_snapshot()
TEST_ASSERT_NULL(GLOB.ship_saves.decode("not json"), "Junk text decoded")
@@ -260,6 +274,70 @@
wall_tile.ChangeTurf(wall_tile_type)
copy_tile.ChangeTurf(copy_tile_type)
+/// A hull that was saved once, loaded and saved again must not get its salvage back, nor an in-round checkpoint of it.
+/datum/unit_test/voidcrew_ship_saves/proc/check_second_generation()
+ var/datum/ship_checkpoint/saved = make_snapshot()
+ var/datum/ship_checkpoint/in_round = allocate(/datum/ship_checkpoint)
+ var/turf/tile = run_loc_floor_bottom_left
+ var/tile_type = tile.type
+ var/obj/item/construction/rcd/internal/ship/rcd = allocate(/obj/item/construction/rcd/internal/ship)
+
+ tile.ChangeTurf(/turf/closed/wall/mineral/diamond)
+ var/turf/closed/wall/wall = tile
+ wall.sheet_type = /obj/item/stack/sheet/iron
+ TEST_ASSERT(findtext(saved.atom_text(wall), "sheet_type = /obj/item/stack/sheet/iron"), "A wall saved a second time got its salvage back")
+ TEST_ASSERT(findtext(in_round.atom_text(wall), "sheet_type = /obj/item/stack/sheet/iron"), "An in-round checkpoint gave a stripped wall its salvage back")
+ // A drone refunds a stripped wall as the iron wall it comes apart into, and a real mineral wall as before.
+ var/list/refund = rcd.get_deconstruction_materials(wall)
+ TEST_ASSERT_EQUAL(refund[/datum/material/iron], 200, "A stripped diamond wall was not refunded as an iron wall")
+ TEST_ASSERT_EQUAL(length(refund), 1, "A stripped diamond wall was refunded more than iron")
+ tile.ChangeTurf(/turf/closed/wall/mineral/plastitanium)
+ refund = rcd.get_deconstruction_materials(tile)
+ TEST_ASSERT_EQUAL(refund[/datum/material/plasma], 100, "A real plastitanium wall stopped refunding plasma")
+ wall = tile
+ wall.sheet_type = /obj/item/stack/sheet/iron
+ refund = rcd.get_deconstruction_materials(tile)
+ TEST_ASSERT_EQUAL(refund[/datum/material/iron], 200, "A stripped plastitanium wall was not refunded as an iron wall")
+ TEST_ASSERT_NULL(refund[/datum/material/plasma], "A stripped plastitanium wall was refunded plasma")
+ tile.ChangeTurf(/turf/closed/wall)
+ TEST_ASSERT_EQUAL(rcd.get_deconstruction_materials(tile)[/datum/material/iron], 200, "A real iron wall stopped refunding iron")
+
+ tile.ChangeTurf(/turf/open/floor/mineral/plastitanium)
+ var/turf/open/floor/floor = tile
+ refund = rcd.get_deconstruction_materials(tile)
+ TEST_ASSERT_EQUAL(refund[/datum/material/plasma], 25, "A real plastitanium floor stopped refunding plasma")
+ floor.floor_tile = /obj/item/stack/tile/iron
+ TEST_ASSERT(findtext(saved.atom_text(floor), "floor_tile = /obj/item/stack/tile/iron"), "A floor saved a second time got its tile back")
+ TEST_ASSERT(findtext(in_round.atom_text(floor), "floor_tile = /obj/item/stack/tile/iron"), "An in-round checkpoint gave a stripped floor its tile back")
+ refund = rcd.get_deconstruction_materials(tile)
+ TEST_ASSERT_EQUAL(refund[/datum/material/iron], SHIP_SAVE_TEST_TILE_IRON, "A stripped plastitanium floor was not refunded as an iron tile")
+ TEST_ASSERT_NULL(refund[/datum/material/plasma], "A stripped plastitanium floor was refunded plasma")
+ tile.ChangeTurf(/turf/open/floor/iron)
+ TEST_ASSERT_EQUAL(rcd.get_deconstruction_materials(tile)[/datum/material/iron], SHIP_SAVE_TEST_TILE_IRON, "A real iron floor stopped refunding iron")
+ tile.ChangeTurf(tile_type)
+
+ // A mineral airlock keeps its look, and an unfinished one is refused.
+ var/obj/machinery/door/airlock/gold/gold_airlock = allocate(/obj/machinery/door/airlock/gold)
+ var/airlock_text = saved.atom_text(gold_airlock)
+ TEST_ASSERT(findtext(airlock_text, "assemblytype = /obj/structure/door_assembly;") || findtext(airlock_text, "assemblytype = /obj/structure/door_assembly\n"), "A gold airlock kept its gold assembly: [airlock_text]")
+ TEST_ASSERT(!findtext(airlock_text, "door_assembly_gold"), "A gold airlock was saved with a gold assembly: [airlock_text]")
+ TEST_ASSERT(!findtext(in_round.atom_text(gold_airlock), "assemblytype"), "An in-round checkpoint changed an airlock's assembly")
+ TEST_ASSERT(!findtext(saved.atom_text(allocate(/obj/machinery/door/airlock)), "assemblytype"), "A plain airlock was given another assembly")
+ TEST_ASSERT(ship_save_refuses_object(allocate(/obj/structure/door_assembly/door_assembly_gold)), "A gold airlock assembly was saved")
+ TEST_ASSERT(!ship_save_refuses_object(allocate(/obj/structure/door_assembly)), "A plain airlock assembly was refused")
+ // The plain assembly it falls back to comes apart into iron alone.
+ var/obj/machinery/door/airlock/gold/breaking = new(tile)
+ breaking.assemblytype = /obj/structure/door_assembly
+ breaking.deconstruct(TRUE)
+ var/obj/structure/door_assembly/dropped = locate() in tile
+ TEST_ASSERT_NOTNULL(dropped, "A gold airlock with a plain assembly left nothing behind")
+ TEST_ASSERT_EQUAL(dropped?.type, /obj/structure/door_assembly, "The airlock did not fall back to a plain assembly")
+ dropped?.deconstruct(TRUE)
+ TEST_ASSERT_NOTNULL(locate(/obj/item/stack/sheet/iron) in tile, "A plain assembly dropped no iron")
+ TEST_ASSERT_NULL(locate(/obj/item/stack/sheet/mineral/gold) in tile, "A plain assembly dropped gold")
+ for(var/obj/loose in tile)
+ qdel(loose)
+
/// Loading a map with a var its type doesn't have throws, so each one a save writes must exist.
/datum/unit_test/voidcrew_ship_saves/proc/check_saved_vars()
var/list/saved_vars = list(
@@ -350,7 +428,7 @@
// Slots full: refused before any charge.
var/parts_before = economy.total()
- TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null), "All three slots are full.", "A fourth save was not refused")
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null), "All slots are full.", "A fourth save was not refused")
TEST_ASSERT_EQUAL(economy.total(), parts_before, "A refused save was charged")
// A paid save is never overwritten: a write to a taken slot fails, and refunds.
@@ -551,7 +629,7 @@
var/list/rows = GLOB.ship_saves.saves_for(captain.ckey)
for(var/slot in 2 to SHIP_SAVE_TEST_SLOTS)
rows += list(list("slot" = slot, "ship_name" = "Filler", "ship_class" = null))
- TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "All three slots are full.", "Full slots were not refused")
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "All slots are full.", "Full slots were not refused")
GLOB.ship_saves.cache -= captain.ckey
TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "Shipyard records are offline.", "An unloaded player read as having slots")
@@ -727,7 +805,7 @@
var/mob/dead/new_player/ship_save_test/lobby = allocate(__IMPLIED_TYPE__)
lobby.key = "savelobby"
store.fail_reads = TRUE
- lobby.launch_saved_ship(1)
+ GLOB.ship_saves.launch(lobby, 1)
store.fail_reads = FALSE
TEST_ASSERT_NULL(lobby.seated_on, "A failed read seated the player on something")
TEST_ASSERT(!lobby.spawning_ship, "A failed read left the player stuck preparing a ship")
@@ -735,7 +813,7 @@
TEST_ASSERT_NULL(GLOB.ship_saves.retrieve_denial("savelobby", 1), "A failed read kept its claim on the save")
// The lobby: the ship loads, the save goes, and the player is seated as the first job.
- lobby.launch_saved_ship(1)
+ GLOB.ship_saves.launch(lobby, 1)
var/obj/structure/overmap/ship/launched = lobby.seated_on
TEST_ASSERT_NOTNULL(launched, "The lobby did not seat the player on the saved ship")
retrieved_ships += launched
@@ -746,6 +824,27 @@
TEST_ASSERT_EQUAL(GLOB.ship_saves.free_slot("savelobby"), 1, "The lobby did not free the slot")
check_retrieved(launched, "Lobby")
+ // The same save after its room and floor types were removed from the code still loads whole.
+ var/list/stale_data = json_decode(first["payload"])
+ var/stale_tgm = stale_data["tgm"]
+ var/regex/floor_path = new(@"^/turf/open/floor/[\w/]*", "gm")
+ TEST_ASSERT(floor_path.Find(stale_tgm), "The saved hull has no floor to remove")
+ var/gone_floor = floor_path.match
+ var/regex/room_path = new(@"^/area/shuttle/voidcrew[\w/]*", "gm")
+ TEST_ASSERT(room_path.Find(stale_tgm), "The saved hull has no room to remove")
+ var/gone_room = room_path.match
+ stale_data["tgm"] = replacetext(replacetext(stale_tgm, gone_floor, "/turf/this_was_removed_long_ago"), gone_room, "/area/shuttle/voidcrew/this_was_removed_long_ago")
+ store.saved["savestale|1"] = list("meta" = first_meta.Copy(), "payload" = json_encode(stale_data))
+ GLOB.ship_saves.refresh("savestale")
+ var/mob/dead/new_player/ship_save_test/stale_lobby = allocate(__IMPLIED_TYPE__)
+ stale_lobby.key = "savestale"
+ GLOB.ship_saves.launch(stale_lobby, 1)
+ var/obj/structure/overmap/ship/stale_ship = stale_lobby.seated_on
+ TEST_ASSERT_NOTNULL(stale_ship, "A save with removed types did not load")
+ retrieved_ships += stale_ship
+ TEST_ASSERT_NULL(store.saved["savestale|1"], "A save with removed types was not spent")
+ TEST_ASSERT_EQUAL(hull_owned_tile_count(stale_ship.shuttle), first_meta["tile_count"], "A save with removed types lost tiles")
+
/// What a player could try to carry between rounds: an upgraded machine, a tank of plasma beside a tank of air.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/fit_source(obj/docking_port/mobile/voidcrew/port)
var/list/fittings = list(/obj/machinery/autolathe, /obj/machinery/atmospherics/components/tank/plasma, /obj/machinery/atmospherics/components/tank/air)
@@ -822,6 +921,103 @@
TEST_ASSERT(plasma_tanks, "[where]: the plasma tank did not come back")
TEST_ASSERT(air_tanks, "[where]: the air tank did not come back")
+/**
+ * The real table, through the real store and the game's database connection. Skipped with no database.
+ * Everything it writes belongs to one test ckey, and is deleted before and after.
+ */
+/datum/unit_test/voidcrew_ship_saves_sql
+ var/test_ckey = "shipsavesqltest"
+ var/obj/structure/overmap/ship/test_ship
+
+/datum/unit_test/voidcrew_ship_saves_sql/Destroy()
+ if(!QDELETED(test_ship))
+ qdel(test_ship)
+ if(SSdbcore.IsConnected())
+ wipe()
+ return ..()
+
+/// Deletes every row the test ckey owns.
+/datum/unit_test/voidcrew_ship_saves_sql/proc/wipe()
+ var/datum/db_query/query = SSdbcore.NewQuery(
+ "DELETE FROM [format_table_name("player_ship_saves")] WHERE ckey = :ckey",
+ list("ckey" = test_ckey)
+ )
+ query.Execute()
+ qdel(query)
+
+/// The size the database holds a row's map at, or null.
+/datum/unit_test/voidcrew_ship_saves_sql/proc/stored_size(slot)
+ var/datum/db_query/query = SSdbcore.NewQuery(
+ "SELECT LENGTH(payload) FROM [format_table_name("player_ship_saves")] WHERE ckey = :ckey AND slot = :slot",
+ list("ckey" = test_ckey, "slot" = slot)
+ )
+ var/size
+ if(query.Execute() && query.NextRow())
+ size = text2num(query.item[1])
+ qdel(query)
+ return size
+
+/datum/unit_test/voidcrew_ship_saves_sql/Run()
+ if(!SSdbcore.IsConnected())
+ log_test("Ship saves SQL test skipped: no database connection.")
+ return
+ wipe()
+ var/datum/ship_save_store/store = new
+ test_ship = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
+ TEST_ASSERT_NOTNULL(test_ship, "Could not create the hull")
+ var/datum/ship_checkpoint/snapshot = allocate(/datum/ship_checkpoint)
+ snapshot.persistent = TRUE
+ var/denial = snapshot.capture_hull(test_ship, test_ckey)
+ TEST_ASSERT_NULL(denial, "The fixture hull was not captured: [denial]")
+ var/payload = GLOB.ship_saves.encode(snapshot)
+ var/list/meta = list(
+ "ship_name" = snapshot.ship_name,
+ "ship_class" = snapshot.class_name,
+ "tile_count" = snapshot.owned_tile_count(),
+ "format_version" = SHIP_SAVE_TEST_FORMAT,
+ "saved_round" = SHIP_SAVE_TEST_ROUND,
+ "raw_bytes" = length(payload),
+ )
+
+ // A real save comes back exactly, and decodes.
+ TEST_ASSERT(store.write(test_ckey, 1, meta, payload), "A save was not written")
+ var/read_back = store.read_payload(test_ckey, 1)
+ TEST_ASSERT(read_back == payload, "A saved hull did not read back the same: [length(read_back)] bytes against [length(payload)]")
+ var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(read_back)
+ TEST_ASSERT_NOTNULL(decoded, "A saved hull read back from the table did not decode")
+ TEST_ASSERT_EQUAL(decoded.ship_name, snapshot.ship_name, "The read back hull lost its name")
+ TEST_ASSERT_EQUAL(decoded.width, snapshot.width, "The read back hull lost its width")
+ TEST_ASSERT_EQUAL(decoded.height, snapshot.height, "The read back hull lost its height")
+ qdel(decoded)
+ var/stored = stored_size(1)
+ log_test("Ship saves SQL: the fixture hull is [meta["raw_bytes"]] bytes of text, stored as [stored] bytes in the table.")
+
+ // Quotes, backslashes and text that is not ASCII survive the round trip.
+ var/awkward = "it's a \"quoted\" back\\slash, 'single' and \\\" both, [ascii2text(228)][ascii2text(26085)] and 100% done; DROP TABLE x;"
+ TEST_ASSERT(store.write(test_ckey, 2, list("ship_name" = "Awkward", "ship_class" = null, "tile_count" = 1, "format_version" = SHIP_SAVE_TEST_FORMAT, "saved_round" = SHIP_SAVE_TEST_ROUND, "raw_bytes" = length(awkward)), awkward), "An awkward save was not written")
+ TEST_ASSERT(store.read_payload(test_ckey, 2) == awkward, "Quotes, backslashes or non-ASCII text changed in the table")
+
+ // The row is there, a second write to the slot fails and leaves the first.
+ var/list/rows = store.read_meta(test_ckey)
+ TEST_ASSERT_EQUAL(length(rows), 2, "The table does not hold both saves")
+ var/list/first_row = rows[1]
+ TEST_ASSERT_EQUAL(first_row["slot"], 1, "The first row is not slot 1")
+ TEST_ASSERT_EQUAL(first_row["ship_name"], snapshot.ship_name, "The row has the wrong name")
+ TEST_ASSERT_EQUAL(first_row["tile_count"], meta["tile_count"], "The row has the wrong tile count")
+ TEST_ASSERT_EQUAL(first_row["format_version"], SHIP_SAVE_TEST_FORMAT, "The row has the wrong format")
+ TEST_ASSERT_EQUAL(first_row["saved_round"], SHIP_SAVE_TEST_ROUND, "The row has the wrong round")
+ TEST_ASSERT_EQUAL(first_row["raw_bytes"], meta["raw_bytes"], "The row has the wrong size")
+ TEST_ASSERT(!store.write(test_ckey, 1, meta, "another hull"), "A taken slot was overwritten")
+ TEST_ASSERT(store.read_payload(test_ckey, 1) == payload, "A refused write changed the saved hull")
+
+ // Using a save removes it once.
+ TEST_ASSERT(store.remove(test_ckey, 1), "A save was not removed")
+ TEST_ASSERT(!store.remove(test_ckey, 1), "A save was removed twice")
+ TEST_ASSERT_NULL(store.read_payload(test_ckey, 1), "A removed save still read back")
+ TEST_ASSERT(store.remove(test_ckey, 2), "The awkward save was not removed")
+ TEST_ASSERT_EQUAL(length(store.read_meta(test_ckey)), 0, "Rows were left behind")
+ qdel(store)
+
#undef SHIP_SAVE_TEST_ROUND
#undef SHIP_SAVE_TEST_FORMAT
#undef SHIP_SAVE_TEST_GONE_PATH
@@ -830,3 +1026,4 @@
#undef SHIP_SAVE_TEST_PRICE_MAX
#undef SHIP_SAVE_TEST_PASSWORD_PRICE
#undef SHIP_SAVE_TEST_TOO_LONG
+#undef SHIP_SAVE_TEST_TILE_IRON
From 67d3af876e243287c3f8d6817dac79d8be0a3c66 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 13:21:13 -0800
Subject: [PATCH 06/72] Stop saved machines handing back precious stacks from
their boards
---
.../outpost_checkpoint_machinery.dm | 1 +
.../modules/ship_saves/ship_save_capture.dm | 16 +++++++++++++++
.../modules/unit_tests/voidcrew_ship_saves.dm | 20 +++++++++++++++++++
3 files changed, 37 insertions(+)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
index 5d6c8ecd7da..df5888d2868 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
@@ -108,6 +108,7 @@
for(var/obj/item/part in machine.component_parts)
if(part != board)
checkpoint_scrub_part(part)
+ ship_save_strip_board_salvage(board)
machine.RefreshParts()
if(istype(machine, /obj/machinery/vending))
var/obj/machinery/vending/vendor = machine
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index 3c620ac0048..af36c8ea2cc 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -26,6 +26,22 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
/datum/material/zaukerite,
)))
+/**
+ * Taking a machine apart hands back the stacks its board asks for, whether or not the machine holds any
+ * (/obj/machinery/handle_deconstruct()). This machine's own board stops asking for precious ones, so a hull full of RTGs
+ * is not a uranium store. The machine still runs: stacks are never in component_parts.
+ */
+/proc/ship_save_strip_board_salvage(obj/item/circuitboard/machine/board)
+ if(!istype(board))
+ return
+ for(var/obj/item/stack/stack_path as anything in board.req_components?.Copy())
+ if(!ispath(stack_path, /obj/item/stack))
+ continue
+ var/material_type = initial(stack_path.material_type)
+ // Boards ask for the raw crystal, which is the one precious stack with no material_type.
+ if(ispath(stack_path, /obj/item/stack/ore/bluespace_crystal) || (material_type && GLOB.ship_save_precious_materials[material_type]))
+ board.req_components -= stack_path
+
/// Build a statue of gold and save it for two parts, and you could break it down next round.
/proc/ship_save_refuses_object(obj/object)
if(!isstructure(object) || is_type_in_typecache(object, GLOB.ship_save_kept_structures))
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 252996c3468..622f4051c44 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -136,6 +136,7 @@
check_rejected_payloads()
check_salvage()
check_second_generation()
+ check_board_salvage()
check_saved_vars()
check_prices()
check_part_plans()
@@ -338,6 +339,25 @@
for(var/obj/loose in tile)
qdel(loose)
+/// A saved RTG still makes power, and taking it apart hands back no uranium. An ordinary one still does.
+/datum/unit_test/voidcrew_ship_saves/proc/check_board_salvage()
+ var/turf/tile = run_loc_floor_bottom_left
+ for(var/obj/loose in tile)
+ qdel(loose)
+ var/obj/machinery/power/rtg/saved = new(tile)
+ checkpoint_restore_machine(saved, null)
+ TEST_ASSERT(saved.power_gen > 0, "A saved RTG makes no power")
+ saved.deconstruct(TRUE)
+ TEST_ASSERT_NULL(locate(/obj/item/stack/sheet/mineral/uranium) in tile, "A saved RTG handed back uranium")
+ TEST_ASSERT_NOTNULL(locate(/obj/item/stack/cable_coil) in tile, "A saved RTG stopped handing back its ordinary stacks")
+ for(var/obj/loose in tile)
+ qdel(loose)
+ var/obj/machinery/power/rtg/ordinary = new(tile)
+ ordinary.deconstruct(TRUE)
+ TEST_ASSERT_NOTNULL(locate(/obj/item/stack/sheet/mineral/uranium) in tile, "An ordinary RTG stopped handing back its uranium")
+ for(var/obj/loose in tile)
+ qdel(loose)
+
/// Loading a map with a var its type doesn't have throws, so each one a save writes must exist.
/datum/unit_test/voidcrew_ship_saves/proc/check_saved_vars()
var/list/saved_vars = list(
From d4f355dda48315b03b0e8a84dfd407aa521b2331 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 13:35:36 -0800
Subject: [PATCH 07/72] Replace removed turfs across their whole property block
---
.../modules/ship_saves/ship_save_capture.dm | 3 ++-
.../modules/unit_tests/voidcrew_ship_saves.dm | 20 ++++++++++++++-----
2 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index af36c8ea2cc..d4e95fd011b 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -227,7 +227,8 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
snapshot.tgm = gone_port_room.Replace(snapshot.tgm, "$1" + SHIP_SAVE_FALLBACK_AREA + "$2")
else if(findtext(path_text, "/turf/") == 1)
// What it was saved with belongs to the old type, and plating may not have it.
- var/regex/gone_turf = new(@"^" + path_text + @"(\{[^}]*\})?([^\w/{]|$)", "gm")
+ // The block runs over several lines, and a negated class alone does not cross them here.
+ var/regex/gone_turf = new(@"^" + path_text + @"(\{(?:[^}]|\n)*\})?([^\w/{]|$)", "gm")
snapshot.tgm = gone_turf.Replace(snapshot.tgm, SHIP_SAVE_FALLBACK_TURF + "$2")
CHECK_TICK
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 622f4051c44..7292fb3044b 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -326,7 +326,9 @@
TEST_ASSERT(!findtext(saved.atom_text(allocate(/obj/machinery/door/airlock)), "assemblytype"), "A plain airlock was given another assembly")
TEST_ASSERT(ship_save_refuses_object(allocate(/obj/structure/door_assembly/door_assembly_gold)), "A gold airlock assembly was saved")
TEST_ASSERT(!ship_save_refuses_object(allocate(/obj/structure/door_assembly)), "A plain airlock assembly was refused")
- // The plain assembly it falls back to comes apart into iron alone.
+ // The plain assembly it falls back to comes apart into iron alone. The assemblies above share this tile.
+ for(var/obj/structure/door_assembly/leftover in tile)
+ qdel(leftover)
var/obj/machinery/door/airlock/gold/breaking = new(tile)
breaking.assemblytype = /obj/structure/door_assembly
breaking.deconstruct(TRUE)
@@ -336,27 +338,35 @@
dropped?.deconstruct(TRUE)
TEST_ASSERT_NOTNULL(locate(/obj/item/stack/sheet/iron) in tile, "A plain assembly dropped no iron")
TEST_ASSERT_NULL(locate(/obj/item/stack/sheet/mineral/gold) in tile, "A plain assembly dropped gold")
+ // Not effects: the test room's own marker is on this tile, and every later test finds the room by it.
for(var/obj/loose in tile)
- qdel(loose)
+ if(!iseffect(loose))
+ qdel(loose)
/// A saved RTG still makes power, and taking it apart hands back no uranium. An ordinary one still does.
/datum/unit_test/voidcrew_ship_saves/proc/check_board_salvage()
var/turf/tile = run_loc_floor_bottom_left
+ // Not effects: the test room's own marker is on this tile, and every later test finds the room by it.
for(var/obj/loose in tile)
- qdel(loose)
+ if(!iseffect(loose))
+ qdel(loose)
var/obj/machinery/power/rtg/saved = new(tile)
checkpoint_restore_machine(saved, null)
TEST_ASSERT(saved.power_gen > 0, "A saved RTG makes no power")
saved.deconstruct(TRUE)
TEST_ASSERT_NULL(locate(/obj/item/stack/sheet/mineral/uranium) in tile, "A saved RTG handed back uranium")
TEST_ASSERT_NOTNULL(locate(/obj/item/stack/cable_coil) in tile, "A saved RTG stopped handing back its ordinary stacks")
+ // Not effects: the test room's own marker is on this tile, and every later test finds the room by it.
for(var/obj/loose in tile)
- qdel(loose)
+ if(!iseffect(loose))
+ qdel(loose)
var/obj/machinery/power/rtg/ordinary = new(tile)
ordinary.deconstruct(TRUE)
TEST_ASSERT_NOTNULL(locate(/obj/item/stack/sheet/mineral/uranium) in tile, "An ordinary RTG stopped handing back its uranium")
+ // Not effects: the test room's own marker is on this tile, and every later test finds the room by it.
for(var/obj/loose in tile)
- qdel(loose)
+ if(!iseffect(loose))
+ qdel(loose)
/// Loading a map with a var its type doesn't have throws, so each one a save writes must exist.
/datum/unit_test/voidcrew_ship_saves/proc/check_saved_vars()
From 27e1e24b2c4545255e04253c9bece6e30ab5b0e4 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 17:29:53 -0800
Subject: [PATCH 08/72] Show saved ships as slot cards with an itemised price
---
.../interfaces/SavedShipCards.tsx | 294 +++++++++++++
.../interfaces/ShipCheckpoint.tsx | 407 +++++++++++-------
.../voidcrew_tgui/interfaces/ShipJoinMenu.tsx | 60 ++-
3 files changed, 576 insertions(+), 185 deletions(-)
create mode 100644 tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
diff --git a/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx b/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
new file mode 100644
index 00000000000..c49dcf13645
--- /dev/null
+++ b/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
@@ -0,0 +1,294 @@
+import type { CSSProperties, ReactNode } from 'react';
+import { useMemo } from 'react';
+import { Box } from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+
+export type PartClass = 'combat' | 'science' | 'trade' | 'misc';
+
+export type BillLine = {
+ name: string;
+ count: number | null;
+ parts: number;
+};
+
+export type BillClass = {
+ class: PartClass;
+ parts: number;
+ items: BillLine[];
+};
+
+/** What a hull holds. A save written before this existed has none of it. */
+export type ShipSurvey = {
+ mass?: number;
+ crew?: number;
+ plan?: string | null;
+ bill?: BillClass[];
+ total?: number;
+};
+
+export type SavedShip = ShipSurvey & {
+ slot: number;
+ name: string;
+ class: string | null;
+ denial: string | null;
+ locked?: BooleanLike;
+};
+
+/** The colours of the physical parts. */
+export const PART_CLASSES: {
+ id: PartClass;
+ label: string;
+ color: string;
+}[] = [
+ { id: 'combat', label: 'combat', color: '#e0605a' },
+ { id: 'science', label: 'science', color: '#5aa7e8' },
+ { id: 'trade', label: 'trade', color: '#e0b64a' },
+ { id: 'misc', label: 'misc', color: '#a9b4bd' },
+];
+
+const partColor = (id: PartClass) =>
+ PART_CLASSES.find((entry) => entry.id === id)?.color;
+
+const PLAN_LAYERS: { char: string; color: string }[] = [
+ { char: 'o', color: '#274a60' },
+ { char: 'g', color: '#4f8fb0' },
+ { char: '#', color: '#86c5e4' },
+ { char: 'e', color: '#d98f4e' },
+ { char: 't', color: partColor('trade')! },
+ { char: 's', color: partColor('science')! },
+ { char: 'w', color: partColor('combat')! },
+];
+
+/** Rows of runs from ship_save_survey(): "5.3#2o/..." is five empty, three wall, two floor. */
+const readPlan = (plan: string) => {
+ const paths: Record = {};
+ const rows = plan.split('/');
+ let width = 0;
+ rows.forEach((row, y) => {
+ let x = 0;
+ for (const [, count, char] of row.matchAll(/(\d*)(\D)/g)) {
+ const run = count ? Number(count) : 1;
+ if (char !== '.') {
+ paths[char] = `${paths[char] || ''}M${x} ${y}h${run}v1h-${run}z`;
+ }
+ x += run;
+ }
+ width = Math.max(width, x);
+ });
+ return { paths, width, height: rows.length };
+};
+
+const GRID: CSSProperties = {
+ backgroundColor: '#0a1520',
+ backgroundImage:
+ 'linear-gradient(rgba(134,197,228,0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(134,197,228,0.07) 1px, transparent 1px)',
+ backgroundSize: '12px 12px',
+ backgroundPosition: 'center',
+};
+
+/** The hull from above, as the yard's drawing of it. */
+export const HullPlan = (props: { plan?: string | null; height: number }) => {
+ const { plan, height } = props;
+ const drawing = useMemo(() => (plan ? readPlan(plan) : null), [plan]);
+ return (
+
- )}
);
diff --git a/voidcrew/_DEFINES/player_outposts.dm b/voidcrew/_DEFINES/player_outposts.dm
index d5bb4c91cd2..8f51ed6f266 100644
--- a/voidcrew/_DEFINES/player_outposts.dm
+++ b/voidcrew/_DEFINES/player_outposts.dm
@@ -37,11 +37,8 @@
#define OUTPOST_SHIP_BAY_COST 10000
#define OUTPOST_DOCK_VARIANT_BAY "ship_bay"
-/// Round-local, prepaid hull recovery. A ship has at most one current registration.
-#define OUTPOST_MAX_CHECKPOINTS 12
+/// The most map text a captured hull may hold.
#define OUTPOST_CHECKPOINT_MAX_TEXT (1024 * 1024)
-#define OUTPOST_CHECKPOINT_SAVE_COST 10000
-#define OUTPOST_CHECKPOINT_UPDATE_COST 5000
/// New ships built to order in the ship bay (outpost_ship_orders.dm). Credits replace parts:
/// every part a hull, theme or module would cost in the lobby shipyard is this many credits.
diff --git a/voidcrew/modules/overmap/code/modules/overmap/ship.dm b/voidcrew/modules/overmap/code/modules/overmap/ship.dm
index 7a866223f29..4ed638b1178 100644
--- a/voidcrew/modules/overmap/code/modules/overmap/ship.dm
+++ b/voidcrew/modules/overmap/code/modules/overmap/ship.dm
@@ -25,7 +25,7 @@
var/obj/docking_port/mobile/voidcrew/shuttle
///The map template the shuttle was spawned from, if it was indeed created from a template. CAN BE NULL (ex. custom-built ships).
var/datum/map_template/shuttle/voidcrew/source_template
- /// The crew roster this hull was set up with, as job definitions. Ship saves and checkpoints keep it.
+ /// The crew roster this hull was set up with, as job definitions. Ship saves keep it.
var/list/job_definitions
/// A password is being paid for, so a double click can't charge twice.
var/buying_join_password = FALSE
@@ -1411,7 +1411,7 @@
*/
/obj/structure/overmap/ship/proc/claim_abandoned_ship(mob/living/claimer)
if(retired_by_checkpoint || checkpoint_rebuilding)
- to_chat(claimer, span_warning("This ship is retired or being rebuilt from a checkpoint."))
+ to_chat(claimer, span_warning("This ship is retired or being rebuilt."))
return FALSE
if(!abandoned)
return FALSE
diff --git a/voidcrew/modules/player_outposts/outpost_admin.dm b/voidcrew/modules/player_outposts/outpost_admin.dm
index 8f0353928c5..185af2a7aed 100644
--- a/voidcrew/modules/player_outposts/outpost_admin.dm
+++ b/voidcrew/modules/player_outposts/outpost_admin.dm
@@ -194,7 +194,7 @@ ADMIN_VERB(outpost_manipulator, R_ADMIN, "Outpost Manipulator", "Create and mana
if(action in list("install_bays", "remove_bays", "bay_jump", "bay_vv", "bay_grant_materials", "bay_revoke_materials", "bay_select_silo"))
manage_ship_bays(home, user, action, params)
return
- if(action in list("checkpoint_save", "checkpoint_rebuild_docked", "checkpoint_order_free", "checkpoint_rebuild", "checkpoint_delete", "bay_remove_ship", "rebuild_rush", "rebuild_stop"))
+ if(action in list("checkpoint_rebuild_docked", "checkpoint_order_free", "bay_remove_ship", "rebuild_rush", "rebuild_stop"))
manage_checkpoints(home, user, action, params)
return
if(action in list("playtest_visitor", "service_admin"))
diff --git a/voidcrew/modules/player_outposts/outpost_admin_bays.dm b/voidcrew/modules/player_outposts/outpost_admin_bays.dm
index fdca3213134..b989c901767 100644
--- a/voidcrew/modules/player_outposts/outpost_admin_bays.dm
+++ b/voidcrew/modules/player_outposts/outpost_admin_bays.dm
@@ -27,7 +27,6 @@
"installed" = home.ship_bay_installed, "capacity" = OUTPOST_SHIP_BAY_SLOTS,
"install_denial" = home.ship_bay_setup_denial(), "remove_denial" = bay_removal_denial(home),
"slots" = slots, "silos" = silos, "silo" = selected_silo ? REF(selected_silo) : null,
- "saved_checkpoints" = length(home.checkpoints),
)
/datum/outpost_manipulator/proc/bay_material_denial(obj/structure/overmap/dynamic/player_outpost/home, datum/outpost_berth/ship_bay/bay)
@@ -41,15 +40,13 @@
return "Select an outpost silo first."
return null
-/// Never disable recovery or pull the bay out from under an active visit.
+/// Never pull the bay out from under an active visit or build.
/datum/outpost_manipulator/proc/bay_removal_denial(obj/structure/overmap/dynamic/player_outpost/home)
if(!home.ship_bay_installed)
return "The ship bay is not installed."
- if(length(home.checkpoints))
- return "Saved checkpoints still depend on this bay."
for(var/datum/outpost_berth/ship_bay/bay as anything in home.bay_berths)
if(bay && !bay.is_available())
- return "Undock the ship and finish any checkpoint rebuild first."
+ return "Undock the ship and finish any construction first."
for(var/obj/structure/overmap/ship/ship as anything in home.pending_dock_variants)
if(home.pending_dock_variants[ship] == OUTPOST_DOCK_VARIANT_BAY)
return "Resolve pending ship-bay docking requests first."
diff --git a/voidcrew/modules/player_outposts/outpost_admin_checkpoints.dm b/voidcrew/modules/player_outposts/outpost_admin_checkpoints.dm
index 00ef00ac8a7..36ea167cc86 100644
--- a/voidcrew/modules/player_outposts/outpost_admin_checkpoints.dm
+++ b/voidcrew/modules/player_outposts/outpost_admin_checkpoints.dm
@@ -1,19 +1,8 @@
/**
- * Checkpoint testing tools for the Outpost Manipulator. Saves and rebuilds are free and skip
- * the captain rules; the rebuild itself is the normal staged reconstruction.
+ * Ship construction testing tools for the Outpost Manipulator. Everything is free and skips the
+ * captain rules; the builds themselves are the normal staged construction.
*/
/datum/outpost_manipulator/proc/checkpoint_admin_data(obj/structure/overmap/dynamic/player_outpost/home)
- var/list/saved = list()
- var/bay_free = !!home.available_ship_bay()
- for(var/datum/ship_checkpoint/snapshot as anything in home.checkpoints)
- saved += list(list(
- "ref" = REF(snapshot),
- "name" = snapshot.ship_name,
- "owner" = snapshot.captain_ckey,
- "size" = "[snapshot.width] x [snapshot.height]",
- "original" = checkpoint_original_text(snapshot),
- "rebuild_denial" = snapshot.busy ? "Already being rebuilt." : (bay_free ? null : "The ship bay is occupied or reserved."),
- ))
var/list/jobs = list()
for(var/datum/checkpoint_construction/job as anything in home.checkpoint_jobs)
var/list/entry = job.rebuild_ui_data()
@@ -24,20 +13,9 @@
return list(
"enabled" = home.ship_bay_installed,
"docked" = docked?.name,
- "checkpoints" = saved,
"rebuilds" = jobs,
)
-/datum/outpost_manipulator/proc/checkpoint_original_text(datum/ship_checkpoint/snapshot)
- var/obj/structure/overmap/ship/original = snapshot.source_ship?.resolve()
- if(!original || QDELETED(original.shuttle))
- return "Lost"
- if(original.abandoned)
- return "Abandoned"
- if(original.docked == snapshot.outpost)
- return "Docked here"
- return "In service"
-
/datum/outpost_manipulator/proc/docked_bay_ship(obj/structure/overmap/dynamic/player_outpost/home)
var/datum/outpost_berth/ship_bay/bay = LAZYACCESS(home.bay_berths, 1)
return bay?.is_ship_present() ? bay.ship : null
@@ -55,9 +33,6 @@
return
error = null
switch(action)
- if("checkpoint_save")
- prompt_free_checkpoint(home, user)
- return
if("checkpoint_rebuild_docked")
rebuild_docked_ship(home, user)
return
@@ -74,22 +49,6 @@
return
remove_docked_ship(home, user, bay, ship)
return
- if("checkpoint_rebuild", "checkpoint_delete")
- var/datum/ship_checkpoint/snapshot = locate(params["ref"]) in home.checkpoints
- if(!snapshot)
- error = "That checkpoint is no longer available."
- return
- if(action == "checkpoint_rebuild")
- admin_rebuild(home, user, snapshot)
- return
- if(snapshot.busy)
- error = "Stop its reconstruction first."
- return
- if(!confirm(home, user, "Delete [snapshot.captain_ckey]'s checkpoint of [snapshot.ship_name]?") || QDELETED(snapshot) || snapshot.busy)
- return
- record(user, home, "delete [snapshot.captain_ckey]'s checkpoint of [snapshot.ship_name]")
- qdel(snapshot)
- return
if("rebuild_rush", "rebuild_stop")
var/datum/checkpoint_construction/job = locate(params["ref"]) in home.checkpoint_jobs
if(!job)
@@ -100,128 +59,11 @@
if(job.rush())
record(user, home, "rush the reconstruction of [job.ship_name]")
if("rebuild_stop")
- if(!confirm(home, user, "Stop building [job.ship_name]? Placed pieces are removed. A checkpoint or payment already used by this build is not returned.") || QDELETED(job))
+ if(!confirm(home, user, "Stop building [job.ship_name]? Placed pieces are removed. A saved ship or payment already used by this build is not returned.") || QDELETED(job))
return
record(user, home, "stop the construction of [job.ship_name]")
job.abort("Stopped by an administrator.")
-/datum/outpost_manipulator/proc/prompt_free_checkpoint(obj/structure/overmap/dynamic/player_outpost/home, mob/user)
- if(!home.ship_bay_installed)
- error = "Install the ship bay first."
- return
- var/list/ships = list()
- var/obj/structure/overmap/ship/docked = docked_bay_ship(home)
- if(docked)
- ships["[docked.name] (in the ship bay)"] = docked
- for(var/obj/structure/overmap/ship/ship as anything in SSovermap.simulated_ships)
- if(ship == docked || QDELETED(ship) || QDELETED(ship.shuttle) || !istype(ship.shuttle, /obj/docking_port/mobile/voidcrew))
- continue
- var/label = "[ship.name] ([ship.docked ? "at [ship.docked.name]" : "in flight"])"
- var/suffix = 1
- while(ships[label])
- label = "[ship.name] ([ship.docked ? "at [ship.docked.name]" : "in flight"]) #[++suffix]"
- ships[label] = ship
- if(!length(ships))
- error = "No ship has a hull to save."
- return
- var/ship_choice = tgui_input_list(user, "Ship to save", "Free Checkpoint", ships)
- if(!valid_selection(home, user) || !ship_choice)
- return
- var/obj/structure/overmap/ship/ship = ships[ship_choice]
- var/list/owners = list("You ([user.ckey])" = user.ckey)
- var/captain_ckey = ship_captain_ckey(ship)
- if(captain_ckey && captain_ckey != user.ckey)
- owners["Its captain ([captain_ckey])"] = captain_ckey
- owners["Another player..."] = "pick"
- var/owner_choice = tgui_input_list(user, "Owner. Only the owner can rebuild it at the console, and they receive the rebuilt ship.", "Free Checkpoint", owners)
- if(!valid_selection(home, user) || !owner_choice)
- return
- var/owner_ckey = owners[owner_choice]
- if(owner_ckey == "pick")
- var/mob/living/picked = voidcrew_admin_pick_player(user.client, "Checkpoint Owner")
- if(!valid_selection(home, user) || !picked?.ckey)
- return
- owner_ckey = picked.ckey
- save_free_checkpoint(home, user, ship, owner_ckey)
-
-/// The player save rules that still matter without fees or a captain.
-/datum/outpost_manipulator/proc/free_checkpoint_denial(obj/structure/overmap/dynamic/player_outpost/home, obj/structure/overmap/ship/ship, owner_ckey)
- if(!home.ship_bay_installed)
- return "Install the ship bay first."
- if(QDELETED(ship) || QDELETED(ship.shuttle))
- return "That ship no longer has a hull."
- if(ship.retired_by_checkpoint)
- return "That hull was already replaced by a rebuild."
- if(ship.checkpoint_rebuilding)
- return "That ship's checkpoint is being rebuilt."
- var/datum/ship_checkpoint/previous = home.checkpoint_for_ckey(owner_ckey)
- if(previous?.busy)
- return "[owner_ckey]'s checkpoint here is being rebuilt."
- var/datum/ship_checkpoint/linked = ship.checkpoint_ref?.resolve()
- if(linked && linked != previous)
- return "That ship already has a checkpoint owned by [linked.captain_ckey]. Delete it first."
- if(!previous && length(home.checkpoints) >= OUTPOST_MAX_CHECKPOINTS)
- return "This outpost's checkpoint storage is full."
- return null
-
-/// Saves ship as owner_ckey's checkpoint here, replacing their previous one. No fee.
-/datum/outpost_manipulator/proc/save_free_checkpoint(obj/structure/overmap/dynamic/player_outpost/home, mob/user, obj/structure/overmap/ship/ship, owner_ckey)
- error = free_checkpoint_denial(home, ship, owner_ckey)
- if(error)
- return null
- var/datum/ship_checkpoint/snapshot = new
- var/denial = snapshot.capture_hull(ship, owner_ckey)
- // Reading the hull yields; check everything again before replacing anything.
- if(!denial && !valid_selection(home, user))
- denial = "The outpost changed while the hull was being read."
- denial ||= free_checkpoint_denial(home, ship, owner_ckey)
- if(denial)
- qdel(snapshot)
- if(!QDELETED(src))
- error = denial
- return null
- var/datum/ship_checkpoint/previous = home.checkpoint_for_ckey(owner_ckey)
- if(previous)
- qdel(previous)
- snapshot.outpost = home
- home.checkpoints += snapshot
- ship.checkpoint_ref = WEAKREF(snapshot)
- record(user, home, "save a free checkpoint of [ship.name] for [owner_ckey]")
- return snapshot
-
-/**
- * Starts the staged reconstruction without the captain rule. A lost or abandoned original is
- * retired as usual; one still in service is left alone and the new hull is an extra copy.
- */
-/datum/outpost_manipulator/proc/admin_rebuild(obj/structure/overmap/dynamic/player_outpost/home, mob/user, datum/ship_checkpoint/snapshot)
- if(QDELETED(snapshot) || snapshot.outpost != home || !(snapshot in home.checkpoints))
- error = "That checkpoint is no longer available."
- return FALSE
- if(snapshot.busy)
- error = "That checkpoint is already being rebuilt."
- return FALSE
- if(!home.available_ship_bay())
- error = "The ship bay is occupied or reserved."
- return FALSE
- var/obj/structure/overmap/ship/original = snapshot.source_ship?.resolve()
- if(original?.retired_by_checkpoint)
- error = "That checkpoint's hull was already replaced."
- return FALSE
- var/in_service = original && !QDELETED(original.shuttle) && !original.abandoned
- var/ship_name = snapshot.ship_name
- var/owner_ckey = snapshot.captain_ckey
- var/datum/checkpoint_construction/job = new(null, snapshot, user, FALSE, in_service)
- if(!job.bay)
- error = job.error || "The ship bay is occupied or reserved."
- qdel(job)
- return FALSE
- if(!job.prepare())
- if(!QDELETED(src))
- error = job.error || "The rebuild could not start. The checkpoint is still available."
- return FALSE
- record(user, home, "start a free rebuild of [ship_name] for [owner_ckey][in_service ? " as a copy; the original stays in service" : ""]")
- return TRUE
-
/// Deletes the hull docked in a bay the way the Shuttle Manipulator does. People aboard are
/// moved to the bay elevator first, and the pad gets its own hangar room back.
/datum/outpost_manipulator/proc/remove_docked_ship(obj/structure/overmap/dynamic/player_outpost/home, mob/user, datum/outpost_berth/ship_bay/bay, obj/structure/overmap/ship/expected)
@@ -262,7 +104,10 @@
record(user, home, "delete [ship_name] from Ship Bay [bay.bay_number]")
return TRUE
-/// One click: save the docked ship for its captain (or you), delete it, and rebuild it.
+/**
+ * One click: capture the docked ship for its captain (or you), delete it, and rebuild it straight
+ * from the capture. The job owns the capture, so a build that fails after the delete loses the ship.
+ */
/datum/outpost_manipulator/proc/rebuild_docked_ship(obj/structure/overmap/dynamic/player_outpost/home, mob/user)
var/datum/outpost_berth/ship_bay/bay = LAZYACCESS(home.bay_berths, 1)
if(!bay?.is_ship_present())
@@ -270,15 +115,40 @@
return FALSE
var/obj/structure/overmap/ship/ship = bay.ship
var/owner_ckey = ship_captain_ckey(ship) || user.ckey
- if(!confirm(home, user, "Save [ship.name] as a free checkpoint for [owner_ckey], delete the docked hull (living people aboard move to the bay elevator), then rebuild it with the drones?"))
+ if(!confirm(home, user, "Capture [ship.name] for [owner_ckey], delete the docked hull (living people aboard move to the bay elevator), then rebuild it with the drones? It comes back at default tier."))
return FALSE
if(bay.ship != ship || !bay.is_ship_present())
error = "The docked ship changed. Try again."
return FALSE
- var/datum/ship_checkpoint/snapshot = save_free_checkpoint(home, user, ship, owner_ckey)
- if(!snapshot || !remove_docked_ship(home, user, bay, ship))
+ if(ship.retired_by_checkpoint || ship.checkpoint_rebuilding)
+ error = "That hull is being replaced already."
+ return FALSE
+ var/datum/ship_checkpoint/snapshot = new
+ var/denial = snapshot.capture_hull(ship, owner_ckey)
+ // Reading the hull yields; check everything again before deleting anything.
+ if(!denial && (!valid_selection(home, user) || bay.ship != ship || !bay.is_ship_present()))
+ denial = "The docked ship changed while the hull was being read."
+ if(denial)
+ qdel(snapshot)
+ if(!QDELETED(src))
+ error = denial
+ return FALSE
+ snapshot.outpost = home
+ var/ship_name = snapshot.ship_name
+ if(!remove_docked_ship(home, user, bay, ship))
+ qdel(snapshot)
return FALSE
- return admin_rebuild(home, user, snapshot)
+ var/datum/checkpoint_construction/job = new(null, snapshot, user)
+ if(!job.bay)
+ error = job.error || "The ship bay is occupied or reserved."
+ qdel(job)
+ return FALSE
+ if(!job.prepare())
+ if(!QDELETED(src))
+ error = job.error || "The rebuild could not start."
+ return FALSE
+ record(user, home, "start a free rebuild of [ship_name] for [owner_ckey]")
+ return TRUE
/**
* Starts a free shipyard order: pick a hull, and optionally its theme and modules, or take the
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm
index 77b236cf2f4..43fcfd62ee9 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm
@@ -11,10 +11,11 @@
* the bay. A piece that is removed, moved or damaged after placement stays that way.
*
* The source is what the job builds from and what it spends at the first piece. This type
- * rebuilds a saved checkpoint: stock is scrubbed and parts are restored in the hidden copy
- * before any of it can be reached, and the checkpoint is consumed immediately before the
- * first piece. /datum/checkpoint_construction/order (outpost_ship_orders.dm) builds a new
- * ship from the catalog instead, and charges its buyer at that point. The hooks each source
+ * builds from a snapshot, which it owns: stock is scrubbed and parts are put right in the
+ * hidden copy before any of it can be reached, and the snapshot is consumed immediately before
+ * the first piece. /datum/checkpoint_construction/saved (ship_save_construction.dm) spends a
+ * saved ship at that point, and /datum/checkpoint_construction/order (outpost_ship_orders.dm)
+ * builds a new ship from the catalog instead, and charges its buyer. The hooks each source
* overrides are grouped under SOURCE below.
*/
/obj/structure/overmap/dynamic/player_outpost
@@ -39,12 +40,10 @@
var/datum/outpost_berth/ship_bay/bay
var/datum/ship_checkpoint/snapshot
var/datum/weakref/panel_ref
- /// Only used to reapply console upgrade disks while the hidden copy is prepared.
- var/datum/weakref/operator_ref
var/datum/weakref/original_ref
var/captain_ckey
var/ship_name
- /// What the hidden copy is loaded from. Checkpoints use their commissioned checkpoint
+ /// What the hidden copy is loaded from. Snapshots use their commissioned checkpoint
/// template; orders a plain hull template. The ship record keeps it at handover.
var/datum/map_template/shuttle/voidcrew/template
/// The loaded copy. Its port joins the bay before the first piece does.
@@ -107,11 +106,10 @@
var/status_verb = "Rebuilding"
var/build_noun = "Reconstruction"
-/// leave_original: an admin copy of a hull that is still in service. It is not retired and
-/// keeps its money; the checkpoint is still consumed.
+/// leave_original: a copy of a hull that may still be in service. It is not retired and keeps
+/// its money; the snapshot is still consumed. The job owns the snapshot from here on.
/datum/checkpoint_construction/New(datum/ship_checkpoint_ui/terminal, datum/ship_checkpoint/blueprint, mob/living/user, manual_drive = FALSE, leave_original = FALSE)
panel_ref = terminal ? WEAKREF(terminal) : null
- operator_ref = WEAKREF(user)
snapshot = blueprint
home = blueprint.outpost
captain_ckey = blueprint.captain_ckey
@@ -120,7 +118,7 @@
var/obj/structure/overmap/ship/original = leave_original ? null : blueprint.source_ship?.resolve()
if(original)
original_ref = WEAKREF(original)
- // Refunds follow the checkpoint's owner, who is the operator unless an admin started it.
+ // Refunds follow the snapshot's owner, who is the operator unless an admin started it.
var/mob/living/owner = user?.ckey == captain_ckey ? user : get_mob_by_ckey(captain_ckey)
var/datum/bank_account/personal = istype(owner) ? owner.get_bank_account() : null
if(personal)
@@ -132,12 +130,10 @@
state = CHECKPOINT_BUILD_FAILED
return
home.checkpoint_jobs += src
- snapshot.busy = TRUE
if(original)
original.checkpoint_rebuilding = TRUE
RegisterSignal(home, COMSIG_QDELETING, PROC_REF(on_site_deleted))
RegisterSignal(bay, COMSIG_QDELETING, PROC_REF(on_site_deleted))
- RegisterSignal(snapshot, COMSIG_QDELETING, PROC_REF(on_checkpoint_deleted))
/// Once pieces exist, only a forced deletion may stop the job; it then removes them.
/datum/checkpoint_construction/Destroy(force)
@@ -153,11 +149,9 @@
UnregisterSignal(home, COMSIG_QDELETING)
if(bay)
UnregisterSignal(bay, COMSIG_QDELETING)
- if(snapshot)
- UnregisterSignal(snapshot, COMSIG_QDELETING)
home = null
bay = null
- snapshot = null
+ QDEL_NULL(snapshot)
port = null
vessel = null
// A load still writing into the shipyard lets it go itself when it returns (load_source()).
@@ -174,13 +168,6 @@
// Releasing reservations can yield; the removal itself finishes before the bay goes.
INVOKE_ASYNC(src, PROC_REF(abort), "The ship bay was removed.")
-/datum/checkpoint_construction/proc/on_checkpoint_deleted(datum/source)
- SIGNAL_HANDLER
- UnregisterSignal(snapshot, COMSIG_QDELETING)
- snapshot = null
- if(!committed)
- INVOKE_ASYNC(src, PROC_REF(abort), "This checkpoint is no longer available.")
-
// ===== PREPARATION =====
/// Loads and plans the hidden copy. Returns TRUE once the survey markers are down.
@@ -253,7 +240,7 @@
return TRUE
/// Whether a queued job still wants the shared loader. Anything that ended it already
-/// released its bay and kept its checkpoint.
+/// released its bay and spent nothing.
/datum/checkpoint_construction/proc/still_queued()
return !QDELETED(src) && state == CHECKPOINT_BUILD_PREPARING
@@ -280,12 +267,12 @@
return source_denial()
// ===== SOURCE =====
-// A checkpoint rebuild. /datum/checkpoint_construction/order overrides these.
+// Building from a snapshot. /datum/checkpoint_construction/saved and order override these.
/// Why the source can no longer be built, or null.
/datum/checkpoint_construction/proc/source_denial()
- if(QDELETED(snapshot) || snapshot.outpost != home || !(snapshot in home.checkpoints))
- return "This checkpoint is no longer available."
+ if(QDELETED(snapshot))
+ return "This record can't be read."
var/obj/structure/overmap/ship/original = original_ref?.resolve()
if(original)
if(original.retired_by_checkpoint)
@@ -296,7 +283,7 @@
/// Added to every refusal made before the first piece.
/datum/checkpoint_construction/proc/unspent_note()
- return "Your checkpoint is still available."
+ return "Nothing was built."
/// The template the hidden copy is loaded from.
/datum/checkpoint_construction/proc/create_template()
@@ -337,22 +324,20 @@
// its stock gone and its parts not yet restored (an APC without its cell, for one).
// Initialization may stock lockers or engine tanks even though no items were saved.
clear_stock(port)
- var/mob/living/operator = operator_ref?.resolve()
for(var/turf/tile as anything in port.return_turfs())
if(!(get_area(tile) in port.shuttle_areas))
continue
for(var/obj/machinery/machine in tile)
- restore_machine(machine, operator)
+ checkpoint_restore_machine(machine)
mark_phase("scrub_and_restore")
/**
* Spends the source immediately before the first piece, and sets committed. From here the
- * job only moves forward: partial output is never rolled back into a fresh checkpoint.
+ * job only moves forward: partial output is never rolled back into a fresh snapshot.
* Returns FALSE, with error set, when it cannot be spent; nothing is spent then.
*/
/datum/checkpoint_construction/proc/consume_source()
committed = TRUE
- UnregisterSignal(snapshot, COMSIG_QDELETING)
var/datum/ship_checkpoint/consumed = snapshot
snapshot = null
var/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/checkpoint_template = template
@@ -360,17 +345,11 @@
qdel(consumed)
var/obj/structure/overmap/ship/original = original_ref?.resolve()
if(original)
- original.retired_by_checkpoint = TRUE
- original.checkpoint_rebuilding = FALSE
- var/balance = original.ship_account?.account_balance
- if(balance > 0 && original.ship_account.adjust_money(-balance, "Checkpoint recovery"))
- held_balance = balance
- if(QDELETED(original.shuttle))
- qdel(original)
- log_game("Checkpoint for [ship_name] ([captain_ckey]) at [home.name] was consumed by its reconstruction.")
+ held_balance = original.retire_replaced_hull()
+ log_game("Snapshot of [ship_name] ([captain_ckey]) at [home.name] was consumed by its reconstruction.")
return TRUE
-/// Lets go of the source when the job stops. Before the first piece it stays spendable.
+/// Lets go of the source when the job stops. Before the first piece nothing has been spent.
/datum/checkpoint_construction/proc/release_source()
var/obj/structure/overmap/ship/original = original_ref?.resolve()
if(committed)
@@ -380,8 +359,6 @@
original.checkpoint_rebuilding = FALSE
return_held_balance(original)
return
- if(!QDELETED(snapshot))
- snapshot.busy = FALSE
if(original)
original.checkpoint_rebuilding = FALSE
@@ -412,7 +389,7 @@
/// For the handover log.
/datum/checkpoint_construction/proc/finished_text()
- return "rebuilt from its checkpoint"
+ return "rebuilt from a snapshot"
/// Records the template's timing phases; only a checkpoint template keeps them.
/datum/checkpoint_construction/proc/mark_phase(phase)
@@ -1192,8 +1169,8 @@
// ===== FAILURE AND TERMINATION =====
/**
- * Before the first piece: release everything and keep the checkpoint.
- * After it: remove what was placed and release the bay. The checkpoint stays consumed.
+ * Before the first piece: release everything, having spent nothing.
+ * After it: remove what was placed and release the bay. The source stays spent.
*/
/datum/checkpoint_construction/proc/abort(reason, delete_job = TRUE)
if(state == CHECKPOINT_BUILD_COMPLETE || state == CHECKPOINT_BUILD_FAILED)
@@ -1244,7 +1221,7 @@
/**
* Objects mapped around the hull but outside its rooms are what a landing leaves behind. A
- * saved checkpoint has none; a ship map can (signs on the outer face of a wall). Nobody can
+ * snapshot has none; a ship map can (signs on the outer face of a wall). Nobody can
* reach them here, so shipyard orders delete them rather than leave them to the shipyard's wipe.
*/
/datum/checkpoint_construction/proc/clear_off_hull()
@@ -1385,9 +1362,9 @@
var/mob/living/captain = get_mob_by_ckey(captain_ckey)
account = istype(captain) ? captain.get_bank_account() : null
if(account)
- account.adjust_money(held_balance, "Checkpoint recovery refund")
+ account.adjust_money(held_balance, "Ship recovery refund")
else
- log_game("Checkpoint reconstruction of [ship_name] could not return [held_balance] credits: no account remains.")
+ log_game("Reconstruction of [ship_name] could not return [held_balance] credits: no account remains.")
held_balance = 0
/datum/checkpoint_construction/proc/on_port_deleted(datum/source)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
index df5888d2868..e90db4dbde9 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
@@ -1,7 +1,3 @@
-/// Used only while loading an isolated registry preview; cleared before delivery.
-/obj/machinery
- var/checkpoint_load_id = 0
-
/// Construct the ordinary machine, not a stocked/map/event subtype. Infrastructure
/// without a circuit retains its existing, explicitly supported hull representation.
/// What this machine is rebuilt as: its board's product when it has one, else itself.
@@ -30,43 +26,6 @@
var/obj/machinery/atmospherics/connector = internal_connector?.gas_connector
return connector ? list(connector) : list()
-/// Keep parts as construction types, never mutable datums belonging to the source.
-/datum/ship_checkpoint/proc/capture_machine(obj/machinery/machine)
- var/list/parts = list()
- var/has_battery = FALSE
- for(var/datum/part as anything in machine.component_parts)
- if(istype(part, /datum/stock_part))
- var/datum/stock_part/stock = part
- parts += stock.type
- else if(isitem(part) && part != machine.circuit)
- var/datum/stock_part/stock = GLOB.stock_part_datums_per_object[part.type]
- parts += stock ? stock.type : part.type
- if(istype(part, /obj/item/stock_parts/power_store))
- has_battery = TRUE
- if(istype(machine, /obj/machinery/power/smes) && !istype(machine, /obj/machinery/power/smes/connector) && !has_battery)
- for(var/i in 1 to 5)
- parts += /obj/item/stock_parts/power_store/battery/high
- var/list/data = list("parts" = parts)
- if(istype(machine, /obj/machinery/power/apc))
- var/obj/machinery/power/apc/controller = machine
- var/cell_type = controller.cell?.type || /obj/item/stock_parts/power_store/battery/upgraded
- data["cell"] = cell_type
- if(istype(machine, /obj/machinery/computer/camera_advanced/base_construction/ship))
- var/obj/machinery/computer/camera_advanced/base_construction/ship/builder = machine
- data["construction_upgrades"] = builder.installed_upgrade_types.Copy()
- return data
-
-/// Restore parts before RefreshParts calculates capacity, speed and efficiency.
-/datum/checkpoint_construction/proc/restore_machine(obj/machinery/machine, mob/living/operator)
- if(snapshot.persistent)
- checkpoint_restore_machine(machine, null, operator)
- return
- if(!machine.checkpoint_load_id || machine.checkpoint_load_id > length(snapshot.machines))
- return
- var/list/data = snapshot.machines[machine.checkpoint_load_id]
- machine.checkpoint_load_id = 0
- checkpoint_restore_machine(machine, data, operator)
-
/// Empties a construction part of whatever it came with and fills its battery.
/proc/checkpoint_scrub_part(obj/item/part)
part.reagents?.clear_reagents()
@@ -84,31 +43,17 @@
refill.premium = list()
/**
- * Puts a loaded machine's parts and supplies right. `data` is what capture_machine() kept for it.
- * A saved ship keeps nothing per machine, so its machines stay at the default tier they initialized with.
+ * Puts a loaded machine's parts and supplies right. A capture keeps nothing per machine, so it stays
+ * at the default tier it initialized with.
*/
-/proc/checkpoint_restore_machine(obj/machinery/machine, list/data, mob/living/operator)
+/proc/checkpoint_restore_machine(obj/machinery/machine)
if(machine.circuit)
// Retain the newly initialized circuit and its machine-specific configuration.
var/obj/item/circuitboard/board = machine.circuit
- if(data)
- var/list/old_parts = machine.component_parts?.Copy() || list()
- machine.component_parts = list(board)
- for(var/obj/item/part in old_parts)
- if(part != board)
- qdel(part)
- for(var/part_type in data["parts"])
- if(ispath(part_type, /datum/stock_part))
- machine.component_parts += GLOB.stock_part_datums[part_type]
- else
- var/obj/item/part = new part_type(machine)
- machine.component_parts += part
- checkpoint_scrub_part(part)
- else
- for(var/obj/item/part in machine.component_parts)
- if(part != board)
- checkpoint_scrub_part(part)
- ship_save_strip_board_salvage(board)
+ for(var/obj/item/part in machine.component_parts)
+ if(part != board)
+ checkpoint_scrub_part(part)
+ ship_save_strip_board_salvage(board)
machine.RefreshParts()
if(istype(machine, /obj/machinery/vending))
var/obj/machinery/vending/vendor = machine
@@ -117,19 +62,12 @@
if(istype(machine, /obj/machinery/power/apc))
var/obj/machinery/power/apc/controller = machine
QDEL_NULL(controller.cell)
- var/cell_type = data?["cell"] || controller.cell_type || /obj/item/stock_parts/power_store/battery/upgraded
+ var/cell_type = controller.cell_type || /obj/item/stock_parts/power_store/battery/upgraded
controller.cell = new cell_type(controller)
controller.cell.charge = controller.cell.maxcharge
controller.update()
if(istype(machine, /obj/machinery/computer/camera_advanced/base_construction/ship))
var/obj/machinery/computer/camera_advanced/base_construction/ship/builder = machine
- for(var/upgrade_type in data?["construction_upgrades"])
- if(!ispath(upgrade_type, /obj/item/ship_construction_upgrade) && !ispath(upgrade_type, /obj/item/rcd_upgrade) && !ispath(upgrade_type, /obj/item/rpd_upgrade))
- continue
- var/obj/item/disk = new upgrade_type(builder)
- builder.item_interaction(operator, disk)
- if(!QDELETED(disk))
- qdel(disk)
if(builder.internal_painter)
QDEL_NULL(builder.internal_painter.ink)
builder.internal_rcd.matter = 0
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
index acfa01212f0..74670469441 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
@@ -1,68 +1,5 @@
-/datum/ship_checkpoint_ui/proc/rebuild_denial(mob/living/user, datum/ship_checkpoint/snapshot, allow_busy = FALSE)
- if(QDELETED(src) || ui_status(user, GLOB.always_state) != UI_INTERACTIVE)
- return "Checkpoint console unavailable."
- if(QDELETED(snapshot) || snapshot.outpost != outpost || !(snapshot in outpost.checkpoints))
- return "This checkpoint is no longer available."
- if(user.ckey != snapshot.captain_ckey)
- return "Only the captain who saved this hull can rebuild it."
- if(snapshot.busy && !allow_busy)
- return "This hull is already being rebuilt."
- var/obj/structure/overmap/ship/original = snapshot.source_ship?.resolve()
- if(original)
- if(original.retired_by_checkpoint)
- return "The original hull has already been replaced."
- if(original.checkpoint_rebuilding && !allow_busy)
- return "Recovery is already in progress."
- // A deleted physical hull can leave an overmap record behind. Its crew
- // roster alone must not prevent recovery of a ship that no longer exists.
- if(!QDELETED(original.shuttle) && !original.abandoned)
- return "The original hull must be lost or abandoned."
- if(!allow_busy && !outpost.available_ship_bay())
- return "The ship bay is occupied or reserved."
- return null
-
-/// Starts staged reconstruction. Returns TRUE once the hidden copy is loaded and marked out.
-/datum/ship_checkpoint_ui/proc/rebuild(mob/living/user, datum/ship_checkpoint/snapshot)
- error = rebuild_denial(user, snapshot)
- if(error || working)
- return FALSE
- notice = null
- working = TRUE
- // The job claims the bay and both hulls before waiting for the shared shuttle loader.
- var/datum/checkpoint_construction/job = create_rebuild_job(user, snapshot)
- var/started = FALSE
- if(job.bay)
- started = job.prepare()
- else
- job.error = "The ship bay is occupied or reserved."
- qdel(job)
- if(!QDELETED(src))
- working = FALSE
- if(started)
- error = null
- notice = "Reconstruction started."
- else
- error = job.error || "Rebuild failed. Your checkpoint is still available."
- return started
-
-/datum/ship_checkpoint_ui/proc/create_rebuild_job(mob/living/user, datum/ship_checkpoint/snapshot)
- return new /datum/checkpoint_construction(src, snapshot, user)
-
-/// Basics a rebuilt ship keeps as they load, full: breathable air and engine plasma tanks,
-/// welding fuel and water tanks, and plumbing water. Every other supply is scrubbed.
-GLOBAL_LIST_INIT(outpost_checkpoint_restocked, typecacheof(list(
- /obj/machinery/atmospherics/components/tank/air,
- /obj/machinery/atmospherics/components/tank/oxygen,
- /obj/machinery/atmospherics/components/tank/nitrogen,
- /obj/machinery/atmospherics/components/tank/plasma,
- /obj/structure/reagent_dispensers/fueltank,
- /obj/structure/reagent_dispensers/watertank,
- /obj/machinery/shower,
- /obj/structure/sink,
-)))
-
/datum/checkpoint_construction/proc/clear_stock(obj/docking_port/mobile/voidcrew/source_port)
- checkpoint_clear_stock(source_port, snapshot?.persistent ? ship_save_restocked() : GLOB.outpost_checkpoint_restocked)
+ checkpoint_clear_stock(source_port, ship_save_restocked())
/// Empties a loaded copy of everything but its fittings. Types in `restocked_types` keep what they loaded with.
/proc/checkpoint_clear_stock(obj/docking_port/mobile/voidcrew/source_port, list/restocked_types)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
index 8b4ab7ad871..9140b61d174 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
@@ -49,7 +49,7 @@ GLOBAL_LIST_INIT(outpost_checkpoint_excluded, typecacheof(list(
)))
/**
- * Hull terrain a checkpoint cannot rebuild. Any other turf is saved as it is, so ship features
+ * Hull terrain a capture cannot rebuild. Any other turf is saved as it is, so ship features
* such as pools, hot springs and dirt planters need no entry anywhere.
* - Mineable rock and asteroid ground hand out ore or sand on every rebuild.
* - Planetary atmosphere regenerates air forever wherever it is placed.
@@ -71,7 +71,7 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
return open_tile.planetary_atmos
return FALSE
-/// Whether a checkpoint keeps this object. Machinery also needs a type to rebuild as.
+/// Whether a capture keeps this object. Machinery also needs a type to rebuild as.
/proc/outpost_checkpoint_saves(obj/object)
if(object.flags_1 & HOLOGRAM_1 || is_type_in_typecache(object, GLOB.outpost_checkpoint_excluded))
return FALSE
@@ -80,7 +80,12 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
return !!machine.checkpoint_type()
return isstructure(object)
+/**
+ * A captured hull: its map text and rooms. It stores no per-machine data, so machines come back at
+ * default tier, and it only ever comes apart into iron (ship_save_capture.dm).
+ */
/datum/ship_checkpoint
+ /// The outpost a construction job builds this in.
var/obj/structure/overmap/dynamic/player_outpost/outpost
var/datum/weakref/source_ship
var/captain_ckey
@@ -92,34 +97,23 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
var/port_x
var/port_y
var/list/rooms = list()
- /// Construction parts only; no live references or saved inventories.
- var/list/machines = list()
- var/busy = FALSE
var/error
- /// Saved across rounds: stores no per-machine data and comes apart into iron (ship_save_capture.dm).
- var/persistent = FALSE
/// The hull's class, shown wherever it is listed.
var/class_name
/// The crew roster as template job definitions. Null leaves the commissioned default.
var/list/jobs
/datum/ship_checkpoint/Destroy()
- if(outpost)
- outpost.checkpoints -= src
- var/obj/structure/overmap/ship/original = source_ship?.resolve()
- if(original?.checkpoint_ref?.resolve() == src)
- original.checkpoint_ref = null
outpost = null
source_ship = null
tgm = null
rooms = null
- machines = null
jobs = null
return ..()
/// Ports are handled separately and cannot carry NPC types.
/datum/ship_checkpoint/proc/includes_object(obj/object)
- if(persistent && ship_save_refuses_object(object))
+ if(ship_save_refuses_object(object))
return FALSE
return outpost_checkpoint_saves(object)
@@ -131,10 +125,6 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
if(ismachinery(object))
var/obj/machinery/machine = object
saved_type = machine.checkpoint_type()
- // A saved ship's machines come back at default tier, so there is nothing to restore.
- if(!persistent)
- machines += list(capture_machine(machine))
- properties += "checkpoint_load_id = [length(machines)]"
if(istype(machine, /obj/machinery/power/smes))
keys += list("input_level", "output_level")
if(istype(machine, /obj/machinery/power))
@@ -166,24 +156,14 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
keys += "open_ports"
if(istype(object, /obj/machinery/duct))
keys += list("duct_layer", "duct_color", "connects")
- if(persistent)
- saved_type = strip_salvage(object, saved_type, keys, properties)
- else
- // A wall or floor a saved ship brought in holds iron where its type holds more; keep it
- // through an in-round checkpoint. Equal to the type's own value, it is left out as usual.
- if(istype(object, /turf/closed/wall))
- keys += "sheet_type"
- if(istype(object, /turf/open/floor))
- keys += "floor_tile"
+ saved_type = strip_salvage(object, saved_type, keys, properties)
for(var/key in keys)
var/value = object.vars[key]
// Always encode direction: an oriented source must survive a round trip.
if(saved_type == object.type && key != "dir" && value == initial(object.vars[key]))
continue
if(istext(value))
- value = replacetext(value, "\\", "")
- if(persistent)
- value = ship_save_plain_text(value)
+ value = ship_save_plain_text(replacetext(value, "\\", ""))
var/encoded = tgm_encode(value)
if(encoded)
properties += "[key] = [encoded]"
@@ -192,17 +172,17 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
/// A captain saves their own ship.
/datum/ship_checkpoint/proc/capture(obj/structure/overmap/ship/ship, mob/living/captain)
if(QDELETED(ship?.shuttle) || !ship.is_ship_captain(captain) || !captain.ckey)
- return "Only the ship's captain can save a checkpoint."
+ return "Only the ship's captain can save it."
return capture_hull(ship, captain.ckey)
/// Capture only this port's owned tiles, including its docking tile and hull holes. The
-/// caller decides who may save the ship and who owns the checkpoint.
+/// caller decides who may save the ship and who owns the snapshot.
/datum/ship_checkpoint/proc/capture_hull(obj/structure/overmap/ship/ship, owner_ckey)
var/obj/docking_port/mobile/voidcrew/port = ship?.shuttle
if(QDELETED(port) || !owner_ckey)
return "The source hull is no longer available."
if(port.z_levels_above || port.z_levels_below)
- return "Checkpoints currently support single-level ships only."
+ return "Only single-level ships can be saved."
var/list/bounds = port.return_coords()
var/min_x = min(bounds[1], bounds[3])
var/min_y = min(bounds[2], bounds[4])
@@ -229,7 +209,7 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
atoms = list("/turf/template_noop", "/area/template_noop")
else
if(checkpoint_refuses_terrain(tile))
- return "Replace the hull's [tile.name] terrain with constructed flooring before saving a checkpoint."
+ return "Replace the hull's [tile.name] terrain with constructed flooring before saving."
var/room_id = area_ids[room]
if(!room_id)
rooms += list(list("name" = room.name, "type" = room.type, "tiles" = list()))
@@ -263,7 +243,7 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
return "The source hull is no longer available."
tgm = "//[DMM2TGM_MESSAGE]\n[headers.Join()][columns.Join()]"
if(length(tgm) > OUTPOST_CHECKPOINT_MAX_TEXT)
- return "The hull design exceeds checkpoint capacity."
+ return "The hull design is too large to save."
var/datum/parsed_map/validated = new(tgm)
var/valid = validated.bounds?[MAP_MAXX] == width && validated.bounds?[MAP_MAXY] == height
qdel(validated)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoints.dm b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
index 5b3d99c0605..9a64503ab65 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoints.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
@@ -1,23 +1,34 @@
-/obj/structure/overmap/dynamic/player_outpost
- var/list/datum/ship_checkpoint/checkpoints = list()
-
/obj/structure/overmap/ship
- var/datum/weakref/checkpoint_ref
- /// Prevent claiming the old hull while its paid replacement is loading.
+ /// Prevent claiming the old hull while its replacement is loading.
var/checkpoint_rebuilding = FALSE
var/retired_by_checkpoint = FALSE
+/**
+ * A new hull has replaced this one: nobody can claim or fly it from here on, and its money goes to
+ * the replacement. A record whose hull is already gone goes with it.
+ * Returns the balance it took, for the caller to pay to the replacement.
+ */
+/obj/structure/overmap/ship/proc/retire_replaced_hull()
+ . = 0
+ retired_by_checkpoint = TRUE
+ checkpoint_rebuilding = FALSE
+ var/balance = ship_account?.account_balance
+ if(balance > 0 && ship_account.adjust_money(-balance, "Ship replaced"))
+ . = balance
+ if(QDELETED(shuttle))
+ qdel(src)
+
/// Every ship bay map has a shipyard console; the board replaces a lost one.
/obj/item/circuitboard/computer/ship_checkpoint
name = "Shipyard Console (Computer Board)"
greyscale_colors = CIRCUIT_COLOR_COMMAND
build_path = /obj/machinery/computer/ship_checkpoint
-/// The outpost shipyard: new ships built to order, and ship checkpoints. The type path predates
+/// The outpost shipyard: new ships built to order, and saved ships. The type path predates
/// the shop and is kept so maps and boards stay valid.
/obj/machinery/computer/ship_checkpoint
name = "shipyard console"
- desc = "Orders new ships for the outpost ship bay and keeps checkpoints of ships docked there, for rebuilding one that is lost."
+ desc = "Orders new ships for the outpost ship bay and keeps records of ships saved there."
icon_screen = "id"
icon_keyboard = "id_key"
circuit = /obj/item/circuitboard/computer/ship_checkpoint
@@ -49,28 +60,11 @@
/obj/machinery/computer/ship_checkpoint/checkpoint_type()
return null
-/// One checkpoint per captain at this outpost, even when updating from a new ship.
-/obj/structure/overmap/dynamic/player_outpost/proc/checkpoint_for(mob/user)
- return checkpoint_for_ckey(user?.ckey)
-
-/obj/structure/overmap/dynamic/player_outpost/proc/checkpoint_for_ckey(owner_ckey)
- if(!owner_ckey)
- return null
- for(var/datum/ship_checkpoint/snapshot as anything in checkpoints)
- if(snapshot.captain_ckey == owner_ckey)
- return snapshot
-
/datum/ship_checkpoint_ui
var/obj/structure/overmap/dynamic/player_outpost/outpost
var/datum/weakref/host_ref
var/datum/weakref/user_ref
var/turf/host_turf
- var/datum/ship_checkpoint/quote
- var/datum/weakref/quoted_bay
- var/datum/weakref/quoted_account
- var/datum/weakref/quoted_checkpoint
- var/quoted_owner
- var/quoted_fee
var/error
var/notice
var/working = FALSE
@@ -92,7 +86,6 @@
if(outpost)
UnregisterSignal(outpost, COMSIG_QDELETING)
outpost = null
- QDEL_NULL(quote)
QDEL_NULL(cart)
return ..()
@@ -129,136 +122,20 @@
/datum/ship_checkpoint_ui/ui_static_data(mob/user)
return shop_static_data()
-/// Only a physically present, captain-owned bay can supply a snapshot or payment.
-/datum/ship_checkpoint_ui/proc/save_denial(mob/living/user, datum/outpost_berth/ship_bay/bay)
- if(QDELETED(src) || ui_status(user, GLOB.always_state) != UI_INTERACTIVE)
- return "Checkpoint console unavailable."
- if(QDELETED(bay) || bay.outpost != outpost || !(bay in outpost.bay_berths) || !bay.is_ship_present())
- return "Dock your ship in a ship bay first."
- var/obj/structure/overmap/ship/ship = bay.ship
- if(!ship.is_ship_captain(user) || ship.abandoned || ship.retired_by_checkpoint)
- return "Only the ship's current captain can save it."
- if(ship.checkpoint_rebuilding)
- return "Recovery is already in progress."
- var/datum/ship_checkpoint/previous = outpost.checkpoint_for(user)
- if(previous?.busy)
- return "Your checkpoint is being rebuilt."
- var/datum/ship_checkpoint/ship_checkpoint = ship.checkpoint_ref?.resolve()
- if(ship_checkpoint && ship_checkpoint != previous)
- return "This ship already has another checkpoint."
- if(length(outpost.checkpoints) >= OUTPOST_MAX_CHECKPOINTS && !previous)
- return "This outpost's checkpoint storage is full."
- if(QDELETED(ship.ship_account))
- return "The ship bank account is unavailable."
- return null
-
-/datum/ship_checkpoint_ui/proc/prepare_save(mob/living/user, datum/outpost_berth/ship_bay/bay)
- error = save_denial(user, bay)
- if(error || working)
- return FALSE
- working = TRUE
- notice = null
- QDEL_NULL(quote)
- var/datum/ship_checkpoint/snapshot = new
- error = snapshot.capture(bay.ship, user)
- if(!error)
- error = save_denial(user, bay)
- if(error || QDELETED(src))
- qdel(snapshot)
- working = FALSE
- return FALSE
- quote = snapshot
- quoted_bay = WEAKREF(bay)
- quoted_account = WEAKREF(bay.ship.ship_account)
- var/datum/ship_checkpoint/previous = outpost.checkpoint_for(user)
- quoted_checkpoint = previous ? WEAKREF(previous) : null
- quoted_owner = outpost.founder_ckey
- quoted_fee = previous ? OUTPOST_CHECKPOINT_UPDATE_COST : OUTPOST_CHECKPOINT_SAVE_COST
- working = FALSE
- return TRUE
-
-/// Shared by the invoice and transaction; rechecked after captain confirmation.
-/datum/ship_checkpoint_ui/proc/quote_denial(mob/living/user)
- if(QDELETED(quote))
- return "Prepare a quote first."
- var/datum/outpost_berth/ship_bay/bay = quoted_bay?.resolve()
- var/denial = save_denial(user, bay)
- if(denial)
- return denial
- var/obj/structure/overmap/ship/ship = quote.source_ship.resolve()
- if(bay.ship != ship || quote.captain_ckey != user.ckey)
- return "The docked ship changed. Try again."
- if(quoted_owner != outpost.founder_ckey || quoted_account?.resolve() != ship.ship_account)
- return "The payment account changed. Try again."
- var/datum/ship_checkpoint/previous = outpost.checkpoint_for(user)
- if(quoted_checkpoint != (previous ? WEAKREF(previous) : null))
- return "Your checkpoint changed. Try again."
- if(!ship.ship_account.has_money(quoted_fee))
- return "The ship account has insufficient credits."
- return null
-
-/datum/ship_checkpoint_ui/proc/confirm_save(mob/user, prompt_text, title = "Ship checkpoint")
+/datum/ship_checkpoint_ui/proc/confirm_save(mob/user, prompt_text, title = "Shipyard")
return tgui_alert(user, prompt_text, title, list("Confirm", "Cancel")) == "Confirm"
-/datum/ship_checkpoint_ui/proc/save_quote(mob/living/user)
- if(working || !quote)
- return FALSE
- error = quote_denial(user)
- if(error)
- return FALSE
- var/datum/ship_checkpoint/snapshot = quote
- working = TRUE
- var/accepted = confirm_save(user, "[quoted_checkpoint ? "Update" : "Save"] your checkpoint for [quoted_fee] credits from the ship account? This service fee is spent, not deposited into the outpost treasury. Save [snapshot.ship_name] as your checkpoint[quoted_checkpoint ? ", replacing the previous one" : ""]. One prepaid rebuild includes constructible machinery and fitted upgrades, charged batteries and engine fuel. Cargo, ammunition, stored materials and other supplies are excluded.")
- if(QDELETED(src))
- return FALSE
- working = FALSE
- if(!accepted || quote != snapshot || QDELETED(snapshot))
- return FALSE
- error = quote_denial(user)
- if(error)
- return FALSE
- var/obj/structure/overmap/ship/ship = snapshot.source_ship.resolve()
- // No yields between the final checks, payment and replacing the checkpoint.
- if(!ship.ship_account.adjust_money(-quoted_fee, "Ship checkpoint at [outpost.name], approved by [user.ckey]"))
- error = "The ship account payment was declined."
- return FALSE
- var/datum/ship_checkpoint/previous = outpost.checkpoint_for(user)
- if(previous)
- qdel(previous)
- snapshot.outpost = outpost
- outpost.checkpoints += snapshot
- ship.checkpoint_ref = WEAKREF(snapshot)
- quote = null
- notice = "Checkpoint saved for [snapshot.ship_name]. One prepaid rebuild available."
- log_game("[key_name(user)] saved a checkpoint for [ship.name] at [outpost.name] for [quoted_fee] credits.")
- return TRUE
-
/datum/ship_checkpoint_ui/ui_data(mob/user)
- var/list/bays = list()
- for(var/datum/outpost_berth/ship_bay/bay as anything in outpost.bay_berths)
- if(!bay?.ship?.is_ship_captain(user))
- continue
- bays += list(list("ref" = REF(bay), "name" = bay.ship.name, "number" = bay.bay_number, "denial" = save_denial(user, bay), "balance" = bay.ship.ship_account?.account_balance || 0))
- var/list/blueprints = list()
- for(var/datum/ship_checkpoint/snapshot as anything in outpost.checkpoints)
- if(snapshot.captain_ckey != user.ckey)
- continue
- blueprints += list(list("ref" = REF(snapshot), "name" = snapshot.ship_name, "width" = snapshot.width, "height" = snapshot.height, "denial" = rebuild_denial(user, snapshot)))
var/list/rebuilds = list()
for(var/datum/checkpoint_construction/job as anything in outpost.checkpoint_jobs)
if(job.captain_ckey == user.ckey)
rebuilds += list(job.rebuild_ui_data())
return list(
"outpost" = outpost.name,
- "bays" = bays,
- "blueprints" = blueprints,
"rebuilds" = rebuilds,
"working" = working,
"error" = error,
"notice" = notice,
- "save_cost" = OUTPOST_CHECKPOINT_SAVE_COST,
- "update_cost" = OUTPOST_CHECKPOINT_UPDATE_COST,
- "has_checkpoint" = !!outpost.checkpoint_for(user),
"shop" = shop_data(user),
"saved_ships" = saved_ships_data(user),
)
@@ -273,17 +150,6 @@
if(working)
return
switch(action)
- if("save", "update")
- var/has_checkpoint = !!outpost.checkpoint_for(user)
- if((action == "update") != has_checkpoint)
- error = "Your checkpoint changed. Please try again."
- return TRUE
- var/datum/outpost_berth/ship_bay/bay = locate(params["ref"]) in outpost.bay_berths
- if(prepare_save(user, bay))
- save_quote(user)
- if("rebuild")
- var/datum/ship_checkpoint/snapshot = locate(params["ref"]) in outpost.checkpoints
- rebuild(user, snapshot)
if("ship_save_store")
var/datum/outpost_berth/ship_bay/bay = locate(params["ref"]) in outpost.bay_berths
save_ship(user, bay)
@@ -303,7 +169,7 @@
/datum/techweb_node/ship_checkpoint
id = "ship_checkpoint"
- display_name = "Ship Checkpoints"
- description = "Save and recover ships at outposts."
+ display_name = "Outpost Shipyards"
+ description = "Order and save ships at outposts."
starting_node = TRUE
design_ids = list("ship_checkpoint")
diff --git a/voidcrew/modules/player_outposts/outpost_ship_orders.dm b/voidcrew/modules/player_outposts/outpost_ship_orders.dm
index 2459458c5dd..5b8b8f07613 100644
--- a/voidcrew/modules/player_outposts/outpost_ship_orders.dm
+++ b/voidcrew/modules/player_outposts/outpost_ship_orders.dm
@@ -2,7 +2,7 @@
* # Shipyard orders
*
* A new ship bought for credits at the shipyard console and built in the ship bay by its
- * drones, through the same staged construction that rebuilds checkpoints. The catalog is the
+ * drones, through the same staged construction that builds saved ships. The catalog is the
* lobby shipyard's: every hull, theme and module a player could pick there, with the same
* previews. Credits replace parts entirely: nothing needs unlocking.
*
@@ -155,7 +155,7 @@ GLOBAL_LIST_INIT(ship_order_refused_hulls, typecacheof(list(
// ===== CONSTRUCTION =====
-/// Builds a new ship from an order. Everything but the source is the checkpoint rebuild's.
+/// Builds a new ship from an order. Everything but the source is the base construction's.
/datum/checkpoint_construction/order
status_verb = "Building"
build_noun = "Construction"
@@ -178,7 +178,6 @@ GLOBAL_LIST_INIT(ship_order_refused_hulls, typecacheof(list(
/datum/checkpoint_construction/order/New(datum/ship_checkpoint_ui/terminal, obj/structure/overmap/dynamic/player_outpost/site, datum/ship_order/new_order, mob/living/buyer, datum/bank_account/payer, obj/structure/overmap/ship/payer_ship, manual_drive = FALSE, free_build = FALSE)
panel_ref = terminal ? WEAKREF(terminal) : null
- operator_ref = WEAKREF(buyer)
order = new_order
home = site
captain_ckey = buyer?.ckey
diff --git a/voidcrew/modules/player_outposts/player_outpost.dm b/voidcrew/modules/player_outposts/player_outpost.dm
index c1b9d3c3a3f..4116b11388a 100644
--- a/voidcrew/modules/player_outposts/player_outpost.dm
+++ b/voidcrew/modules/player_outposts/player_outpost.dm
@@ -90,9 +90,6 @@ GLOBAL_LIST_EMPTY(player_outposts)
for(var/key in level_zones)
var/datum/outpost_zone/zone = level_zones[key]
zone.retired = TRUE
- var/list/retired_registrations = checkpoints
- checkpoints = list()
- QDEL_LIST(retired_registrations)
QDEL_LIST_ASSOC_VAL(outpost_upgrades)
GLOB.player_outposts -= src
for(var/datum/outpost_berth/ship_bay/bay as anything in bay_berths.Copy())
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index d4e95fd011b..371467ad3df 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -57,12 +57,21 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
return TRUE
return FALSE
-/// What a saved ship keeps as it loads: a rebuild's basics without plasma tanks, or a hull full of
-/// them would be gas to sell next round. Its engines run on the heater reserve instead.
+/// Basics a saved ship keeps as they load, full: breathable air tanks, welding fuel and water tanks, and
+/// plumbing water. Every other supply is scrubbed. Not plasma tanks, or a hull full of them would be gas
+/// to sell next round: its engines run on the heater reserve instead.
/proc/ship_save_restocked()
var/static/list/restocked
if(!restocked)
- restocked = GLOB.outpost_checkpoint_restocked - typecacheof(/obj/machinery/atmospherics/components/tank/plasma)
+ restocked = typecacheof(list(
+ /obj/machinery/atmospherics/components/tank/air,
+ /obj/machinery/atmospherics/components/tank/oxygen,
+ /obj/machinery/atmospherics/components/tank/nitrogen,
+ /obj/structure/reagent_dispensers/fueltank,
+ /obj/structure/reagent_dispensers/watertank,
+ /obj/machinery/shower,
+ /obj/structure/sink,
+ ))
return restocked
/// Text macros are raw bytes the database connection would mangle. A saved name goes without them.
@@ -168,7 +177,6 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
if(!istext(data["tgm"]) || !isnum(data["port_x"]) || !isnum(data["port_y"]) || !islist(data["rooms"]))
return null
var/datum/ship_checkpoint/snapshot = new
- snapshot.persistent = TRUE
snapshot.ship_name = data["name"]
snapshot.class_name = data["class"]
snapshot.width = width
diff --git a/voidcrew/modules/ship_saves/ship_save_console.dm b/voidcrew/modules/ship_saves/ship_save_console.dm
index 5e9b72c0c51..3d838b50186 100644
--- a/voidcrew/modules/ship_saves/ship_save_console.dm
+++ b/voidcrew/modules/ship_saves/ship_save_console.dm
@@ -39,7 +39,6 @@
var/ship_name = ship.name
var/player_ckey = user.ckey
var/datum/ship_checkpoint/snapshot = new
- snapshot.persistent = TRUE
var/failure = snapshot.capture(ship, user) || saved_ship_changed_denial(user, bay, ship)
if(!failure)
var/price = GLOB.ship_saves.price_for(snapshot.owned_tile_count())
diff --git a/voidcrew/modules/ship_saves/ship_save_construction.dm b/voidcrew/modules/ship_saves/ship_save_construction.dm
index 322380f9867..c60940b0e04 100644
--- a/voidcrew/modules/ship_saves/ship_save_construction.dm
+++ b/voidcrew/modules/ship_saves/ship_save_construction.dm
@@ -1,4 +1,4 @@
-/// Drones build a saved ship in a ship bay. Everything but the source is the checkpoint rebuild's.
+/// Drones build a saved ship in a ship bay. Everything but the source is the base construction's.
/datum/checkpoint_construction/saved
status_verb = "Building"
build_noun = "Construction"
@@ -11,15 +11,6 @@
// There is no hull to retire: this one was saved rounds ago.
..(terminal, blueprint, user, manual_drive, TRUE)
-/// The snapshot is this job's own decoded copy. The save itself is in the table.
-/datum/checkpoint_construction/saved/Destroy(force)
- var/datum/ship_checkpoint/transient = snapshot
- . = ..()
- if(. == QDEL_HINT_LETMELIVE)
- return
- if(transient)
- qdel(transient)
-
/datum/checkpoint_construction/saved/source_denial()
if(QDELETED(snapshot))
return "This record can't be read."
diff --git a/voidcrew/modules/unit_tests/voidcrew_checkpoint_loading.dm b/voidcrew/modules/unit_tests/voidcrew_checkpoint_loading.dm
index 9138c0eb23b..734ca2d8992 100644
--- a/voidcrew/modules/unit_tests/voidcrew_checkpoint_loading.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_checkpoint_loading.dm
@@ -118,8 +118,6 @@
original.shuttle.admin_delete_shuttle()
return "not saved: [denial]"
snapshot.outpost = home
- home.checkpoints += snapshot
- original.checkpoint_ref = WEAKREF(snapshot)
return snapshot
/// Moves the saved original off its bay and deletes it, as a lost hull would be.
@@ -168,7 +166,6 @@
. += "\n stock: [stock.load_summary]\n spread: [spread.load_summary]"
qdel(stock)
qdel(spread)
- home.checkpoints -= snapshot
qdel(snapshot)
if(original && !QDELETED(original.shuttle))
original.shuttle.admin_delete_shuttle()
@@ -428,7 +425,7 @@
/**
* A rebuild that cannot get the shared loader waits for it, well past the old 30 second limit,
- * keeping its bay. One deleted while it waits releases everything and keeps its checkpoint.
+ * keeping its bay. One deleted while it waits releases everything and cleans up its snapshot.
*/
/datum/unit_test/voidcrew_checkpoints/queued_rebuild
var/datum/shuttle_template_load/held
@@ -460,10 +457,13 @@
var/mob/living/carbon/human/captain = make_player(run_loc_floor_bottom_left, "queuedcaptain")
var/datum/ship_checkpoint/snapshot = save_class(home, captain, smallest)
TEST_ASSERT(istype(snapshot), "[smallest] could not be saved: [snapshot]")
+ // Each job owns its snapshot, and the original is gone before the second one starts.
+ var/datum/ship_checkpoint/second_snapshot = capture_copy(home, snapshot.source_ship.resolve(), captain.ckey)
+ TEST_ASSERT_NOTNULL(second_snapshot, "The second snapshot could not be taken")
TEST_ASSERT(lose_original(home, snapshot.source_ship.resolve()), "The original could not be removed")
var/datum/outpost_berth/ship_bay/bay = home.bay_berths[1]
- // Deleted while waiting: nothing is kept but the checkpoint.
+ // Deleted while waiting: nothing is kept.
held = SSshuttle.acquire_template_load()
var/datum/checkpoint_construction/cancelled = new(null, snapshot, captain, TRUE)
INVOKE_ASYNC(src, PROC_REF(prepare_job), cancelled)
@@ -471,17 +471,16 @@
TEST_ASSERT_EQUAL(cancelled.status_line(), "Waiting for the shipyard", "A waiting rebuild reported the wrong status")
TEST_ASSERT_EQUAL(bay.status_text(), "Queued", "A waiting rebuild's bay sign is wrong")
TEST_ASSERT(!bay.is_available(), "A waiting rebuild released its bay")
- TEST_ASSERT(snapshot.busy, "A waiting rebuild left its checkpoint free for another")
qdel(cancelled)
var/deadline = world.time + 10 SECONDS
UNTIL(!length(SSshuttle.background_template_waiters) || world.time > deadline)
TEST_ASSERT(!length(SSshuttle.background_template_waiters), "A deleted rebuild stayed in the loader queue")
TEST_ASSERT(bay.is_available(), "A deleted rebuild kept its bay")
- TEST_ASSERT(!QDELETED(snapshot) && (snapshot in home.checkpoints) && !snapshot.busy, "A deleted rebuild lost or held its checkpoint")
+ TEST_ASSERT(QDELETED(snapshot), "A deleted rebuild left its snapshot behind")
TEST_ASSERT_EQUAL(SSshuttle.active_template_load, held, "A deleted rebuild disturbed the loader's owner")
// Waits as long as it takes, then starts.
- var/datum/checkpoint_construction/patient = new(null, snapshot, captain, TRUE)
+ var/datum/checkpoint_construction/patient = new(null, second_snapshot, captain, TRUE)
INVOKE_ASYNC(src, PROC_REF(prepare_job), patient)
TEST_ASSERT(patient.queued, "The second rebuild did not wait for the busy loader")
sleep(35 SECONDS)
@@ -492,9 +491,9 @@
UNTIL(prepared[patient] || world.time > deadline)
TEST_ASSERT_EQUAL(prepared[patient], "started", "The waiting rebuild did not start once the loader was free: [patient.error]")
TEST_ASSERT_NULL(SSshuttle.active_template_load, "A started rebuild kept the loader")
- // Stopped before its first piece: the checkpoint survives.
+ // Stopped before its first piece: the bay is free and the snapshot cleaned up.
patient.abort("Test cancelled.")
- TEST_ASSERT(!QDELETED(snapshot) && (snapshot in home.checkpoints) && !snapshot.busy, "A rebuild stopped before its first piece lost its checkpoint")
+ TEST_ASSERT(QDELETED(second_snapshot), "A rebuild stopped before its first piece left its snapshot behind")
TEST_ASSERT(bay.is_available(), "A stopped rebuild kept its bay")
/datum/unit_test/voidcrew_checkpoints/queued_rebuild/proc/prepare_job(datum/checkpoint_construction/job)
diff --git a/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm b/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
index 28af34ce87c..322ec7e25fb 100644
--- a/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
@@ -1,17 +1,12 @@
/// Confirmations exercise the real save transaction without a connected client.
/datum/ship_checkpoint_ui/registry_test
- var/datum/callback/during_confirmation
var/accept_save = TRUE
/// Most cases place visits directly; one lets the real drones work.
var/manual_jobs = TRUE
/datum/ship_checkpoint_ui/registry_test/confirm_save(mob/user, prompt_text)
- during_confirmation?.Invoke()
return accept_save
-/datum/ship_checkpoint_ui/registry_test/create_rebuild_job(mob/living/user, datum/ship_checkpoint/snapshot)
- return new /datum/checkpoint_construction(src, snapshot, user, manual_jobs)
-
/// Refuse the bay reservation to exercise a rebuild refused before anything loads.
/obj/structure/overmap/dynamic/player_outpost/registry_test
var/refuse_bay = FALSE
@@ -72,8 +67,16 @@
qdel(ship)
return ..()
-/datum/unit_test/voidcrew_checkpoints/proc/drain_account(obj/structure/overmap/ship/ship)
- ship.ship_account.account_balance = 0
+/// A snapshot straight off a hull. The job built from it owns it.
+/datum/unit_test/voidcrew_checkpoints/proc/capture_copy(obj/structure/overmap/dynamic/player_outpost/home, obj/structure/overmap/ship/ship, owner_ckey)
+ var/datum/ship_checkpoint/snapshot = new
+ var/denial = snapshot.capture_hull(ship, owner_ckey)
+ if(denial)
+ TEST_FAIL("Could not capture [ship]: [denial]")
+ qdel(snapshot)
+ return null
+ snapshot.outpost = home
+ return snapshot
/datum/unit_test/voidcrew_checkpoints/proc/count_hull(obj/docking_port/mobile/port)
var/list/counts = list()
@@ -86,11 +89,21 @@
var/obj/machinery/machine = object
if(machine.checkpoint_type())
counts["[machine.checkpoint_type()]"]++
- else if(outpost_checkpoint_saves(object))
- // Storage shells intentionally omit the original loot-spawner subtype.
- counts[istype(object, /obj/structure/closet) ? "closet:[object.name]" : "[object.type]"]++
+ else if(outpost_checkpoint_saves(object) && !ship_save_refuses_object(object))
+ counts[census_key(object)]++
return counts
+/// What a capture brings a fitting back as: see strip_salvage().
+/datum/unit_test/voidcrew_checkpoints/proc/census_key(obj/object)
+ // Storage shells intentionally omit the original loot-spawner subtype.
+ if(istype(object, /obj/structure/closet))
+ return "closet:[ship_save_plain_text(object.name)]"
+ if(istype(object, /obj/structure/falsewall))
+ return "[/obj/structure/falsewall]"
+ if(istype(object, /obj/structure/mineral_door))
+ return "[/obj/structure/mineral_door/iron]"
+ return "[object.type]"
+
/// Every turf of the permanent bay interior.
/datum/unit_test/voidcrew_checkpoints/proc/bay_turfs(datum/outpost_berth/ship_bay/bay)
return bay.get_block()
@@ -148,10 +161,10 @@
return engine.return_fuel_cap() && engine.return_fuel() >= engine.return_fuel_cap() * 0.99
return TRUE
-/// Restocked air and plasma tanks share their gas with the pipes they feed.
+/// Restocked air tanks share their gas with the pipes they feed.
/datum/unit_test/voidcrew_checkpoints/proc/fed_by_restocked_tank(datum/pipeline/network)
for(var/obj/machinery/atmospherics/components/tank/supply in network.other_atmos_machines)
- if(is_type_in_typecache(supply, GLOB.outpost_checkpoint_restocked))
+ if(is_type_in_typecache(supply, ship_save_restocked()))
return TRUE
return FALSE
@@ -171,7 +184,7 @@
home.founder_ckey = "registrycaptain"
TEST_ASSERT(home.load_level(), "The registry outpost did not load")
TEST_ASSERT_NULL(home.enable_ship_bays(), "The permanent recovery bay did not load")
- // Checkpoints are saved and rebuilt at the ship bay's own shipyard console.
+ // Ships are built at the ship bay's own shipyard console.
terminal = outpost_bay_shipyard_console(LAZYACCESS(home.bay_berths, 1))
TEST_ASSERT_NOTNULL(terminal, "The ship bay has no shipyard console")
terminal_turf = get_turf(terminal)
@@ -201,7 +214,7 @@
TEST_ASSERT(bay.is_ship_present(), "The real source hull did not land in the bay")
var/obj/machinery/ore_silo/silo = home.ship_bay_silo()
TEST_ASSERT_NOTNULL(silo, "The mapped outpost silo was not discovered without a construction link")
- // No selected silo, no materials and no grants may be required by checkpoints.
+ // No selected silo, no materials and no grants may be required by a build.
bay.revoke_silo()
bay.console.disconnect_materials()
var/turf/fixture_turf
@@ -210,18 +223,20 @@
fixture_turf = tile
break
var/obj/machinery/autolathe/lathe = new(fixture_turf)
- lathe.name = "checkpoint upgrade fixture"
+ lathe.name = "checkpoint fixture"
+ // Upgrades do not carry over: what is rebuilt is the machine as it first comes.
+ var/default_bin_rating = lathe.total_part_rating(/datum/stock_part/matter_bin)
for(var/datum/stock_part/matter_bin/part in lathe.component_parts.Copy())
lathe.component_parts -= part
lathe.component_parts += GLOB.stock_part_datums[/datum/stock_part/matter_bin/tier4]
lathe.RefreshParts()
- var/expected_bin_rating = lathe.total_part_rating(/datum/stock_part/matter_bin)
+ TEST_ASSERT(lathe.total_part_rating(/datum/stock_part/matter_bin) > default_bin_rating, "The fixture lathe was not upgraded")
lathe.materials.insert_amount_mat(5000, /datum/material/iron)
var/obj/machinery/computer/camera_advanced/base_construction/ship/builder = new(fixture_turf)
for(var/disk_type in list(/obj/item/ship_construction_upgrade/rtd, /obj/item/ship_construction_upgrade/rpd, /obj/item/ship_construction_upgrade/rld, /obj/item/ship_construction_upgrade/decal, /obj/item/ship_construction_upgrade/servo/mk4))
var/obj/item/disk = new disk_type(builder)
builder.item_interaction(captain, disk)
- var/expected_construction_upgrades = builder.console_upgrades
+ TEST_ASSERT(builder.console_upgrades, "The fixture construction console was not upgraded")
new /obj/machinery/power/shuttle_engine/ship/liquid/oil(fixture_turf)
new /obj/machinery/vending/cola(fixture_turf)
var/obj/machinery/ore_silo/ship_silo = new(fixture_turf)
@@ -246,16 +261,22 @@
removed_smes_cells = TRUE
bank.RefreshParts()
var/datum/ship_checkpoint_ui/registry_test/panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
- TEST_ASSERT_NOTNULL(panel.save_denial(visitor, bay), "A non-captain could register the hull")
- TEST_ASSERT(panel.prepare_save(captain, bay), "Could not prepare a real hull: [panel.error]")
- TEST_ASSERT(!findtext(panel.quote.tgm, "/obj/item"), "The snapshot includes items")
- TEST_ASSERT(findtext(panel.quote.tgm, "/obj/machinery/computer/helm"), "Checkpoint omitted the helm")
- TEST_ASSERT(!findtext(panel.quote.tgm, "initial_gas_mix"), "The snapshot preserved room gases")
- var/datum/parsed_map/parsed = new(panel.quote.tgm)
- TEST_ASSERT_EQUAL(parsed.bounds[4], panel.quote.width, "Exported width does not parse correctly")
- TEST_ASSERT_EQUAL(parsed.bounds[5], panel.quote.height, "Exported height does not parse correctly")
+ var/datum/ship_checkpoint/refused_capture = allocate(/datum/ship_checkpoint)
+ TEST_ASSERT_NOTNULL(refused_capture.capture(original, visitor), "A non-captain could capture the hull")
+ var/datum/ship_checkpoint/snapshot = new
+ var/capture_denial = snapshot.capture(original, captain)
+ TEST_ASSERT_NULL(capture_denial, "Could not capture a real hull: [capture_denial]")
+ snapshot.outpost = home
+ // Map text starts each atom on a new line; a stripped wall's sheet_type is a property, not an item.
+ TEST_ASSERT(!findtext(snapshot.tgm, "\n/obj/item"), "The snapshot includes items")
+ TEST_ASSERT(findtext(snapshot.tgm, "/obj/machinery/computer/helm"), "The snapshot omitted the helm")
+ TEST_ASSERT(!findtext(snapshot.tgm, "initial_gas_mix"), "The snapshot preserved room gases")
+ TEST_ASSERT(findtext(snapshot.tgm, "checkpoint fixture"), "The snapshot lost the fixture's name")
+ var/datum/parsed_map/parsed = new(snapshot.tgm)
+ TEST_ASSERT_EQUAL(parsed.bounds[4], snapshot.width, "Exported width does not parse correctly")
+ TEST_ASSERT_EQUAL(parsed.bounds[5], snapshot.height, "Exported height does not parse correctly")
qdel(parsed)
- rustg_file_write(panel.quote.tgm, "data/registry-source-hull.dmm")
+ rustg_file_write(snapshot.tgm, "data/registry-source-hull.dmm")
var/list/before_counts = count_hull(original.shuttle)
TEST_ASSERT(before_counts["[/obj/machinery/cryopod]"], "The source hull has no cryopod to recover")
var/list/dropped = list()
@@ -271,49 +292,12 @@
var/list/dropped_text = list()
for(var/type_name in dropped)
dropped_text += "[type_name] x[dropped[type_name]]"
- log_test("Machinery and structures a checkpoint of this hull leaves out: [length(dropped_text) ? dropped_text.Join(", ") : "none"]")
- var/room_count = length(panel.quote.rooms)
- var/before_iron = silo.materials.get_material_amount(/datum/material/iron)
- var/before_money = original.ship_account.account_balance
- var/before_treasury = home.treasury.account_balance
- panel.accept_save = FALSE
- TEST_ASSERT(!panel.save_quote(captain), "Cancelling confirmation still saved a checkpoint")
- TEST_ASSERT_EQUAL(original.ship_account.account_balance, before_money, "Cancellation charged credits")
- panel.accept_save = TRUE
- panel.during_confirmation = CALLBACK(src, PROC_REF(drain_account), original)
- TEST_ASSERT(!panel.save_quote(captain), "Funds spent during confirmation were charged again")
- TEST_ASSERT_EQUAL(length(home.checkpoints), 0, "A declined save created a checkpoint")
- panel.during_confirmation = null
- original.ship_account.account_balance = before_money
- var/datum/ship_checkpoint_ui/registry_test/other_panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
- TEST_ASSERT(other_panel.prepare_save(captain, bay), "Could not prepare a concurrent save")
- TEST_ASSERT_EQUAL(panel.quoted_fee, 10000, "First checkpoint did not cost 10,000 credits")
- TEST_ASSERT(panel.save_quote(captain), "Could not save a checkpoint without silo access: [panel.error]")
- TEST_ASSERT_EQUAL(before_money - original.ship_account.account_balance, 10000, "Save charged the wrong amount")
- TEST_ASSERT_EQUAL(home.treasury.account_balance, before_treasury, "Checkpoint fee was refunded through the outpost treasury")
- TEST_ASSERT(!other_panel.save_quote(captain), "A stale save overwrote the paid checkpoint")
- var/datum/ship_checkpoint/snapshot = home.checkpoints[1]
- TEST_ASSERT(!panel.save_quote(captain), "A repeated save reused the paid snapshot")
- // Updating must capture the newest design, charge 5k and preserve the old one on failure.
- lathe.name = "updated checkpoint fixture"
- TEST_ASSERT(panel.prepare_save(captain, bay), "Could not prepare an update")
- TEST_ASSERT_EQUAL(panel.quoted_fee, 5000, "Updating did not cost 5,000 credits")
- panel.accept_save = FALSE
- TEST_ASSERT(!panel.save_quote(captain) && home.checkpoints[1] == snapshot, "Cancelled update lost the old checkpoint")
- panel.accept_save = TRUE
- original.ship_account.account_balance = 4999
- TEST_ASSERT(!panel.save_quote(captain) && home.checkpoints[1] == snapshot, "Unaffordable update lost the old checkpoint")
- original.ship_account.account_balance = before_money - 10000
- TEST_ASSERT(panel.save_quote(captain), "Could not update a checkpoint")
- TEST_ASSERT(QDELETED(snapshot), "Update retained the old snapshot")
- TEST_ASSERT_EQUAL(length(home.checkpoints), 1, "Updating created a second checkpoint")
- TEST_ASSERT_EQUAL(before_money - original.ship_account.account_balance, 15000, "Save and update charged the wrong total")
- TEST_ASSERT_EQUAL(home.treasury.account_balance, before_treasury, "Update fee was refunded through the outpost treasury")
- TEST_ASSERT_EQUAL(silo.materials.get_material_amount(/datum/material/iron), before_iron, "Checkpoint consumed silo materials")
- snapshot = home.checkpoints[1]
- TEST_ASSERT(findtext(snapshot.tgm, "updated checkpoint fixture"), "Update retained the older design")
- TEST_ASSERT_NOTNULL(panel.rebuild_denial(captain, snapshot), "A crewed original permitted a second hull")
- TEST_ASSERT_NOTNULL(panel.rebuild_denial(visitor, snapshot), "A non-captain could recover another captain's hull")
+ log_test("Machinery and structures a capture of this hull leaves out: [length(dropped_text) ? dropped_text.Join(", ") : "none"]")
+ var/room_count = length(snapshot.rooms)
+ // Jobs that stop before their first piece use up a snapshot too, so they get their own.
+ var/datum/ship_checkpoint/in_service_source = capture_copy(home, original, captain.ckey)
+ var/datum/ship_checkpoint/refused_source = destroy_original ? null : capture_copy(home, original, captain.ckey)
+ var/datum/ship_checkpoint/stopped_source = (destroy_original || orphaned_original) ? null : capture_copy(home, original, captain.ckey)
// Move the original out of its construction bay, then abandon it.
var/obj/docking_port/stationary/transit/source_transit = original.shuttle.assigned_transit || SSshuttle.generate_transit_dock(original.shuttle)
original.shuttle.mode = SHUTTLE_PREARRIVAL
@@ -325,6 +309,13 @@
home.on_ship_undock_complete(original)
TEST_ASSERT_EQUAL(home.bay_berths[1], bay, "Departure replaced the permanent bay")
TEST_ASSERT(bay.is_available(), "Departure retained the old ship reservation")
+ // Crewed and flying: a job will not build a second hull from it.
+ var/datum/checkpoint_construction/in_service = new(panel, in_service_source, captain, TRUE)
+ TEST_ASSERT_NOTNULL(in_service.bay, "A job could not reserve the empty bay")
+ TEST_ASSERT_NOTNULL(in_service.source_denial(), "A crewed original permitted a second hull")
+ TEST_ASSERT(original.checkpoint_rebuilding, "A job did not lock its original hull")
+ qdel(in_service)
+ TEST_ASSERT(bay.is_available() && !original.checkpoint_rebuilding, "A cancelled job kept the bay or a lock on the original")
var/old_balance = original.ship_account.account_balance
if(orphaned_original)
// Reproduce the old admin deletion's resulting state without its known
@@ -332,16 +323,8 @@
var/obj/docking_port/mobile/old_port = original.detach_shuttle()
old_port.jumpToNullSpace()
TEST_ASSERT(!QDELETED(original) && !original.abandoned && !original.shuttle, "The orphan fixture still has a hull or was abandoned")
- TEST_ASSERT_NULL(panel.rebuild_denial(captain, snapshot), "A hull-less overmap record blocks recovery")
- original.retired_by_checkpoint = TRUE
- TEST_ASSERT_NOTNULL(panel.rebuild_denial(captain, snapshot), "A retired hull-less record bypassed duplicate protection")
- original.retired_by_checkpoint = FALSE
- original.checkpoint_rebuilding = TRUE
- TEST_ASSERT_NOTNULL(panel.rebuild_denial(captain, snapshot), "A hull-less record bypassed concurrent recovery protection")
- original.checkpoint_rebuilding = FALSE
else if(!destroy_original)
original.abandon_ship(crash = FALSE)
- TEST_ASSERT_NULL(panel.rebuild_denial(captain, snapshot), "The registered captain cannot recover an abandoned hull")
if(destroy_original)
if(delete_via_admin)
var/obj/docking_port/mobile/old_port = original.shuttle
@@ -350,24 +333,22 @@
TEST_ASSERT(!(original in SSovermap.simulated_ships), "Admin deletion retained its fleet entry")
else
qdel(original)
- TEST_ASSERT(snapshot in home.checkpoints, "Deleting the original consumed its paid registration")
+ TEST_ASSERT(!QDELETED(snapshot), "Deleting the original consumed its snapshot")
old_balance = 0
qdel(panel)
captain.key = null
captain = make_player(terminal_turf, "registrycaptain")
panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
- TEST_ASSERT_NULL(panel.rebuild_denial(captain, snapshot), "A new body with the saved captain's key cannot recover a destroyed hull")
var/list/recovery_data = panel.ui_data(captain)
- TEST_ASSERT_EQUAL(length(recovery_data["bays"]), 0, "An empty permanent bay was offered as a docked ship")
- TEST_ASSERT_EQUAL(length(recovery_data["blueprints"]), 1, "The terminal lost the captain's saved hull")
- var/list/saved_hull = recovery_data["blueprints"][1]
- TEST_ASSERT_NULL(saved_hull["denial"], "The recovery button remains disabled after losing the original")
+ TEST_ASSERT_EQUAL(length(recovery_data["saved_ships"]["ships"]), 0, "An empty permanent bay was offered as a docked ship")
var/before_recovery_iron = silo.materials.get_material_amount(/datum/material/iron)
if(!destroy_original)
home.refuse_bay = TRUE
- TEST_ASSERT(!panel.rebuild(captain, snapshot), "A refused bay reservation reported a started rebuild")
- TEST_ASSERT(snapshot in home.checkpoints, "A refused rebuild consumed the registration")
- TEST_ASSERT(!snapshot.busy && !original.checkpoint_rebuilding && !original.retired_by_checkpoint, "A refused rebuild left a recovery or retirement lock")
+ var/datum/checkpoint_construction/refused = new(panel, refused_source, captain, TRUE)
+ TEST_ASSERT_NULL(refused.bay, "A refused bay reservation still gave the job a bay")
+ qdel(refused)
+ TEST_ASSERT(QDELETED(refused_source), "A refused rebuild left its snapshot behind")
+ TEST_ASSERT(!original.checkpoint_rebuilding && !original.retired_by_checkpoint, "A refused rebuild left a recovery or retirement lock")
TEST_ASSERT_NULL(SSshuttle.preview_shuttle, "A refused rebuild leaked its preview ship")
TEST_ASSERT_NULL(SSshuttle.preview_reservation, "A refused rebuild leaked its preview reservation")
TEST_ASSERT_NULL(SSshuttle.active_template_load, "A refused rebuild locked the shuttle loader")
@@ -377,32 +358,41 @@
home.refuse_bay = FALSE
if(!destroy_original && !orphaned_original)
// A hull back in service before the first piece stops the job with nothing built.
- TEST_ASSERT(panel.rebuild(captain, snapshot), "Could not start a reconstruction: [panel.error]")
- var/datum/checkpoint_construction/stopped = home.checkpoint_jobs[1]
+ var/datum/checkpoint_construction/stopped = new(panel, stopped_source, captain, TRUE)
+ TEST_ASSERT(stopped.prepare(), "Could not start a reconstruction: [stopped.error]")
var/obj/docking_port/mobile/voidcrew/stopped_copy = stopped.port
original.abandoned = FALSE
stopped.fast_forward(1)
original.abandoned = TRUE
TEST_ASSERT(QDELETED(stopped) && !length(home.checkpoint_jobs), "A refused commitment left its job running")
TEST_ASSERT(QDELETED(stopped_copy), "A refused commitment leaked its hidden copy")
- TEST_ASSERT((snapshot in home.checkpoints) && !snapshot.busy, "A rebuild stopped before its first piece consumed the checkpoint")
+ TEST_ASSERT(QDELETED(stopped_source), "A rebuild stopped before its first piece left its snapshot behind")
TEST_ASSERT(!original.checkpoint_rebuilding && !original.retired_by_checkpoint, "A stopped rebuild kept a lock on the original")
TEST_ASSERT(bay.is_available() && !bay.dock.get_docked(), "A stopped rebuild kept the bay")
TEST_ASSERT_EQUAL(count_bay_ship_tiles(bay), 0, "A stopped rebuild left pieces in the bay")
TEST_ASSERT_EQUAL(original.ship_account.account_balance, old_balance, "A stopped rebuild moved the original balance")
TEST_ASSERT_NULL(SSshuttle.active_template_load, "A stopped rebuild locked the shuttle loader")
- panel.manual_jobs = manual_jobs
prepare_captain(captain)
- TEST_ASSERT(panel.rebuild(captain, snapshot), "Rebuild failed: [panel.error]")
+ var/datum/checkpoint_construction/job = new(panel, snapshot, captain, manual_jobs)
+ if(orphaned_original)
+ TEST_ASSERT_NULL(job.source_denial(), "A hull-less overmap record blocks recovery")
+ original.retired_by_checkpoint = TRUE
+ TEST_ASSERT_NOTNULL(job.source_denial(), "A retired hull-less record bypassed duplicate protection")
+ original.retired_by_checkpoint = FALSE
+ else if(!destroy_original)
+ TEST_ASSERT_NULL(job.source_denial(), "An abandoned hull blocks recovery")
+ TEST_ASSERT(job.prepare(), "Rebuild failed: [job.error]")
TEST_ASSERT_EQUAL(length(home.checkpoint_jobs), 1, "Reconstruction did not create exactly one job")
- var/datum/checkpoint_construction/job = home.checkpoint_jobs[1]
+ TEST_ASSERT_EQUAL(home.checkpoint_jobs[1], job, "The outpost does not list the job")
+ var/list/console_data = panel.ui_data(captain)
+ TEST_ASSERT_EQUAL(length(console_data["rebuilds"]), 1, "The console does not show the captain's build")
TEST_ASSERT_NULL(SSshuttle.preview_shuttle, "Reconstruction held the shared shuttle preview")
TEST_ASSERT_NULL(SSshuttle.active_template_load, "Reconstruction held the shared shuttle loader")
- TEST_ASSERT(snapshot in home.checkpoints, "The checkpoint was consumed before the first piece")
+ TEST_ASSERT(!QDELETED(snapshot), "The snapshot was consumed before the first piece")
TEST_ASSERT(!bay.is_available(), "The bay was released while reconstruction owned it")
TEST_ASSERT_EQUAL(count_bay_ship_tiles(bay), 0, "The hull appeared before construction started")
TEST_ASSERT_EQUAL(length(job.markers), length(job.hull_indices), "Warning markers do not cover the hull footprint")
- TEST_ASSERT(!panel.rebuild(captain, snapshot), "A second click started another reconstruction")
+ TEST_ASSERT_NULL(home.available_ship_bay(), "The bay was offered while a reconstruction held it")
var/obj/docking_port/mobile/voidcrew/built_port = job.port
captain = build_hull(job, home, bay, captain, original, panel, snapshot)
if(!captain)
@@ -425,9 +415,7 @@
TEST_ASSERT_EQUAL(original.ship_account.account_balance, 0, "The original kept its transferred balance")
TEST_ASSERT(original.retired_by_checkpoint, "The original hull was not retired")
TEST_ASSERT(!original.claim_abandoned_ship(visitor), "A retired hull can still be claimed")
- TEST_ASSERT_EQUAL(length(home.checkpoints), 0, "Successful recovery did not consume the registration")
TEST_ASSERT(QDELETED(snapshot), "Consumed snapshot leaked")
- TEST_ASSERT(!panel.rebuild(captain, snapshot), "The consumed registration rebuilt twice")
TEST_ASSERT_EQUAL(length(home.checkpoint_jobs), 0, "A finished reconstruction stayed listed")
TEST_ASSERT_EQUAL(length(hull_owned_areas(rebuilt.shuttle)), room_count, "Recovery claimed padding or lost a room")
for(var/area/room as anything in rebuilt.shuttle.shuttle_areas)
@@ -475,18 +463,18 @@
if(bank.terminal?.powernet && bank.powernet)
TEST_ASSERT(bank.terminal.powernet != bank.powernet, "[bank] was rebuilt charging from its own output network")
for(var/obj/machinery/autolathe/restored_lathe in tile)
- if(restored_lathe.name == "updated checkpoint fixture")
- TEST_ASSERT_EQUAL(restored_lathe.total_part_rating(/datum/stock_part/matter_bin), expected_bin_rating, "Recovery lost fitted upgrades")
+ if(restored_lathe.name == "checkpoint fixture")
+ TEST_ASSERT_EQUAL(restored_lathe.total_part_rating(/datum/stock_part/matter_bin), default_bin_rating, "A rebuilt machine kept its upgraded parts")
TEST_ASSERT_EQUAL(restored_lathe.materials.get_material_amount(/datum/material/iron), 0, "Recovery copied lathe stock")
for(var/obj/machinery/computer/camera_advanced/base_construction/ship/restored_builder in tile)
- TEST_ASSERT_EQUAL(restored_builder.console_upgrades, expected_construction_upgrades, "Construction console lost its installed upgrades")
- TEST_ASSERT(!QDELETED(restored_builder.internal_rcd) && !QDELETED(restored_builder.internal_rtd) && !QDELETED(restored_builder.internal_rpd) && !QDELETED(restored_builder.internal_rld) && !QDELETED(restored_builder.internal_painter), "Construction console lost its internal tools")
+ TEST_ASSERT_EQUAL(restored_builder.console_upgrades, NONE, "A construction console kept its installed upgrades")
+ TEST_ASSERT(!QDELETED(restored_builder.internal_rcd), "A construction console lost its internal RCD")
+ TEST_ASSERT_NULL(restored_builder.internal_rtd, "A construction console kept its upgrade's internal RTD")
TEST_ASSERT_EQUAL(restored_builder.internal_rcd.matter, 0, "Construction console recovered free material charges")
- TEST_ASSERT_NULL(restored_builder.internal_painter.ink, "Construction console recovered a toner cartridge")
for(var/obj/machinery/ore_silo/restored_silo in tile)
TEST_ASSERT_EQUAL(restored_silo.materials.get_material_amount(/datum/material/iron), 0, "Recovery copied silo materials")
for(var/obj/structure/reagent_dispensers/dispenser in tile)
- if(is_type_in_typecache(dispenser, GLOB.outpost_checkpoint_restocked))
+ if(is_type_in_typecache(dispenser, ship_save_restocked()))
TEST_ASSERT(dispenser.reagents?.total_volume >= dispenser.reagents?.maximum_volume, "[dispenser] came back [dispenser.reagents?.total_volume]/[dispenser.reagents?.maximum_volume], not full")
else
TEST_ASSERT(!dispenser.reagents?.total_volume, "Recovery refilled [dispenser] with [dispenser.reagents?.total_volume] units")
@@ -501,7 +489,7 @@
continue
TEST_ASSERT(!network?.air?.total_moles(), "Rebuilt pipe network at [machine] ([machine.type]) contains [network?.air?.total_moles()] moles of free gas")
for(var/obj/machinery/atmospherics/components/tank/stored_tank in tile)
- if(is_type_in_typecache(stored_tank, GLOB.outpost_checkpoint_restocked))
+ if(is_type_in_typecache(stored_tank, ship_save_restocked()))
TEST_ASSERT(stored_tank.air_contents?.total_moles(), "Rebuilt [stored_tank] ([stored_tank.type]) came back empty")
else
TEST_ASSERT(!stored_tank.air_contents?.total_moles(), "Rebuilt [stored_tank] ([stored_tank.type]) was refilled with [stored_tank.air_contents?.total_moles()] moles")
@@ -545,10 +533,10 @@
/datum/unit_test/voidcrew_checkpoints/staged/build_hull(datum/checkpoint_construction/job, obj/structure/overmap/dynamic/player_outpost/registry_test/home, datum/outpost_berth/ship_bay/bay, mob/living/carbon/human/captain, obj/structure/overmap/ship/original, datum/ship_checkpoint_ui/registry_test/panel, datum/ship_checkpoint/snapshot)
check_bay_refuses_visitors(home, bay, "before the first piece")
TEST_ASSERT_EQUAL(job.state, "marking", "Reconstruction skipped its survey markers")
- // The first visit consumes the checkpoint and moves the frame, then places one tile.
+ // The first visit consumes the snapshot and moves the frame, then places one tile.
job.fast_forward(1)
- TEST_ASSERT(job.committed, "The first piece was placed without consuming the checkpoint")
- TEST_ASSERT(QDELETED(snapshot) && !length(home.checkpoints), "The first piece did not consume the checkpoint")
+ TEST_ASSERT(job.committed, "The first piece was placed without consuming the snapshot")
+ TEST_ASSERT(QDELETED(snapshot), "The first piece did not consume the snapshot")
TEST_ASSERT(original.retired_by_checkpoint, "The first piece did not retire the original hull")
TEST_ASSERT_EQUAL(bay.dock.get_docked(), job.port, "The hull frame did not take the bay")
TEST_ASSERT(job.port in SSshuttle.mobile_docking_ports, "The hull frame was not registered")
@@ -561,7 +549,6 @@
var/list/original_lighting = job.lit_rooms[first_room]
TEST_ASSERT(!job.execute_visit(first), "A completed visit ran twice")
TEST_ASSERT_EQUAL(job.visits_done, 1, "Replaying a visit counted it again")
- TEST_ASSERT(!panel.rebuild(captain, snapshot), "The consumed checkpoint started another reconstruction")
check_bay_refuses_visitors(home, bay, "during construction")
var/highest_stage = 1
var/turf/broken_wall
@@ -598,11 +585,11 @@
if(5) // Fittings: remove a finished machine and interrupt the job part way.
for(var/turf/tile as anything in bay_turfs(bay))
for(var/obj/machinery/autolathe/fixture in tile)
- if(fixture.name == "updated checkpoint fixture")
+ if(fixture.name == "checkpoint fixture")
qdel(fixture)
lathe_removed = TRUE
count_adjustments["[/obj/machinery/autolathe]"] -= 1
- TEST_ASSERT(lathe_removed, "The upgraded fixture was never placed")
+ TEST_ASSERT(lathe_removed, "The fixture was never placed")
// Losing the console, its panel and the captain must not stop or restart the job.
qdel(panel)
qdel(terminal)
@@ -620,7 +607,7 @@
TEST_ASSERT(!iswallturf(broken_wall), "A broken wall was rebuilt")
for(var/turf/tile as anything in bay_turfs(bay))
for(var/obj/machinery/autolathe/fixture in tile)
- TEST_ASSERT(fixture.name != "updated checkpoint fixture", "A removed machine was rebuilt")
+ TEST_ASSERT(fixture.name != "checkpoint fixture", "A removed machine was rebuilt")
return captain
/datum/unit_test/voidcrew_checkpoints/proc/count_bay_infrastructure(datum/outpost_berth/ship_bay/bay)
@@ -646,7 +633,6 @@
TEST_ASSERT(QDELETED(job), "Reconstruction outlived its bay")
TEST_ASSERT(QDELETED(partial_port) && !(partial_port in SSshuttle.mobile_docking_ports), "The partial hull left a registered port behind")
TEST_ASSERT(hidden_copy.state == "wiping" || hidden_copy.state == "vacant", "The hidden copy's shipyard outlived its job ([hidden_copy.state])")
- TEST_ASSERT(!length(home.checkpoints), "Losing the bay restored a consumed checkpoint")
TEST_ASSERT(!original.retired_by_checkpoint && !original.checkpoint_rebuilding, "The original stayed replaced after its replacement was destroyed")
TEST_ASSERT_EQUAL(original.ship_account.account_balance, held, "The original's balance was not returned")
TEST_ASSERT(bystander.stat != DEAD && (get_turf(bystander) in home.get_floor_alcove(0)), "A player on the partial hull was not evacuated")
@@ -665,7 +651,7 @@
var/deadline = world.time + 30 SECONDS
while(!QDELETED(job) && job.visits_done < 40 && world.time < deadline)
sleep(1)
- TEST_ASSERT(!QDELETED(job) && job.committed, "The drones never consumed the checkpoint")
+ TEST_ASSERT(!QDELETED(job) && job.committed, "The drones never consumed the snapshot")
TEST_ASSERT(job.visits_done >= 40, "The drones placed [job.visits_done] visits in 30 seconds")
TEST_ASSERT(count_bay_ship_tiles(bay) > 0 && count_bay_ship_tiles(bay) < length(job.hull_indices), "The drone build was not partial part way through")
// Let the real controller finish and hand over on its own.
diff --git a/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm b/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm
index 23548940c67..f53e0bb3bc6 100644
--- a/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm
@@ -55,8 +55,6 @@
original.shuttle.admin_delete_shuttle()
return "not saved: [denial]"
snapshot.outpost = home
- home.checkpoints += snapshot
- original.checkpoint_ref = WEAKREF(snapshot)
var/list/before_counts = count_hull(original.shuttle)
var/list/dropped = list()
var/list/without_board = list()
@@ -64,7 +62,7 @@
if(!(get_area(tile) in original.shuttle.shuttle_areas))
continue
for(var/obj/object in tile)
- if(!outpost_checkpoint_saves(object))
+ if(!outpost_checkpoint_saves(object) || ship_save_refuses_object(object))
if(ismachinery(object) || isstructure(object))
dropped["[object.type]"]++
else if(ismachinery(object))
@@ -164,12 +162,12 @@
. += "free item [item.type] in [item.loc?.type]"
for(var/obj/object in tile)
// Engine fuel and the basics (fuel and water tanks, plumbing) are restocked on purpose.
- if(object.reagents?.total_volume && !is_type_in_typecache(object, GLOB.outpost_checkpoint_restocked) && !istype(object, /obj/machinery/power/shuttle_engine))
+ if(object.reagents?.total_volume && !is_type_in_typecache(object, ship_save_restocked()) && !istype(object, /obj/machinery/power/shuttle_engine))
. += "[object.type] holds [object.reagents.total_volume] units of reagents"
var/datum/component/material_container/materials = object.GetComponent(/datum/component/material_container)
if(materials?.total_amount())
. += "[object.type] holds [materials.total_amount()] units of materials"
- if(istype(object, /obj/machinery/atmospherics/components/tank) && !is_type_in_typecache(object, GLOB.outpost_checkpoint_restocked))
+ if(istype(object, /obj/machinery/atmospherics/components/tank) && !is_type_in_typecache(object, ship_save_restocked()))
var/obj/machinery/atmospherics/components/tank/stored = object
if(stored.air_contents?.total_moles())
. += "[object.type] holds [stored.air_contents.total_moles()] moles"
@@ -316,7 +314,7 @@
if(!(get_area(tile) in port.shuttle_areas))
continue
for(var/obj/machinery/atmospherics/machine in tile)
- if(is_type_in_typecache(machine, GLOB.outpost_checkpoint_restocked))
+ if(is_type_in_typecache(machine, ship_save_restocked()))
continue
var/moles = 0
if(istype(machine, /obj/machinery/atmospherics/pipe))
diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_admin_bays.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_admin_bays.dm
index 6ffb745a745..6322dac1c17 100644
--- a/voidcrew/modules/unit_tests/voidcrew_outpost_admin_bays.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_outpost_admin_bays.dm
@@ -56,12 +56,6 @@
var/list/first_slot = data["slots"][1]
TEST_ASSERT_EQUAL(first_slot["status"], "Available", "An empty bay is incorrectly marked occupied")
- var/datum/ship_checkpoint/snapshot = allocate(__IMPLIED_TYPE__)
- snapshot.outpost = home
- home.checkpoints += snapshot
- panel.manage_outpost(home, operator, "remove_bays", list())
- TEST_ASSERT(home.ship_bay_installed, "Admin removal stranded a paid hull registration")
- qdel(snapshot)
var/obj/structure/overmap/ship/ship = allocate(__IMPLIED_TYPE__)
SSovermap.simulated_ships |= ship
panel.during_confirmation = CALLBACK(src, PROC_REF(request_bay), home, ship)
diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm
index d21add66ba1..1c5180e476f 100644
--- a/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm
@@ -1,4 +1,4 @@
-/// The Outpost Manipulator's free checkpoint tools, on a real hull in a real bay.
+/// The Outpost Manipulator's free construction tools, on a real hull in a real bay.
/datum/unit_test/voidcrew_outpost_admin_checkpoints
parent_type = /datum/unit_test/voidcrew_outpost_management
var/list/obj/structure/overmap/ship/test_ships = list()
@@ -33,6 +33,17 @@
ship.state = "flying"
home.on_ship_undock_complete(ship)
+/// A snapshot straight off a hull. The job built from it owns it.
+/datum/unit_test/voidcrew_outpost_admin_checkpoints/proc/capture_copy(obj/structure/overmap/dynamic/player_outpost/home, obj/structure/overmap/ship/ship, owner_ckey)
+ var/datum/ship_checkpoint/snapshot = new
+ var/denial = snapshot.capture_hull(ship, owner_ckey)
+ if(denial)
+ TEST_FAIL("Could not capture [ship]: [denial]")
+ qdel(snapshot)
+ return null
+ snapshot.outpost = home
+ return snapshot
+
/// Waits for a rushed job to reach commissioning, or to finish outright.
/datum/unit_test/voidcrew_outpost_admin_checkpoints/proc/wait_for_hull(datum/checkpoint_construction/job)
var/deadline = world.time + 60 SECONDS
@@ -63,20 +74,18 @@
panel.allow_actions = FALSE
panel.manage_outpost(home, operator, "checkpoint_rebuild_docked", list())
- TEST_ASSERT(!length(home.checkpoints) && !QDELETED(original), "A revoked admin panel used the checkpoint tools")
+ TEST_ASSERT(!length(home.checkpoint_jobs) && !QDELETED(original), "A revoked admin panel used the construction tools")
panel.allow_actions = TRUE
- // Free saves: no fee, no captain, one per owner and one per ship.
- var/datum/ship_checkpoint/first = panel.save_free_checkpoint(home, operator, original, "freeowner")
- TEST_ASSERT_NOTNULL(first, "Free save failed: [panel.error]")
- TEST_ASSERT_EQUAL(first.captain_ckey, "freeowner", "The free checkpoint has the wrong owner")
- TEST_ASSERT_EQUAL(original.ship_account.account_balance, 1234, "A free save charged the ship")
- TEST_ASSERT_EQUAL(home.treasury.account_balance, before_treasury, "A free save touched the treasury")
- var/datum/ship_checkpoint/second = panel.save_free_checkpoint(home, operator, original, "freeowner")
- TEST_ASSERT(second && QDELETED(first) && length(home.checkpoints) == 1, "Saving again did not replace the owner's checkpoint")
- TEST_ASSERT_EQUAL(original.checkpoint_ref?.resolve(), second, "The ship is not linked to its checkpoint")
- TEST_ASSERT_NULL(panel.save_free_checkpoint(home, operator, original, "otherowner"), "One ship was saved for two owners")
- TEST_ASSERT(!panel.admin_rebuild(home, operator, second), "A rebuild started while the original filled the bay")
+ // A capture is free and needs no captain.
+ var/datum/ship_checkpoint/second = capture_copy(home, original, "freeowner")
+ TEST_ASSERT_NOTNULL(second, "The capture failed")
+ TEST_ASSERT_EQUAL(second.captain_ckey, "freeowner", "The snapshot has the wrong owner")
+ TEST_ASSERT_EQUAL(original.ship_account.account_balance, 1234, "A capture charged the ship")
+ TEST_ASSERT_EQUAL(home.treasury.account_balance, before_treasury, "A capture touched the treasury")
+ var/datum/checkpoint_construction/blocked = new(null, capture_copy(home, original, "freeowner"), operator)
+ TEST_ASSERT_NULL(blocked.bay, "A rebuild took the bay while the original filled it")
+ qdel(blocked)
// Removing the docked ship moves people out and gives the pad back its own room.
var/turf/deck
@@ -104,10 +113,9 @@
crew.key = null
// A lost original: the normal rebuild, rushed and handed over to an absent owner.
- panel.manage_outpost(home, operator, "checkpoint_rebuild", list("ref" = REF(second)))
- TEST_ASSERT_EQUAL(length(home.checkpoint_jobs), 1, "The admin rebuild did not start: [panel.error]")
- var/datum/checkpoint_construction/job = home.checkpoint_jobs[1]
- TEST_ASSERT(!job.manual, "Admin rebuilds must run on the real controller")
+ var/datum/checkpoint_construction/job = new(null, second, operator)
+ TEST_ASSERT(job.prepare(), "The rebuild did not start: [job.error]")
+ TEST_ASSERT_EQUAL(length(home.checkpoint_jobs), 1, "The rebuild is not listed on the outpost")
var/list/fleet = job.drones.Copy()
panel.manage_outpost(home, operator, "rebuild_rush", list("ref" = REF(job)))
TEST_ASSERT(job.rushed && job.state == "building", "Finish Now did not skip the survey")
@@ -117,7 +125,7 @@
TEST_ASSERT_NOTNULL(rebuilt, "The rebuilt hull did not take the bay")
test_ships += rebuilt
TEST_ASSERT(rebuilt.abandoned, "A hull nobody collected cannot be claimed")
- TEST_ASSERT(!length(home.checkpoints), "The admin rebuild did not use up the checkpoint")
+ TEST_ASSERT(QDELETED(second), "The rebuild did not use up its snapshot")
var/deadline = world.time + 30 SECONDS
var/drones_left = TRUE
while(drones_left && world.time < deadline)
@@ -132,11 +140,11 @@
// An original still in service is left alone: the rebuild is an extra copy.
rebuilt.abandoned = FALSE
rebuilt.ship_account.account_balance = 777
- var/datum/ship_checkpoint/copy_source = panel.save_free_checkpoint(home, operator, rebuilt, "copyowner")
- TEST_ASSERT_NOTNULL(copy_source, "Could not save the rebuilt ship: [panel.error]")
+ var/datum/ship_checkpoint/copy_source = capture_copy(home, rebuilt, "copyowner")
+ TEST_ASSERT_NOTNULL(copy_source, "Could not capture the rebuilt ship")
send_to_transit(home, rebuilt)
- TEST_ASSERT(panel.admin_rebuild(home, operator, copy_source), "The copy rebuild did not start: [panel.error]")
- job = home.checkpoint_jobs[1]
+ job = new(null, copy_source, operator, FALSE, TRUE)
+ TEST_ASSERT(job.prepare(), "The copy rebuild did not start: [job.error]")
TEST_ASSERT_NULL(job.original_ref, "A copy rebuild took over the ship in service")
job.rush()
wait_for_hull(job)
@@ -145,28 +153,27 @@
var/obj/structure/overmap/ship/copy = bay.ship
TEST_ASSERT(copy && copy != rebuilt, "The finished copy did not take the bay")
test_ships += copy
- TEST_ASSERT(!length(home.checkpoints), "The copy rebuild did not use up its checkpoint")
- TEST_ASSERT_NULL(rebuilt.checkpoint_ref?.resolve(), "The original kept a link to a used checkpoint")
+ TEST_ASSERT(QDELETED(copy_source), "The copy rebuild did not use up its snapshot")
TEST_ASSERT(panel.remove_docked_ship(home, operator, bay, copy), "The copy could not be removed from the bay: [panel.error]")
TEST_ASSERT(bay.is_available(), "Removing the copy did not free the bay")
- // Stop before the first piece: the bay is freed and the checkpoint kept.
- var/datum/ship_checkpoint/stop_source = panel.save_free_checkpoint(home, operator, rebuilt, "copyowner")
- TEST_ASSERT_NOTNULL(stop_source, "Could not save the ship to test Stop: [panel.error]")
- TEST_ASSERT(panel.admin_rebuild(home, operator, stop_source), "The rebuild to stop did not start: [panel.error]")
- job = home.checkpoint_jobs[1]
+ // Stop before the first piece: the bay is freed and nothing is left behind.
+ var/datum/ship_checkpoint/stop_source = capture_copy(home, rebuilt, "copyowner")
+ TEST_ASSERT_NOTNULL(stop_source, "Could not capture the ship to test Stop")
+ job = new(null, stop_source, operator, FALSE, TRUE)
+ TEST_ASSERT(job.prepare(), "The rebuild to stop did not start: [job.error]")
TEST_ASSERT(!job.committed, "The rebuild placed a piece before it could be stopped")
panel.manage_outpost(home, operator, "rebuild_stop", list("ref" = REF(job)))
TEST_ASSERT(QDELETED(job) && bay.is_available(), "Stop did not end the build and free the bay")
- TEST_ASSERT(!QDELETED(stop_source) && !stop_source.busy, "Stop before the first piece lost the checkpoint")
- qdel(stop_source)
+ TEST_ASSERT(QDELETED(stop_source), "Stop before the first piece left its snapshot behind")
- // One click: save the docked ship, delete it and rebuild it for its owner.
+ // One click: capture the docked ship, delete it and rebuild it for its owner.
dock_in_bay(home, rebuilt)
panel.manage_outpost(home, operator, "checkpoint_rebuild_docked", list())
TEST_ASSERT(QDELETED(rebuilt), "Rebuild Docked Ship kept the old hull: [panel.error]")
TEST_ASSERT_EQUAL(length(home.checkpoint_jobs), 1, "Rebuild Docked Ship did not start a rebuild: [panel.error]")
job = home.checkpoint_jobs[1]
+ TEST_ASSERT(!job.manual, "Admin rebuilds must run on the real controller")
TEST_ASSERT_EQUAL(job.captain_ckey, operator.ckey, "An ownerless ship was not rebuilt for the admin")
job.rush()
wait_for_hull(job)
@@ -175,3 +182,4 @@
TEST_ASSERT(QDELETED(job) && bay.ship, "The one-click rebuild did not finish")
test_ships += bay.ship
TEST_ASSERT_EQUAL(bay.ship.claimed_captain, operator.mind, "The one-click rebuild was not handed to its owner")
+ TEST_ASSERT_EQUAL(home.treasury.account_balance, before_treasury, "The free tools touched the treasury")
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_orders.dm b/voidcrew/modules/unit_tests/voidcrew_ship_orders.dm
index da58cd6673c..307bf0584a7 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_orders.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_orders.dm
@@ -282,7 +282,7 @@
TEST_ASSERT(lose_original(home, ship), "[ship] could not leave the bay")
/**
- * An order and a checkpoint rebuild at two outposts share the shipyard queue: both wait with
+ * An order and a snapshot rebuild at two outposts share the shipyard queue: both wait with
* their bays reserved while the loader is busy, load in the order they asked, and finish.
*/
/datum/unit_test/voidcrew_checkpoints/ship_order_queue
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 7292fb3044b..d0df4cfe43a 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -114,7 +114,6 @@
/// A snapshot that needs no hull, with one room of the given size.
/datum/unit_test/voidcrew_ship_saves/proc/make_snapshot(tiles = 10)
var/datum/ship_checkpoint/snapshot = allocate(/datum/ship_checkpoint)
- snapshot.persistent = TRUE
snapshot.ship_name = "Test Hull"
snapshot.class_name = "Test Class"
snapshot.width = 3
@@ -147,7 +146,6 @@
var/datum/ship_checkpoint/snapshot = make_snapshot()
var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(GLOB.ship_saves.encode(snapshot))
TEST_ASSERT_NOTNULL(decoded, "A fresh payload did not decode")
- TEST_ASSERT(decoded.persistent, "A decoded save is not marked persistent")
TEST_ASSERT_EQUAL(decoded.ship_name, snapshot.ship_name, "Decoding lost the ship's name")
TEST_ASSERT_EQUAL(decoded.class_name, snapshot.class_name, "Decoding lost the ship's class")
TEST_ASSERT_EQUAL(decoded.width, snapshot.width, "Decoding lost the width")
@@ -203,18 +201,10 @@
/datum/unit_test/voidcrew_ship_saves/proc/check_salvage()
var/datum/ship_checkpoint/saved = make_snapshot()
- var/datum/ship_checkpoint/in_round = allocate(/datum/ship_checkpoint)
-
- // Machines carry nothing per machine, and the in-round kind is untouched.
- var/obj/machinery/autolathe/lathe = allocate(/obj/machinery/autolathe)
- TEST_ASSERT(!findtext(saved.atom_text(lathe), "checkpoint_load_id"), "A saved machine stored a load id")
- TEST_ASSERT_EQUAL(length(saved.machines), 0, "A saved machine stored its parts")
- TEST_ASSERT(findtext(in_round.atom_text(lathe), "checkpoint_load_id"), "An in-round checkpoint lost its load id")
// Valuable structures stay behind unless they seal the hull.
var/obj/structure/statue/gold/hos/statue = allocate(/obj/structure/statue/gold/hos)
TEST_ASSERT(!saved.includes_object(statue), "A gold statue was saved")
- TEST_ASSERT(in_round.includes_object(statue), "An in-round checkpoint stopped keeping statues")
var/obj/structure/window/plasma/window = allocate(/obj/structure/window/plasma)
TEST_ASSERT(saved.includes_object(window), "A plasma window was refused: it seals the hull")
var/obj/structure/grille/grille = allocate(/obj/structure/grille)
@@ -252,7 +242,6 @@
wall_tile.ChangeTurf(/turf/closed/wall/mineral/diamond)
var/turf/closed/wall/mineral/diamond/diamond_wall = wall_tile
TEST_ASSERT(findtext(saved.atom_text(diamond_wall), "sheet_type = /obj/item/stack/sheet/iron"), "A diamond wall was saved with its own salvage")
- TEST_ASSERT(!findtext(in_round.atom_text(diamond_wall), "sheet_type"), "An in-round checkpoint changed a wall's salvage")
diamond_wall.sheet_type = /obj/item/stack/sheet/iron
diamond_wall.copyTurf(copy_tile)
var/turf/closed/wall/moved_wall = copy_tile
@@ -267,7 +256,6 @@
wall_tile.ChangeTurf(/turf/open/floor/mineral/gold)
var/turf/open/floor/mineral/gold/gold_floor = wall_tile
TEST_ASSERT(findtext(saved.atom_text(gold_floor), "floor_tile = /obj/item/stack/tile/iron"), "A gold floor was saved with its own tile")
- TEST_ASSERT(!findtext(in_round.atom_text(gold_floor), "floor_tile"), "An in-round checkpoint changed a floor's tile")
gold_floor.floor_tile = /obj/item/stack/tile/iron
gold_floor.copyTurf(copy_tile)
var/turf/open/floor/moved_floor = copy_tile
@@ -275,10 +263,9 @@
wall_tile.ChangeTurf(wall_tile_type)
copy_tile.ChangeTurf(copy_tile_type)
-/// A hull that was saved once, loaded and saved again must not get its salvage back, nor an in-round checkpoint of it.
+/// A hull that was saved once, loaded and saved again must not get its salvage back.
/datum/unit_test/voidcrew_ship_saves/proc/check_second_generation()
var/datum/ship_checkpoint/saved = make_snapshot()
- var/datum/ship_checkpoint/in_round = allocate(/datum/ship_checkpoint)
var/turf/tile = run_loc_floor_bottom_left
var/tile_type = tile.type
var/obj/item/construction/rcd/internal/ship/rcd = allocate(/obj/item/construction/rcd/internal/ship)
@@ -287,7 +274,6 @@
var/turf/closed/wall/wall = tile
wall.sheet_type = /obj/item/stack/sheet/iron
TEST_ASSERT(findtext(saved.atom_text(wall), "sheet_type = /obj/item/stack/sheet/iron"), "A wall saved a second time got its salvage back")
- TEST_ASSERT(findtext(in_round.atom_text(wall), "sheet_type = /obj/item/stack/sheet/iron"), "An in-round checkpoint gave a stripped wall its salvage back")
// A drone refunds a stripped wall as the iron wall it comes apart into, and a real mineral wall as before.
var/list/refund = rcd.get_deconstruction_materials(wall)
TEST_ASSERT_EQUAL(refund[/datum/material/iron], 200, "A stripped diamond wall was not refunded as an iron wall")
@@ -309,7 +295,6 @@
TEST_ASSERT_EQUAL(refund[/datum/material/plasma], 25, "A real plastitanium floor stopped refunding plasma")
floor.floor_tile = /obj/item/stack/tile/iron
TEST_ASSERT(findtext(saved.atom_text(floor), "floor_tile = /obj/item/stack/tile/iron"), "A floor saved a second time got its tile back")
- TEST_ASSERT(findtext(in_round.atom_text(floor), "floor_tile = /obj/item/stack/tile/iron"), "An in-round checkpoint gave a stripped floor its tile back")
refund = rcd.get_deconstruction_materials(tile)
TEST_ASSERT_EQUAL(refund[/datum/material/iron], SHIP_SAVE_TEST_TILE_IRON, "A stripped plastitanium floor was not refunded as an iron tile")
TEST_ASSERT_NULL(refund[/datum/material/plasma], "A stripped plastitanium floor was refunded plasma")
@@ -322,7 +307,6 @@
var/airlock_text = saved.atom_text(gold_airlock)
TEST_ASSERT(findtext(airlock_text, "assemblytype = /obj/structure/door_assembly;") || findtext(airlock_text, "assemblytype = /obj/structure/door_assembly\n"), "A gold airlock kept its gold assembly: [airlock_text]")
TEST_ASSERT(!findtext(airlock_text, "door_assembly_gold"), "A gold airlock was saved with a gold assembly: [airlock_text]")
- TEST_ASSERT(!findtext(in_round.atom_text(gold_airlock), "assemblytype"), "An in-round checkpoint changed an airlock's assembly")
TEST_ASSERT(!findtext(saved.atom_text(allocate(/obj/machinery/door/airlock)), "assemblytype"), "A plain airlock was given another assembly")
TEST_ASSERT(ship_save_refuses_object(allocate(/obj/structure/door_assembly/door_assembly_gold)), "A gold airlock assembly was saved")
TEST_ASSERT(!ship_save_refuses_object(allocate(/obj/structure/door_assembly)), "A plain airlock assembly was refused")
@@ -351,7 +335,7 @@
if(!iseffect(loose))
qdel(loose)
var/obj/machinery/power/rtg/saved = new(tile)
- checkpoint_restore_machine(saved, null)
+ checkpoint_restore_machine(saved)
TEST_ASSERT(saved.power_gen > 0, "A saved RTG makes no power")
saved.deconstruct(TRUE)
TEST_ASSERT_NULL(locate(/obj/item/stack/sheet/mineral/uranium) in tile, "A saved RTG handed back uranium")
@@ -642,8 +626,6 @@
TEST_ASSERT_NOTNULL(decoded, "The stored hull did not decode")
TEST_ASSERT_EQUAL(parts_held(), 40 - GLOB.ship_saves.price_for(decoded.owned_tile_count()), "The hull was charged the wrong price")
TEST_ASSERT_EQUAL(hull_owned_tile_count(test_ship.shuttle), decoded.owned_tile_count(), "The price on the screen would differ from the price charged")
- TEST_ASSERT(!findtext(decoded.tgm, "checkpoint_load_id"), "A stored hull has a machine load id")
- TEST_ASSERT_EQUAL(length(decoded.machines), 0, "A stored hull has per-machine data")
TEST_ASSERT_EQUAL(decoded.class_name, test_ship.source_template.short_name, "The stored hull lost its class")
TEST_ASSERT_EQUAL(length(decoded.jobs), length(test_ship.job_definitions), "The stored hull lost its roster")
var/datum/parsed_map/parsed = new(decoded.tgm)
@@ -996,7 +978,6 @@
test_ship = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
TEST_ASSERT_NOTNULL(test_ship, "Could not create the hull")
var/datum/ship_checkpoint/snapshot = allocate(/datum/ship_checkpoint)
- snapshot.persistent = TRUE
var/denial = snapshot.capture_hull(test_ship, test_ckey)
TEST_ASSERT_NULL(denial, "The fixture hull was not captured: [denial]")
var/payload = GLOB.ship_saves.encode(snapshot)
diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md
index f5c4d414656..d49385db58e 100644
--- a/wiki/content/player-outposts.md
+++ b/wiki/content/player-outposts.md
@@ -162,9 +162,8 @@ A hull defense turret bolted to the outpost answers only to its owner, stewards,
The **Rooms** tab sells a permanent **ship bay** for 10,000 credits, 100 iron and 50 glass from the outpost silo; it needs a working hangar elevator. Ships reach it through the **Ship Bay** option at the helm, and people by elevator. When a ship in the bay asks to use the outpost silo, the request appears on its bay row. An evicted ship has three minutes to undock, and gets its docking fee back if it paid in the last 30 minutes.
-The bay's own **shipyard console**, found only in the bay itself, has three tabs:
+The bay's own **shipyard console**, found only in the bay itself, has two tabs:
-- **Checkpoints** saves a captain's docked ship: **10,000 credits** the first time, **5,000 credits** to update it later. If that ship is ever lost or its hull abandoned, the same captain can pay to have it rebuilt from the saved checkpoint. The delivery drones do the rebuilding in one visible pass, then the ship launches into the bay.
- **Saved ships** keeps a ship you command for a later round.
- **New ship** orders a new ship, built at the bay by your delivery drones and stocked with its usual starting supplies, paid from the outpost bank or your own account.
From 5c06181657d12d172bd8dcbc89592fa0c8ef6e66 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 18:09:53 -0800
Subject: [PATCH 10/72] Price ship saves by what is aboard, with chosen slots
and same-round loading
A save now costs parts in the classes that match the machines on the hull, with a
5 part minimum and no cap. A survey of the hull's owned tiles gives the bill, mass,
crew and a plan for the screens, and is kept with the row in a new summary column.
Captains choose the slot and can overwrite a used one, which charges again and keeps
the old row if the write fails. A slot stays locked while it is retrieved, written or
deleted, and a slot that changes while the confirm box is open is refused.
A save can be loaded in the round it was made once the ship it came from is lost or
abandoned. The console and lobby both retire that hull and move its balance across.
---
SQL/database_changelog.md | 3 +-
SQL/migrations/voidcrew_ship_saves.sql | 1 +
SQL/tgstation_schema.sql | 1 +
SQL/tgstation_schema_prefixed.sql | 1 +
tgstation.dme | 1 +
voidcrew/_DEFINES/ship_saves.dm | 14 +-
voidcrew/edits/mobs/ship_join_menu.dm | 2 +-
.../outpost_checkpoint_construction.dm | 7 +-
.../outpost_checkpoint_snapshot.dm | 4 +-
.../player_outposts/outpost_checkpoints.dm | 2 +-
.../ship_purchase/ship_economy_database.dm | 10 +-
.../modules/ship_saves/ship_save_capture.dm | 14 +-
.../modules/ship_saves/ship_save_console.dm | 67 +-
.../ship_saves/ship_save_construction.dm | 17 +-
.../modules/ship_saves/ship_save_lobby.dm | 27 +-
.../modules/ship_saves/ship_save_store.dm | 18 +-
.../modules/ship_saves/ship_save_survey.dm | 241 +++++++
voidcrew/modules/ship_saves/ship_saves.dm | 125 ++--
.../modules/unit_tests/voidcrew_ship_saves.dm | 680 ++++++++++++++++--
wiki/content/player-outposts.md | 8 +-
wiki/content/ship-parts.md | 4 +-
21 files changed, 1068 insertions(+), 179 deletions(-)
create mode 100644 voidcrew/modules/ship_saves/ship_save_survey.dm
diff --git a/SQL/database_changelog.md b/SQL/database_changelog.md
index 882b20962a2..5a5eb4ece91 100644
--- a/SQL/database_changelog.md
+++ b/SQL/database_changelog.md
@@ -20,7 +20,7 @@ In any query remember to add a prefix to the table names if you use one.
Version 5.35, 5 November 2026, by Jackriip
Adds the `player_ship_saves` table for Voidcrew ship saving: one row per used save slot, with the ship's
-map stored through `COMPRESS()`.
+map stored through `COMPRESS()` and what the save holds and cost as JSON in `summary`.
```sql
CREATE TABLE `player_ship_saves` (
@@ -33,6 +33,7 @@ CREATE TABLE `player_ship_saves` (
`saved_round` INT(11) NOT NULL,
`saved_at` DATETIME NOT NULL,
`raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `summary` TEXT NULL,
`payload` MEDIUMBLOB NOT NULL,
PRIMARY KEY (`ckey`, `slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
diff --git a/SQL/migrations/voidcrew_ship_saves.sql b/SQL/migrations/voidcrew_ship_saves.sql
index 6bebc4b487b..bb9ac1fbe99 100644
--- a/SQL/migrations/voidcrew_ship_saves.sql
+++ b/SQL/migrations/voidcrew_ship_saves.sql
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `player_ship_saves` (
`saved_round` INT(11) NOT NULL,
`saved_at` DATETIME NOT NULL,
`raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `summary` TEXT NULL,
`payload` MEDIUMBLOB NOT NULL,
PRIMARY KEY (`ckey`, `slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql
index d87432fee13..bed198bd46c 100644
--- a/SQL/tgstation_schema.sql
+++ b/SQL/tgstation_schema.sql
@@ -575,6 +575,7 @@ CREATE TABLE `player_ship_saves` (
`saved_round` INT(11) NOT NULL,
`saved_at` DATETIME NOT NULL,
`raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `summary` TEXT NULL,
`payload` MEDIUMBLOB NOT NULL,
PRIMARY KEY (`ckey`, `slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql
index 49f6fb25bfa..9e6c5fa3dea 100644
--- a/SQL/tgstation_schema_prefixed.sql
+++ b/SQL/tgstation_schema_prefixed.sql
@@ -574,6 +574,7 @@ CREATE TABLE `SS13_player_ship_saves` (
`saved_round` INT(11) NOT NULL,
`saved_at` DATETIME NOT NULL,
`raw_bytes` INT(10) UNSIGNED NOT NULL,
+ `summary` TEXT NULL,
`payload` MEDIUMBLOB NOT NULL,
PRIMARY KEY (`ckey`, `slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
diff --git a/tgstation.dme b/tgstation.dme
index 188e8d7b9a1..376321bef12 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -7585,6 +7585,7 @@
#include "voidcrew\modules\ship_saves\ship_save_construction.dm"
#include "voidcrew\modules\ship_saves\ship_save_lobby.dm"
#include "voidcrew\modules\ship_saves\ship_save_store.dm"
+#include "voidcrew\modules\ship_saves\ship_save_survey.dm"
#include "voidcrew\modules\ship_saves\ship_saves.dm"
#include "voidcrew\modules\ship_upgrades\_ship_upgrades.dm"
#include "voidcrew\modules\ship_upgrades\admin_verbs.dm"
diff --git a/voidcrew/_DEFINES/ship_saves.dm b/voidcrew/_DEFINES/ship_saves.dm
index 033cf74a44b..ad7a1aafc6b 100644
--- a/voidcrew/_DEFINES/ship_saves.dm
+++ b/voidcrew/_DEFINES/ship_saves.dm
@@ -2,9 +2,15 @@
#define SHIP_SAVE_FORMAT 1
/// How many ships one player can have saved at once.
#define SHIP_SAVE_SLOTS 3
-/// The least a save can cost, in ship parts.
-#define SHIP_SAVE_PRICE_MIN 2
-/// The most a save can cost, in ship parts.
-#define SHIP_SAVE_PRICE_MAX 15
+/// The least a save can cost, in ship parts of every class together.
+#define SHIP_SAVE_PRICE_MIN 5
+/// A hull costs one misc part per this much mass, rounded up.
+#define SHIP_SAVE_MASS_PER_PART 150
+/// Machines the price table doesn't name cost one misc part per this many, rounded up.
+#define SHIP_SAVE_MACHINES_PER_PART 15
+/// How long a hull's survey is good for while a window is open on it.
+#define SHIP_SAVE_SURVEY_LIFE (10 SECONDS)
+/// A hull plan longer than this isn't sent to the screen or kept.
+#define SHIP_SAVE_PLAN_MAX_LENGTH 8000
/// What setting a new ship join password costs, in ship parts.
#define SHIP_PASSWORD_PRICE 3
diff --git a/voidcrew/edits/mobs/ship_join_menu.dm b/voidcrew/edits/mobs/ship_join_menu.dm
index eeaab2ac7de..9ce259a97cf 100644
--- a/voidcrew/edits/mobs/ship_join_menu.dm
+++ b/voidcrew/edits/mobs/ship_join_menu.dm
@@ -135,7 +135,7 @@
data["can_requisition"] = can_requisition_hull(user)
var/list/saved_ships = list()
for(var/list/row as anything in GLOB.ship_saves.saves_for(user.ckey))
- saved_ships += list(list("slot" = row["slot"], "name" = row["ship_name"], "class" = row["ship_class"], "denial" = GLOB.ship_saves.retrieve_denial(user.ckey, row["slot"])))
+ saved_ships += list(GLOB.ship_saves.saved_ship_entry(user.ckey, row, GLOB.ship_saves.retrieve_denial(user.ckey, row["slot"])))
data["saved_ships"] = saved_ships
return data
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm
index 43fcfd62ee9..f327d93a706 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_construction.dm
@@ -106,16 +106,15 @@
var/status_verb = "Rebuilding"
var/build_noun = "Reconstruction"
-/// leave_original: a copy of a hull that may still be in service. It is not retired and keeps
-/// its money; the snapshot is still consumed. The job owns the snapshot from here on.
-/datum/checkpoint_construction/New(datum/ship_checkpoint_ui/terminal, datum/ship_checkpoint/blueprint, mob/living/user, manual_drive = FALSE, leave_original = FALSE)
+/// The job owns the snapshot from here on.
+/datum/checkpoint_construction/New(datum/ship_checkpoint_ui/terminal, datum/ship_checkpoint/blueprint, mob/living/user, manual_drive = FALSE)
panel_ref = terminal ? WEAKREF(terminal) : null
snapshot = blueprint
home = blueprint.outpost
captain_ckey = blueprint.captain_ckey
ship_name = blueprint.ship_name
manual = manual_drive
- var/obj/structure/overmap/ship/original = leave_original ? null : blueprint.source_ship?.resolve()
+ var/obj/structure/overmap/ship/original = blueprint.source_ship?.resolve()
if(original)
original_ref = WEAKREF(original)
// Refunds follow the snapshot's owner, who is the operator unless an admin started it.
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
index 9140b61d174..eab91ea000b 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
@@ -113,9 +113,7 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
/// Ports are handled separately and cannot carry NPC types.
/datum/ship_checkpoint/proc/includes_object(obj/object)
- if(ship_save_refuses_object(object))
- return FALSE
- return outpost_checkpoint_saves(object)
+ return ship_save_keeps_object(object)
/// Preserve placement and construction settings; inventories are never serialized.
/datum/ship_checkpoint/proc/atom_text(atom/object)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoints.dm b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
index 9a64503ab65..5678d895588 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoints.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
@@ -152,7 +152,7 @@
switch(action)
if("ship_save_store")
var/datum/outpost_berth/ship_bay/bay = locate(params["ref"]) in outpost.bay_berths
- save_ship(user, bay)
+ save_ship(user, bay, text2num(params["slot"]))
if("ship_save_retrieve")
retrieve_ship(user, text2num(params["slot"]))
else
diff --git a/voidcrew/modules/ship_purchase/ship_economy_database.dm b/voidcrew/modules/ship_purchase/ship_economy_database.dm
index 2c9aba7cc14..f9e4e33c154 100644
--- a/voidcrew/modules/ship_purchase/ship_economy_database.dm
+++ b/voidcrew/modules/ship_purchase/ship_economy_database.dm
@@ -344,14 +344,10 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new)
log_game("SHIP_ECONOMY: [ckey] LOST [plan[part_class]] [part_class] parts: the refund failed")
. = FALSE
-/// Total parts held, from the cache alone so it never sleeps. Null until something has loaded them.
-/datum/ship_economy_db/proc/cached_parts_total(ckey)
+/// Parts held by class, from the cache alone so it never sleeps. Null until something has loaded them.
+/datum/ship_economy_db/proc/cached_parts(ckey)
var/list/parts = ckey ? parts_cache[ckey(ckey)] : null
- if(!parts)
- return null
- . = 0
- for(var/part_class in parts)
- . += parts[part_class]
+ return parts?.Copy()
/**
* Check if a ship template is unlocked for a player
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index 371467ad3df..06830d49a98 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -42,7 +42,7 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
if(ispath(stack_path, /obj/item/stack/ore/bluespace_crystal) || (material_type && GLOB.ship_save_precious_materials[material_type]))
board.req_components -= stack_path
-/// Build a statue of gold and save it for two parts, and you could break it down next round.
+/// Build a statue of gold and save it, and you could break it down next round.
/proc/ship_save_refuses_object(obj/object)
if(!isstructure(object) || is_type_in_typecache(object, GLOB.ship_save_kept_structures))
return FALSE
@@ -57,6 +57,10 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
return TRUE
return FALSE
+/// Whether a save keeps this object: what a capture writes down, and what the survey prices.
+/proc/ship_save_keeps_object(obj/object)
+ return !ship_save_refuses_object(object) && outpost_checkpoint_saves(object)
+
/// Basics a saved ship keeps as they load, full: breathable air tanks, welding fuel and water tanks, and
/// plumbing water. Every other supply is scrubbed. Not plasma tanks, or a hull full of them would be gas
/// to sell next round: its engines run on the heater reserve instead.
@@ -78,14 +82,6 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
/proc/ship_save_plain_text(text)
return replacetext(replacetext(text, "\improper", ""), "\proper", "")
-/// Tiles in the rooms this hull owns, which is what a save is priced on.
-/proc/hull_owned_tile_count(obj/docking_port/mobile/port)
- . = 0
- if(QDELETED(port) || !port.z)
- return
- for(var/area/room as anything in hull_owned_areas(port))
- . += length(room.get_turfs_by_zlevel(port.z))
-
/datum/ship_checkpoint/proc/owned_tile_count()
. = 0
for(var/list/room as anything in rooms)
diff --git a/voidcrew/modules/ship_saves/ship_save_console.dm b/voidcrew/modules/ship_saves/ship_save_console.dm
index 3d838b50186..e723ea5c514 100644
--- a/voidcrew/modules/ship_saves/ship_save_console.dm
+++ b/voidcrew/modules/ship_saves/ship_save_console.dm
@@ -1,5 +1,5 @@
-/// Why this user can't save the ship in this bay, or null. Only checks caches, so ui_data can use it.
-/datum/ship_checkpoint_ui/proc/saved_ship_denial(mob/living/user, datum/outpost_berth/ship_bay/bay)
+/// Why this user can't save the ship in this bay to any slot, or null. Only checks caches, so ui_data can use it.
+/datum/ship_checkpoint_ui/proc/saved_ship_bay_denial(mob/living/user, datum/outpost_berth/ship_bay/bay)
if(QDELETED(src) || ui_status(user, GLOB.always_state) != UI_INTERACTIVE)
return "Shipyard console unavailable."
if(QDELETED(bay) || bay.outpost != outpost || !(bay in outpost.bay_berths) || !bay.is_ship_present())
@@ -14,23 +14,25 @@
// Unloaded is not the same as empty: a failed read must not look like three free slots.
if(!GLOB.ship_save_store.online() || isnull(GLOB.ship_saves.saves_for(user.ckey)))
return "Shipyard records are offline."
- if(!GLOB.ship_saves.free_slot(user.ckey))
- return "All slots are full."
return null
+/// Why this user can't save the ship in this bay to this slot, or null.
+/datum/ship_checkpoint_ui/proc/saved_ship_denial(mob/living/user, datum/outpost_berth/ship_bay/bay, slot)
+ return saved_ship_bay_denial(user, bay) || GLOB.ship_saves.slot_denial(user.ckey, slot)
+
/// The denial, plus whether the ship we captured is still the one in the bay.
-/datum/ship_checkpoint_ui/proc/saved_ship_changed_denial(mob/living/user, datum/outpost_berth/ship_bay/bay, obj/structure/overmap/ship/ship)
- var/denial = saved_ship_denial(user, bay)
+/datum/ship_checkpoint_ui/proc/saved_ship_changed_denial(mob/living/user, datum/outpost_berth/ship_bay/bay, obj/structure/overmap/ship/ship, slot)
+ var/denial = saved_ship_denial(user, bay, slot)
if(denial)
return denial
if(bay.ship != ship)
return "The docked ship changed. Try again."
return null
-/// Captures the ship, asks the captain, and charges them. Sleeps at every step, so everything is
-/// checked again afterwards.
-/datum/ship_checkpoint_ui/proc/save_ship(mob/living/user, datum/outpost_berth/ship_bay/bay)
- error = saved_ship_denial(user, bay)
+/// Captures the ship, asks the captain, and charges them for what it holds. Sleeps at every step, so
+/// everything is checked again afterwards.
+/datum/ship_checkpoint_ui/proc/save_ship(mob/living/user, datum/outpost_berth/ship_bay/bay, slot)
+ error = saved_ship_denial(user, bay, slot)
if(error)
return FALSE
working = TRUE
@@ -39,16 +41,29 @@
var/ship_name = ship.name
var/player_ckey = user.ckey
var/datum/ship_checkpoint/snapshot = new
- var/failure = snapshot.capture(ship, user) || saved_ship_changed_denial(user, bay, ship)
+ var/failure = snapshot.capture(ship, user) || saved_ship_changed_denial(user, bay, ship, slot)
+ var/list/survey
+ if(!failure)
+ survey = ship.save_survey(fresh = TRUE)
+ if(!survey)
+ failure = "The source hull is no longer available."
if(!failure)
- var/price = GLOB.ship_saves.price_for(snapshot.owned_tile_count())
- if(!confirm_save(user, "Save [ship_name] for [price] ship parts?", "Shipyard"))
+ var/list/old_row = GLOB.ship_saves.saved_row(player_ckey, slot)
+ var/old_name = old_row?["ship_name"]
+ var/old_round = old_row?["saved_round"]
+ var/cost_text = ship_save_cost_text(survey["cost"])
+ var/question = old_row ? "Overwrite [old_name] with [ship_name] for [cost_text]?" : "Save [ship_name] to slot [slot] for [cost_text]?"
+ if(!confirm_save(user, question, "Shipyard"))
qdel(snapshot)
working = FALSE
return FALSE
- failure = saved_ship_changed_denial(user, bay, ship)
+ failure = saved_ship_changed_denial(user, bay, ship, slot)
+ if(!failure)
+ var/list/new_row = GLOB.ship_saves.saved_row(player_ckey, slot)
+ if(new_row?["ship_name"] != old_name || new_row?["saved_round"] != old_round)
+ failure = "That slot changed. Try again."
if(!failure)
- failure = GLOB.ship_saves.commit_save(player_ckey, snapshot, user)
+ failure = GLOB.ship_saves.commit_save(player_ckey, snapshot, user, slot, survey)
qdel(snapshot)
if(QDELETED(src))
return !failure
@@ -94,6 +109,11 @@
snapshot = null
else
failure = saved_ship_retrieve_denial(user, slot, allow_busy = TRUE)
+ // Another save of the same hull may have started building during the read. The job takes this
+ // lock itself, with no wait in between, so two can't hold it.
+ var/obj/structure/overmap/ship/original = snapshot.source_ship?.resolve()
+ if(!failure && original?.checkpoint_rebuilding)
+ failure = "Already under way."
if(failure)
qdel(snapshot)
GLOB.ship_saves.cancel_retrieval(player_ckey, slot)
@@ -123,20 +143,21 @@
/datum/ship_checkpoint_ui/proc/saved_ships_data(mob/user)
var/list/saves = list()
for(var/list/row as anything in GLOB.ship_saves.saves_for(user.ckey))
- saves += list(list("slot" = row["slot"], "name" = row["ship_name"], "class" = row["ship_class"], "denial" = saved_ship_retrieve_denial(user, row["slot"])))
+ saves += list(GLOB.ship_saves.saved_ship_entry(user.ckey, row, saved_ship_retrieve_denial(user, row["slot"])))
var/list/ships = list()
for(var/datum/outpost_berth/ship_bay/bay as anything in outpost.bay_berths)
- if(!bay?.ship?.is_ship_captain(user))
+ var/obj/structure/overmap/ship/ship = bay?.ship
+ if(!ship?.is_ship_captain(user))
continue
- ships += list(list(
+ ships += list(ship_save_add_survey(list(
"ref" = REF(bay),
- "name" = bay.ship.name,
- "price" = GLOB.ship_saves.price_for(hull_owned_tile_count(bay.ship.shuttle)),
- "denial" = saved_ship_denial(user, bay),
- ))
+ "name" = ship.name,
+ "class" = ship.source_template?.short_name,
+ "denial" = saved_ship_bay_denial(user, bay),
+ ), ship.save_survey()))
return list(
"online" = !!GLOB.ship_save_store.online(),
- "parts" = GLOB.ship_economy_db.cached_parts_total(user.ckey),
+ "parts" = GLOB.ship_economy_db.cached_parts(user.ckey),
"slots" = SHIP_SAVE_SLOTS,
"saves" = saves,
"ships" = ships,
diff --git a/voidcrew/modules/ship_saves/ship_save_construction.dm b/voidcrew/modules/ship_saves/ship_save_construction.dm
index c60940b0e04..c8116c59503 100644
--- a/voidcrew/modules/ship_saves/ship_save_construction.dm
+++ b/voidcrew/modules/ship_saves/ship_save_construction.dm
@@ -8,25 +8,28 @@
/datum/checkpoint_construction/saved/New(datum/ship_checkpoint_ui/terminal, datum/ship_checkpoint/blueprint, mob/living/user, slot, manual_drive = FALSE)
save_slot = slot
blueprint.outpost = terminal.outpost
- // There is no hull to retire: this one was saved rounds ago.
- ..(terminal, blueprint, user, manual_drive, TRUE)
+ ..(terminal, blueprint, user, manual_drive)
+/// Refused at the first piece, the save is still there, and the player should know.
/datum/checkpoint_construction/saved/source_denial()
- if(QDELETED(snapshot))
- return "This record can't be read."
- return null
+ . = ..()
+ if(.)
+ return "[.] [unspent_note()]"
/datum/checkpoint_construction/saved/unspent_note()
return "Your saved ship is safe."
-/// The parent clears the template's blueprint and deletes the snapshot; there is no original to retire.
+/// The parent clears the template's blueprint, deletes the snapshot and retires the hull this was saved
+/// from, if it is still about, holding its balance for the new ship.
/datum/checkpoint_construction/saved/consume_source()
. = ..()
GLOB.ship_saves.spend(captain_ckey, save_slot)
log_game("[captain_ckey] spent ship save slot [save_slot] on [ship_name] at [home.name].")
-/// A build stopped before its first piece leaves the save as it was. After that it stays spent.
+/// A build stopped before its first piece leaves the save as it was. After that it stays spent, though
+/// the parent still gives the old hull back its balance.
/datum/checkpoint_construction/saved/release_source()
+ ..()
if(!committed)
GLOB.ship_saves.cancel_retrieval(captain_ckey, save_slot)
diff --git a/voidcrew/modules/ship_saves/ship_save_lobby.dm b/voidcrew/modules/ship_saves/ship_save_lobby.dm
index b5e5da0d25c..b13c3c934e9 100644
--- a/voidcrew/modules/ship_saves/ship_save_lobby.dm
+++ b/voidcrew/modules/ship_saves/ship_save_lobby.dm
@@ -32,7 +32,8 @@
/**
* Loads a saved ship and seats the player on it, the way buying a hull does. The save is spent once the
- * ship has loaded. Sleeps at the read and in the shipyard's queue, and the player can leave during either:
+ * ship has loaded, which also retires the hull it was saved from, if that is still about, and moves its
+ * balance across. Sleeps at the read and in the shipyard's queue, and the player can leave during either:
* this runs here rather than on their mob, which a logout deletes, so nothing stops it before the spend.
*/
/datum/ship_saves/proc/launch(mob/dead/new_player/player, slot)
@@ -71,11 +72,30 @@
to_chat(player, span_warning(snapshot))
return player.select_ship()
+ // The read slept, so the hull this was saved from may be back in service or replaced by now.
+ var/datum/weakref/source = snapshot.source_ship
+ var/obj/structure/overmap/ship/original = source?.resolve()
+ denial = retrieve_denial(player_ckey, slot, allow_busy = TRUE) || (original?.checkpoint_rebuilding ? "Already under way." : null)
+ if(denial)
+ qdel(snapshot)
+ cancel_retrieval(player_ckey, slot)
+ player.spawning_ship = FALSE
+ to_chat(player, span_warning(denial))
+ return player.select_ship()
+ // Nobody claims the old hull while its replacement loads.
+ if(original)
+ original.checkpoint_rebuilding = TRUE
+
to_chat(player, span_notice("[snapshot.ship_name] is being prepared. Please be patient!"))
var/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/template = new(snapshot)
+ // Not holding the old hull while this waits: it can be deleted in the meantime.
+ original = null
var/obj/structure/overmap/ship/target = SSshuttle.create_ship(template)
qdel(snapshot)
+ original = source?.resolve()
if(!istype(target))
+ if(original)
+ original.checkpoint_rebuilding = FALSE
qdel(template)
cancel_retrieval(player_ckey, slot)
if(QDELETED(player))
@@ -91,6 +111,11 @@
// The ship exists now, whether or not its captain stayed to board it. Left, they can join it from the menu.
spend(player_ckey, slot)
log_game("[player_ckey] launched [target.name] from ship save slot [slot].")
+ // The old hull is replaced now, and its money comes across.
+ if(original)
+ var/balance = original.retire_replaced_hull()
+ if(balance > 0)
+ target.ship_account.adjust_money(balance, "Recovered ship account")
target.password_cleared_ckeys[player_ckey] = TRUE
if(QDELETED(player))
return
diff --git a/voidcrew/modules/ship_saves/ship_save_store.dm b/voidcrew/modules/ship_saves/ship_save_store.dm
index f1c67c9af6c..aea316b8822 100644
--- a/voidcrew/modules/ship_saves/ship_save_store.dm
+++ b/voidcrew/modules/ship_saves/ship_save_store.dm
@@ -13,7 +13,7 @@ GLOBAL_DATUM_INIT(ship_save_store, /datum/ship_save_store, new)
/// The player's saves without their maps. An empty list means none; null means the query failed.
/datum/ship_save_store/proc/read_meta(ckey)
var/datum/db_query/query = SSdbcore.NewQuery(
- "SELECT slot, ship_name, ship_class, tile_count, format_version, saved_round, saved_at, raw_bytes \
+ "SELECT slot, ship_name, ship_class, tile_count, format_version, saved_round, saved_at, raw_bytes, summary \
FROM [format_table_name("player_ship_saves")] WHERE ckey = :ckey ORDER BY slot",
list("ckey" = ckey)
)
@@ -31,6 +31,7 @@ GLOBAL_DATUM_INIT(ship_save_store, /datum/ship_save_store, new)
"saved_round" = text2num(query.item[6]),
"saved_at" = query.item[7],
"raw_bytes" = text2num(query.item[8]),
+ "survey" = ship_save_read_summary(query.item[9]),
))
qdel(query)
return rows
@@ -51,12 +52,16 @@ GLOBAL_DATUM_INIT(ship_save_store, /datum/ship_save_store, new)
qdel(query)
return payload
-/// A plain INSERT: a duplicate key fails rather than overwriting a save someone paid for.
-/datum/ship_save_store/proc/write(ckey, slot, list/meta, payload)
+/**
+ * Writes one save. A plain INSERT, so a duplicate key fails rather than overwriting a save someone paid
+ * for, unless overwrite is set, which replaces the row whole.
+ */
+/datum/ship_save_store/proc/write(ckey, slot, list/meta, payload, overwrite = FALSE)
var/datum/db_query/query = SSdbcore.NewQuery(
"INSERT INTO [format_table_name("player_ship_saves")] \
- (ckey, slot, ship_name, ship_class, tile_count, format_version, saved_round, saved_at, raw_bytes, payload) \
- VALUES (:ckey, :slot, :ship_name, :ship_class, :tile_count, :format_version, :saved_round, NOW(), :raw_bytes, COMPRESS(:payload))",
+ (ckey, slot, ship_name, ship_class, tile_count, format_version, saved_round, saved_at, raw_bytes, summary, payload) \
+ VALUES (:ckey, :slot, :ship_name, :ship_class, :tile_count, :format_version, :saved_round, NOW(), :raw_bytes, :summary, COMPRESS(:payload))\
+ [overwrite ? " ON DUPLICATE KEY UPDATE ship_name = VALUES(ship_name), ship_class = VALUES(ship_class), tile_count = VALUES(tile_count), format_version = VALUES(format_version), saved_round = VALUES(saved_round), saved_at = VALUES(saved_at), raw_bytes = VALUES(raw_bytes), summary = VALUES(summary), payload = VALUES(payload)" : ""]",
list(
"ckey" = ckey,
"slot" = slot,
@@ -66,10 +71,11 @@ GLOBAL_DATUM_INIT(ship_save_store, /datum/ship_save_store, new)
"format_version" = meta["format_version"],
"saved_round" = meta["saved_round"],
"raw_bytes" = meta["raw_bytes"],
+ "summary" = meta["summary"],
"payload" = payload,
)
)
- // A failed insert would log all its arguments, and one of them is the whole map.
+ // A failed write would log all its arguments, and one of them is the whole map.
var/success = query.Execute(log_error = FALSE)
if(!success)
log_game("Ship save [ckey] slot [slot] could not be written: [copytext(query.ErrorMsg(), 1, 300)]")
diff --git a/voidcrew/modules/ship_saves/ship_save_survey.dm b/voidcrew/modules/ship_saves/ship_save_survey.dm
new file mode 100644
index 00000000000..b27bff30b14
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_survey.dm
@@ -0,0 +1,241 @@
+/// One priced kind of machine: the part class that pays for it, parts each, and its line on the bill.
+/// With no label the line takes the machine's own name.
+#define SHIP_SAVE_PRICE(part_class, each_machine, label_text) list("class" = part_class, "each" = each_machine, "label" = label_text)
+
+/**
+ * What the machines a save keeps cost. Subtypes inherit, and a later, more specific entry wins: a null
+ * takes a subtype back off the table, so it counts among the other machines.
+ */
+GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
+ /obj/machinery/ship_combat/laser_turret = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 1, null),
+ /obj/machinery/ship_combat/missile_launcher = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 1, null),
+ /obj/machinery/ship_combat/pod_launcher = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 1, null),
+ /obj/machinery/shuttle_scrambler/ship_siphon = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 1, "Ship siphon"),
+ /obj/machinery/ship_combat/shield_generator = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 2, "Shield generator"),
+ /obj/machinery/ship_combat/interdictor = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 2, null),
+ /obj/machinery/ship_combat/cloak_device = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 2, null),
+ /obj/machinery/ship_combat/ew_suite = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 2, null),
+ /obj/machinery/porta_turret = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 0.5, "Defense turret"),
+
+ /obj/machinery/rnd/production/protolathe = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Protolathe"),
+ /obj/machinery/rnd/production/circuit_imprinter = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Circuit imprinter"),
+ /obj/machinery/rnd/production/techfab = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Techfab"),
+ /obj/machinery/rnd/server = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "R&D server"),
+ /obj/machinery/rnd/destructive_analyzer = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, null),
+ /obj/machinery/rnd/experimentor = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Experimentor"),
+ /obj/machinery/mecha_part_fabricator = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Exosuit fabricator"),
+ /obj/machinery/chem_dispenser = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Chem dispenser"),
+ /obj/machinery/chem_dispenser/drinks = null,
+ /obj/machinery/chem_master = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "ChemMaster"),
+ /obj/machinery/chem_master/condimaster = null,
+ /obj/machinery/nanite_chamber = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Nanite chamber"),
+ /obj/machinery/public_nanite_chamber = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, "Nanite chamber"),
+ /obj/machinery/computer/nanite_cloud_controller = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, null),
+ /obj/machinery/dna_scannernew = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, null),
+ /obj/machinery/processor/slime = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, null),
+ /obj/machinery/quantum_server = SHIP_SAVE_PRICE(PART_CLASS_SCIENCE, 1, null),
+
+ /obj/machinery/computer/voidcrew_cargo = SHIP_SAVE_PRICE(PART_CLASS_TRADE, 1, "Cargo console"),
+ /obj/machinery/computer/cargo = SHIP_SAVE_PRICE(PART_CLASS_TRADE, 1, "Cargo console"),
+ /obj/machinery/mineral/ore_redemption = SHIP_SAVE_PRICE(PART_CLASS_TRADE, 1, "Ore redemption machine"),
+ /obj/machinery/ore_silo = SHIP_SAVE_PRICE(PART_CLASS_TRADE, 1, null),
+ /obj/machinery/computer/order_console/mining = SHIP_SAVE_PRICE(PART_CLASS_TRADE, 1, "Mining order console"),
+ /obj/machinery/piratepad = SHIP_SAVE_PRICE(PART_CLASS_TRADE, 1, "Pirate pad"),
+), clear_nulls = TRUE))
+
+#undef SHIP_SAVE_PRICE
+
+/// The bill's name for a priced machine. Never the instance's: players can rename those.
+/proc/ship_save_machine_line_name(list/price, obj/machinery/machine_type)
+ return price["label"] || capitalize(ship_save_plain_text(initial(machine_type.name)))
+
+/**
+ * What a save of this hull would keep and cost, from one walk of its owned tiles. Doesn't sleep, because
+ * ui_data calls it through /obj/structure/overmap/ship/proc/save_survey(). Null if the hull is gone.
+ */
+/proc/ship_save_survey(obj/docking_port/mobile/voidcrew/port)
+ SHOULD_NOT_SLEEP(TRUE)
+ if(QDELETED(port) || !port.z)
+ return null
+ var/list/bounds = port.return_coords()
+ var/turf/corner = locate(min(bounds[1], bounds[3]), min(bounds[2], bounds[4]), port.z)
+ if(!corner)
+ return null
+ var/list/rooms = list()
+ for(var/area/room as anything in hull_owned_areas(port))
+ rooms[room] = TRUE
+ return ship_save_survey_block(corner, abs(bounds[3] - bounds[1]) + 1, abs(bounds[4] - bounds[2]) + 1, rooms, port.current_ship?.job_definitions)
+
+/**
+ * The walk itself, over any block of tiles. rooms is area -> TRUE for the areas that are part of the hull.
+ * Returns mass, crew, plan, bill and total for the screens, and cost (class -> parts) for spend_parts().
+ */
+/proc/ship_save_survey_block(turf/corner, width, height, list/rooms, list/job_definitions)
+ SHOULD_NOT_SLEEP(TRUE)
+ // Highest priority first, for a tile holding several.
+ var/static/list/plan_rank = list("w" = 1, "s" = 2, "t" = 3, "e" = 4, "#" = 5, "g" = 6, "o" = 7)
+ var/static/list/class_mark = list(PART_CLASS_COMBAT = "w", PART_CLASS_SCIENCE = "s", PART_CLASS_TRADE = "t")
+ var/mass = 0
+ var/other_machines = 0
+ /// Line name -> list("class", "each", "count")
+ var/list/lines = list()
+ var/list/plan_rows = list()
+ for(var/offset_y in (height - 1) to 0 step -1)
+ var/list/plan_row = list()
+ for(var/offset_x in 0 to (width - 1))
+ var/turf/tile = locate(corner.x + offset_x, corner.y + offset_y, corner.z)
+ if(!tile || !rooms[tile.loc])
+ plan_row += "."
+ continue
+ mass += get_turf_mass_weight_instance(tile)
+ var/mark = isclosedturf(tile) ? "#" : "o"
+ for(var/obj/object in tile)
+ if(!ship_save_keeps_object(object))
+ continue
+ var/object_mark
+ if(istype(object, /obj/structure/window) || istype(object, /obj/structure/grille))
+ object_mark = "g"
+ else if(ismachinery(object))
+ var/obj/machinery/machine = object
+ // What comes back, which is what gets paid for: a machine rebuilds as its board's product.
+ var/saved_type = machine.checkpoint_type()
+ var/list/price = GLOB.ship_save_priced_machines[saved_type]
+ if(price)
+ var/line_name = ship_save_machine_line_name(price, saved_type)
+ var/list/line = lines[line_name]
+ if(!line)
+ line = list("class" = price["class"], "each" = price["each"], "count" = 0)
+ lines[line_name] = line
+ line["count"]++
+ object_mark = class_mark[price["class"]]
+ else if(istype(machine, /obj/machinery/power/shuttle_engine))
+ object_mark = "e"
+ else if(!is_type_in_typecache(machine, GLOB.outpost_checkpoint_infrastructure))
+ other_machines++
+ if(object_mark && plan_rank[object_mark] < plan_rank[mark])
+ mark = object_mark
+ plan_row += mark
+ plan_rows += list(plan_row)
+ var/crew = 0
+ for(var/list/definition in job_definitions)
+ crew += definition["slots"] || 1
+ var/list/survey = ship_save_price_bill(lines, mass, other_machines)
+ survey["mass"] = mass
+ survey["crew"] = crew
+ survey["plan"] = ship_save_encode_plan(plan_rows)
+ return survey
+
+/// Rounds each line up and adds them into bill, total and cost.
+/proc/ship_save_price_bill(list/lines, mass, other_machines)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/list/items_by_class = list()
+ var/total = 0
+ for(var/part_class in GLOB.ship_part_classes)
+ items_by_class[part_class] = list()
+ for(var/line_name in lines)
+ var/list/line = lines[line_name]
+ var/parts = CEILING(line["count"] * line["each"], 1)
+ items_by_class[line["class"]] += list(list("name" = line_name, "count" = line["count"], "parts" = parts))
+ total += parts
+ var/list/misc_items = items_by_class[PART_CLASS_MISC]
+ var/hull_parts = CEILING(mass / SHIP_SAVE_MASS_PER_PART, 1)
+ if(hull_parts)
+ misc_items += list(list("name" = "Hull, [mass] t", "count" = null, "parts" = hull_parts))
+ total += hull_parts
+ var/other_parts = CEILING(other_machines / SHIP_SAVE_MACHINES_PER_PART, 1)
+ if(other_parts)
+ misc_items += list(list("name" = "Other machines", "count" = other_machines, "parts" = other_parts))
+ total += other_parts
+ if(total < SHIP_SAVE_PRICE_MIN)
+ misc_items += list(list("name" = "Yard minimum", "count" = null, "parts" = SHIP_SAVE_PRICE_MIN - total))
+ total = SHIP_SAVE_PRICE_MIN
+ var/list/bill = list()
+ var/list/cost = list()
+ for(var/part_class in GLOB.ship_part_classes)
+ var/list/items = items_by_class[part_class]
+ if(!length(items))
+ continue
+ var/class_parts = 0
+ for(var/list/item as anything in items)
+ class_parts += item["parts"]
+ sortTim(items, GLOBAL_PROC_REF(cmp_ship_save_bill_line))
+ bill += list(list("class" = part_class, "parts" = class_parts, "items" = items))
+ cost[part_class] = class_parts
+ return list("bill" = bill, "total" = total, "cost" = cost)
+
+/// Biggest first, then by name so the order doesn't jump around between refreshes.
+/proc/cmp_ship_save_bill_line(list/line_a, list/line_b)
+ return (line_b["parts"] - line_a["parts"]) || sorttext(line_b["name"], line_a["name"])
+
+/**
+ * Rows of one-character marks, top row first, into the text the screens draw: rows of runs joined by "/",
+ * where a run is an optional count and one character. Empties at the end of a row and empty rows at the top
+ * and bottom are trimmed. Null if there is nothing to draw or it comes out too long to send.
+ */
+/proc/ship_save_encode_plan(list/rows)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/list/encoded = list()
+ for(var/list/row as anything in rows)
+ encoded += ship_save_encode_plan_row(row)
+ while(length(encoded) && !length(encoded[1]))
+ encoded.Cut(1, 2)
+ while(length(encoded) && !length(encoded[length(encoded)]))
+ encoded.Cut(length(encoded))
+ if(!length(encoded))
+ return null
+ var/plan = encoded.Join("/")
+ return length(plan) > SHIP_SAVE_PLAN_MAX_LENGTH ? null : plan
+
+/proc/ship_save_encode_plan_row(list/row)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/last = length(row)
+ while(last && row[last] == ".")
+ last--
+ var/list/runs = list()
+ var/start = 1
+ while(start <= last)
+ var/mark = row[start]
+ var/stop = start
+ while(stop < last && row[stop + 1] == mark)
+ stop++
+ runs += stop > start ? "[stop - start + 1][mark]" : mark
+ start = stop + 1
+ return runs.Join()
+
+/// Copies the survey's keys for the screens onto an entry, leaving out what it charges.
+/proc/ship_save_add_survey(list/entry, list/survey)
+ for(var/key in survey)
+ if(key != "cost")
+ entry[key] = survey[key]
+ return entry
+
+/// The summary column back into a survey. Anything that isn't one, including bad JSON, reads as no survey.
+/proc/ship_save_read_summary(text)
+ if(!istext(text))
+ return null
+ var/list/survey = safe_json_decode(text)
+ if(!islist(survey) || !isnum(survey["mass"]) || !isnum(survey["crew"]) || !isnum(survey["total"]) || !islist(survey["bill"]))
+ return null
+ var/plan = survey["plan"]
+ return list("mass" = survey["mass"], "crew" = survey["crew"], "plan" = istext(plan) ? plan : null, "bill" = survey["bill"], "total" = survey["total"])
+
+/// "3 combat, 4 science and 7 misc parts"
+/proc/ship_save_cost_text(list/cost)
+ var/list/amounts = list()
+ for(var/part_class in cost)
+ amounts += "[cost[part_class]] [part_class]"
+ return "[english_list(amounts)] parts"
+
+/obj/structure/overmap/ship
+ /// This hull's last ship_save_survey(), so an open window doesn't rescan it every tick.
+ var/list/save_survey_cache
+ /// When that survey was taken.
+ var/save_survey_taken = 0
+
+/// This hull's survey, at most SHIP_SAVE_SURVEY_LIFE old unless it is about to be paid for.
+/obj/structure/overmap/ship/proc/save_survey(fresh = FALSE)
+ SHOULD_NOT_SLEEP(TRUE)
+ if(fresh || !save_survey_cache || world.time >= save_survey_taken + SHIP_SAVE_SURVEY_LIFE)
+ save_survey_cache = ship_save_survey(shuttle)
+ save_survey_taken = world.time
+ return save_survey_cache
diff --git a/voidcrew/modules/ship_saves/ship_saves.dm b/voidcrew/modules/ship_saves/ship_saves.dm
index d59ea5f4025..c0996f17133 100644
--- a/voidcrew/modules/ship_saves/ship_saves.dm
+++ b/voidcrew/modules/ship_saves/ship_saves.dm
@@ -9,11 +9,14 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
var/list/cache = list()
/// ckey -> number of the newest load, so a slow read can't undo a save made after it started.
var/list/load_tickets = list()
- /// slot_key() -> TRUE for each save being retrieved, from the lobby or any console.
+ /// slot_key() -> TRUE for each slot being retrieved, deleted or written, from the lobby or any console.
var/list/retrievals = list()
/// slot_key() -> TRUE for each used save whose row hasn't been deleted yet. Reads leave these out,
/// so a slow or failed delete can't let one save be used twice.
var/list/spent_rows = list()
+ /// slot_key() -> weakref to the ship a save made this round came from. In memory only, so a save from
+ /// an earlier round has none. It can't be loaded while that ship is still in service.
+ var/list/live_sources = list()
/// The player's cached rows, or null if they aren't loaded. This is the only read ui_data may use.
/datum/ship_saves/proc/saves_for(ckey)
@@ -43,42 +46,48 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
load_tickets[ckey] = (load_tickets[ckey] || 0) + 1
return load_tickets[ckey]
-/// The lowest empty slot number. Null if they are all taken or the player isn't loaded.
-/datum/ship_saves/proc/free_slot(ckey)
- var/list/rows = saves_for(ckey)
- if(!rows)
- return null
- var/list/taken = list()
- for(var/list/row as anything in rows)
- taken += row["slot"]
- for(var/slot in 1 to SHIP_SAVE_SLOTS)
- if(!(slot in taken))
- return slot
+/// Why a ship can't be written to this slot now, or null.
+/datum/ship_saves/proc/slot_denial(ckey, slot)
+ SHOULD_NOT_SLEEP(TRUE)
+ if(!isnum(slot) || slot < 1 || slot > SHIP_SAVE_SLOTS || slot != round(slot))
+ return "No such slot."
+ if(retrievals[slot_key(ckey, slot)])
+ return "Already under way."
return null
-/// What a save costs in ship parts: the hull price curve, scaled so the biggest hull costs the most.
-/datum/ship_saves/proc/price_for(tiles)
- return clamp(floor(6 * ((tiles / 200) ** 1.6) / 4), SHIP_SAVE_PRICE_MIN, SHIP_SAVE_PRICE_MAX)
-
/**
- * Takes payment and writes a captured ship to the player's lowest free slot. Sleeps.
- * The payment goes back if the write fails. Returns an error for the player, or null on success.
+ * Takes payment and writes a captured ship to the player's chosen slot, replacing whatever is there. Sleeps.
+ * The payment goes back if the write fails, and the old row stays. Returns an error for the player, or null
+ * on success. The slot stays locked throughout, so nothing can use or delete the old save under it.
*/
-/datum/ship_saves/proc/commit_save(ckey, datum/ship_checkpoint/snapshot, mob/user)
+/datum/ship_saves/proc/commit_save(ckey, datum/ship_checkpoint/snapshot, mob/user, slot, list/survey)
ckey = ckey(ckey)
- var/slot = free_slot(ckey)
- if(!slot)
- return "All slots are full."
+ if(!begin_retrieval(ckey, slot))
+ return "Already under way."
+ . = write_claimed_save(ckey, snapshot, user, slot, survey)
+ cancel_retrieval(ckey, slot)
+
+/datum/ship_saves/proc/write_claimed_save(ckey, datum/ship_checkpoint/snapshot, mob/user, slot, list/survey)
+ PRIVATE_PROC(TRUE)
+ // A runtime below ends this proc with this answer, not with a null that reads as success.
+ . = "Shipyard records are offline."
+ var/key = slot_key(ckey, slot)
+ var/list/old_row = saved_row(ckey, slot)
+ var/old_name = old_row?["ship_name"]
+ // A failed delete leaves a row the cache doesn't show, and would otherwise block the slot for good.
+ var/overwrite = !!old_row || !!spent_rows[key]
var/tiles = snapshot.owned_tile_count()
- var/price = price_for(tiles)
var/name = snapshot.ship_name
var/class = snapshot.class_name
- var/obj/structure/overmap/ship/ship = snapshot.source_ship?.resolve()
+ var/datum/weakref/source = snapshot.source_ship
+ var/obj/structure/overmap/ship/ship = source?.resolve()
var/payload = encode(snapshot)
- var/list/plan = GLOB.ship_economy_db.spend_any_parts(ckey, price)
- if(!plan)
- return "You need [price] ship parts."
+ var/list/cost = survey["cost"]
+ GLOB.ship_economy_db.invalidate_cache(ckey, "parts")
+ if(!GLOB.ship_economy_db.spend_parts(ckey, cost))
+ return "You need [ship_save_cost_text(cost)]."
var/raw_bytes = length(payload)
+ var/list/summary = ship_save_add_survey(list(), survey)
var/list/row = list(
"ship_name" = name,
"ship_class" = class,
@@ -86,19 +95,30 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
"format_version" = SHIP_SAVE_FORMAT,
"saved_round" = text2num(GLOB.round_id),
"raw_bytes" = raw_bytes,
+ "summary" = json_encode(summary),
)
- if(!GLOB.ship_save_store.write(ckey, slot, row, payload))
- GLOB.ship_economy_db.refund_parts(ckey, plan)
- return "Shipyard records are offline."
+ if(!GLOB.ship_save_store.write(ckey, slot, row, payload, overwrite))
+ GLOB.ship_economy_db.refund_parts(ckey, cost)
+ return
+ row -= "summary"
+ row["survey"] = summary
row["slot"] = slot
- // The insert worked, so nothing older is left in this slot.
- spent_rows -= slot_key(ckey, slot)
+ // The write worked, so nothing older is left in this slot.
+ spent_rows -= key
+ if(source)
+ live_sources[key] = source
+ else
+ live_sources -= key
new_load_ticket(ckey)
var/list/rows = cache[ckey] || list()
+ // Looked up again: a refresh may have replaced the rows during the write.
+ var/list/replaced = saved_row(ckey, slot)
+ if(replaced)
+ rows -= list(replaced)
rows += list(row)
cache[ckey] = rows
- log_game("[key_name(user)] saved [name] to ship save slot [slot] for [price] ship parts, [raw_bytes] bytes.")
- record_metric(METRIC_SHIP, "ship_saved", ckey = ckey, ship = ship, zone = ship_metric_zone(ship), subject = class, quantity = price, details = list("tiles" = tiles, "raw_bytes" = raw_bytes))
+ log_game("[key_name(user)] saved [name] to ship save slot [slot] for [ship_save_cost_text(cost)], [raw_bytes] bytes[old_name ? ", replacing [old_name]" : ""].")
+ record_metric(METRIC_SHIP, "ship_saved", ckey = ckey, ship = ship, zone = ship_metric_zone(ship), subject = class, quantity = survey["total"], details = list("tiles" = tiles, "raw_bytes" = raw_bytes, "cost" = cost, "overwrote" = old_name))
refresh(ckey)
return null
@@ -113,6 +133,18 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
return row
return null
+/// What a screen shows of one saved ship, given why it can't be retrieved from there. Only reads caches.
+/datum/ship_saves/proc/saved_ship_entry(ckey, list/row, denial)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/slot = row["slot"]
+ return ship_save_add_survey(list(
+ "slot" = slot,
+ "name" = row["ship_name"],
+ "class" = row["ship_class"],
+ "denial" = denial,
+ "locked" = retrievals[slot_key(ckey, slot)] ? 1 : 0,
+ ), row["survey"])
+
/**
* Why a save can't be retrieved now, or null. Only reads caches, so ui_data can use it.
* allow_busy: the caller holds this save's lock and is checking again after a wait.
@@ -126,10 +158,18 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
return "No such record."
if(row["format_version"] != SHIP_SAVE_FORMAT)
return "This record can't be read."
- if(row["saved_round"] == text2num(GLOB.round_id))
- return "Ready next round."
- if(!allow_busy && retrievals[slot_key(ckey, slot)])
+ var/key = slot_key(ckey, slot)
+ if(!allow_busy && retrievals[key])
return "Already under way."
+ var/datum/weakref/source = live_sources[key]
+ var/obj/structure/overmap/ship/original = source?.resolve()
+ if(original)
+ if(original.retired_by_checkpoint)
+ return "The original hull has already been replaced."
+ if(!allow_busy && original.checkpoint_rebuilding)
+ return "Already under way."
+ if(!QDELETED(original.shuttle) && !original.abandoned)
+ return "The original hull is still in service."
return null
/// Claims a save for one retrieval, across the lobby and every console. FALSE if it is already claimed.
@@ -157,16 +197,19 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
if(!snapshot)
return "This record can't be read."
snapshot.captain_ckey = ckey
+ snapshot.source_ship = live_sources[slot_key(ckey, slot)]
return snapshot
/// The save has been used. It leaves the cache now and the table in the background, because a console
-/// build calls this mid-placement and must not sleep.
+/// build calls this mid-placement and must not sleep. The slot stays locked until the delete is done,
+/// so a new save to it can't be overtaken by the delete of the old one.
/datum/ship_saves/proc/spend(ckey, slot)
SHOULD_NOT_SLEEP(TRUE)
ckey = ckey(ckey)
var/key = slot_key(ckey, slot)
- retrievals -= key
+ retrievals[key] = TRUE
spent_rows[key] = TRUE
+ live_sources -= key
new_load_ticket(ckey)
var/list/row = saved_row(ckey, slot)
if(row)
@@ -176,10 +219,12 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
INVOKE_ASYNC(src, PROC_REF(delete_spent), ckey, slot)
/datum/ship_saves/proc/delete_spent(ckey, slot)
+ var/key = slot_key(ckey, slot)
if(GLOB.ship_save_store.remove(ckey, slot))
- spent_rows -= slot_key(ckey, slot)
+ spent_rows -= key
else
// It stays out of reads this round. Next round it would be back, so an admin should delete the row.
log_game("Used ship save [ckey] slot [slot] could not be deleted from the table.")
message_admins("Used ship save [ckey] slot [slot] could not be deleted from the table.")
+ retrievals -= key
refresh(ckey)
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index d0df4cfe43a..6e83c7dcc34 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -1,7 +1,9 @@
// Voidcrew defines load after the unit tests, so these mirror them.
#define SHIP_SAVE_TEST_SLOTS 3 // SHIP_SAVE_SLOTS
-#define SHIP_SAVE_TEST_PRICE_MIN 2 // SHIP_SAVE_PRICE_MIN
-#define SHIP_SAVE_TEST_PRICE_MAX 15 // SHIP_SAVE_PRICE_MAX
+#define SHIP_SAVE_TEST_PRICE_MIN 5 // SHIP_SAVE_PRICE_MIN
+#define SHIP_SAVE_TEST_MASS_PER_PART 150 // SHIP_SAVE_MASS_PER_PART
+#define SHIP_SAVE_TEST_MACHINES_PER_PART 15 // SHIP_SAVE_MACHINES_PER_PART
+#define SHIP_SAVE_TEST_PLAN_MAX 8000 // SHIP_SAVE_PLAN_MAX_LENGTH
#define SHIP_SAVE_TEST_PASSWORD_PRICE 3 // SHIP_PASSWORD_PRICE
#define SHIP_SAVE_TEST_FORMAT 1 // SHIP_SAVE_FORMAT
#define SHIP_SAVE_TEST_TILE_IRON 100 // SHIP_RTD_TILE_IRON
@@ -18,6 +20,8 @@
var/fail_reads = FALSE
var/fail_removes = FALSE
var/offline = FALSE
+ /// Whether the slot was locked when the last remove() ran.
+ var/locked_during_remove = FALSE
/datum/ship_save_store/fake/online()
return !offline
@@ -30,6 +34,8 @@
var/list/row = entry["meta"]
row = row.Copy()
row["slot"] = slot
+ row["survey"] = ship_save_read_summary(row["summary"])
+ row -= "summary"
rows += list(row)
return rows
@@ -39,13 +45,14 @@
var/list/entry = saved["[ckey]|[slot]"]
return entry?["payload"]
-/datum/ship_save_store/fake/write(ckey, slot, list/meta, payload)
- if(fail_writes || saved["[ckey]|[slot]"])
+/datum/ship_save_store/fake/write(ckey, slot, list/meta, payload, overwrite = FALSE)
+ if(fail_writes || (!overwrite && saved["[ckey]|[slot]"]))
return FALSE
saved["[ckey]|[slot]"] = list("meta" = meta.Copy(), "payload" = payload)
return TRUE
/datum/ship_save_store/fake/remove(ckey, slot)
+ locked_during_remove = !!GLOB.ship_saves.retrievals[GLOB.ship_saves.slot_key(ckey, slot)]
if(fail_removes || !saved["[ckey]|[slot]"])
return FALSE
saved -= "[ckey]|[slot]"
@@ -137,9 +144,13 @@
check_second_generation()
check_board_salvage()
check_saved_vars()
- check_prices()
+ check_survey()
+ check_bill_rounding()
+ check_plan()
+ check_summary()
check_part_plans()
check_commit()
+ check_overwrite()
check_retrieval_rules()
/datum/unit_test/voidcrew_ship_saves/proc/check_round_trip()
@@ -377,15 +388,206 @@
var/turf/open/floor/mineral/gold/floor_vars = /turf/open/floor/mineral/gold
TEST_ASSERT_NOTNULL(initial(floor_vars.floor_tile), "A floor's floor_tile has moved")
-/datum/unit_test/voidcrew_ship_saves/proc/check_prices()
- var/datum/ship_saves/saves = GLOB.ship_saves
- TEST_ASSERT_EQUAL(saves.price_for(275), 2, "Wrong price for a Goon-size hull")
- TEST_ASSERT_EQUAL(saves.price_for(340), 3, "Wrong price for a Kilo-size hull")
- TEST_ASSERT_EQUAL(saves.price_for(500), 6, "Wrong price for a Delta-size hull")
- TEST_ASSERT_EQUAL(saves.price_for(630), 9, "Wrong price for a Scarab-size hull")
- TEST_ASSERT_EQUAL(saves.price_for(850), 15, "Wrong price for a Phalanx-size hull")
- TEST_ASSERT_EQUAL(saves.price_for(1), SHIP_SAVE_TEST_PRICE_MIN, "The smallest hull went under the lowest price")
- TEST_ASSERT_EQUAL(saves.price_for(3000), SHIP_SAVE_TEST_PRICE_MAX, "The biggest hull went over the highest price")
+/// One class's entry on a bill, or null.
+/datum/unit_test/voidcrew_ship_saves/proc/bill_class(list/survey, part_class)
+ for(var/list/entry as anything in survey["bill"])
+ if(entry["class"] == part_class)
+ return entry
+ return null
+
+/// One line of one class's entry, or null.
+/datum/unit_test/voidcrew_ship_saves/proc/bill_line(list/survey, part_class, line_name)
+ var/list/entry = bill_class(survey, part_class)
+ for(var/list/item as anything in entry?["items"])
+ if(item["name"] == line_name)
+ return item
+ return null
+
+/// A hull of a few known machines, in the test room.
+/datum/unit_test/voidcrew_ship_saves/proc/check_survey()
+ var/turf/tile = run_loc_floor_bottom_left
+ // Earlier checks leave what they allocated here. Not effects: the test room's own marker is on this tile.
+ for(var/obj/loose in tile)
+ if(!iseffect(loose))
+ qdel(loose)
+ var/list/rooms = list()
+ rooms[tile.loc] = TRUE
+ var/obj/machinery/ship_combat/laser_turret/laser = allocate(/obj/machinery/ship_combat/laser_turret, tile)
+ // The bill never uses a name a player gave it.
+ laser.name = "Big Bertha"
+ for(var/i in 1 to 3)
+ allocate(/obj/machinery/porta_turret/ship_defense, tile)
+ allocate(/obj/machinery/chem_dispenser, tile)
+ allocate(/obj/machinery/chem_dispenser/drinks, tile)
+ allocate(/obj/machinery/computer/voidcrew_cargo, tile)
+ // Infrastructure, and things a save refuses, cost nothing and aren't counted.
+ allocate(/obj/machinery/door/airlock, tile)
+ allocate(/obj/machinery/portable_atmospherics/canister, tile)
+ var/obj/machinery/ship_combat/laser_turret/hologram = allocate(/obj/machinery/ship_combat/laser_turret, tile)
+ hologram.flags_1 |= HOLOGRAM_1
+ var/list/jobs = list(list("name" = "Captain", "slots" = 2), list("name" = "Deckhand"))
+ var/list/survey = ship_save_survey_block(tile, 1, 1, rooms, jobs)
+
+ TEST_ASSERT_EQUAL(survey["crew"], 3, "Crew is not the total slots in the job definitions")
+ TEST_ASSERT_EQUAL(survey["mass"], 0, "A tile that isn't hull has mass")
+ var/list/classes = list()
+ for(var/list/entry as anything in survey["bill"])
+ classes += entry["class"]
+ TEST_ASSERT_EQUAL(classes.Join(","), "combat,science,trade,misc", "The bill's classes are in the wrong order")
+ TEST_ASSERT_EQUAL(bill_class(survey, "combat")["parts"], 3, "Wrong combat total")
+ var/list/combat_items = bill_class(survey, "combat")["items"]
+ var/list/first_combat = combat_items[1]
+ TEST_ASSERT_EQUAL(first_combat["name"], "Defense turret", "The biggest line is not first")
+ TEST_ASSERT_EQUAL(first_combat["count"], 3, "Wrong count of defense turrets")
+ TEST_ASSERT_EQUAL(first_combat["parts"], 2, "Three defense turrets at 1 per 2 did not round up to 2")
+ var/list/laser_line = bill_line(survey, "combat", "Laser turret")
+ TEST_ASSERT_NOTNULL(laser_line, "The laser turret has no line under its own name")
+ TEST_ASSERT_EQUAL(laser_line["count"], 1, "A hologram counted as a laser turret")
+ TEST_ASSERT_EQUAL(bill_line(survey, "science", "Chem dispenser")?["parts"], 1, "A chem dispenser did not cost 1 science")
+ TEST_ASSERT_EQUAL(bill_class(survey, "science")["parts"], 1, "A drink dispenser counted as a chem dispenser")
+ TEST_ASSERT_EQUAL(bill_line(survey, "trade", "Cargo console")?["parts"], 1, "A cargo console did not cost 1 trade")
+ var/list/others = bill_line(survey, "misc", "Other machines")
+ TEST_ASSERT_EQUAL(others?["count"], 1, "Only the drink dispenser should be an other machine")
+ TEST_ASSERT_EQUAL(others?["parts"], 1, "One other machine did not round up to a part")
+ TEST_ASSERT_NULL(bill_line(survey, "misc", "Yard minimum"), "A hull over the minimum paid it")
+ TEST_ASSERT_EQUAL(survey["total"], 6, "Wrong total")
+ TEST_ASSERT_EQUAL(survey["cost"]["combat"], 3, "The charge disagrees with the bill")
+ TEST_ASSERT_EQUAL(survey["cost"]["science"], 1, "The charge disagrees with the bill")
+ TEST_ASSERT_EQUAL(survey["cost"]["trade"], 1, "The charge disagrees with the bill")
+ TEST_ASSERT_EQUAL(survey["cost"]["misc"], 1, "The charge disagrees with the bill")
+ TEST_ASSERT_EQUAL(ship_save_cost_text(survey["cost"]), "3 combat, 1 science, 1 trade and 1 misc parts", "Wrong words for the price")
+
+ // A label wins, a subtype inherits, and a name with no label comes from the type.
+ var/list/price = GLOB.ship_save_priced_machines[/obj/machinery/rnd/production/protolathe/department/science]
+ TEST_ASSERT_EQUAL(price?["class"], "science", "A subtype did not inherit its price")
+ TEST_ASSERT_NULL(GLOB.ship_save_priced_machines[/obj/machinery/chem_dispenser/drinks/beer], "A beer dispenser is on the price table")
+ TEST_ASSERT_NULL(GLOB.ship_save_priced_machines[/obj/machinery/chem_master/condimaster], "A CondiMaster is on the price table")
+ TEST_ASSERT_EQUAL(ship_save_machine_line_name(GLOB.ship_save_priced_machines[/obj/machinery/dna_scannernew], /obj/machinery/dna_scannernew), "DNA scanner", "A name from its type kept a text macro")
+
+ for(var/obj/thing in tile)
+ if(!iseffect(thing))
+ qdel(thing)
+
+/// The misc lines and the minimum, with nothing but numbers.
+/datum/unit_test/voidcrew_ship_saves/proc/check_bill_rounding()
+ var/list/kitted = ship_save_price_bill(list(), 650, 25)
+ var/list/hull_line = bill_line(kitted, "misc", "Hull, 650 t")
+ TEST_ASSERT_EQUAL(hull_line?["parts"], 5, "650 t did not round up to 5 parts")
+ TEST_ASSERT_NULL(hull_line?["count"], "The hull line has a count")
+ var/list/other_line = bill_line(kitted, "misc", "Other machines")
+ TEST_ASSERT_EQUAL(other_line?["parts"], 2, "25 machines did not round up to 2 parts")
+ TEST_ASSERT_EQUAL(other_line?["count"], 25, "The other machines line lost its count")
+ TEST_ASSERT_EQUAL(kitted["total"], 7, "Wrong total for a kitted hull")
+ var/list/exact = ship_save_price_bill(list(), SHIP_SAVE_TEST_MASS_PER_PART * 5, SHIP_SAVE_TEST_MACHINES_PER_PART)
+ TEST_ASSERT_EQUAL(exact["total"], 6, "An exact multiple was rounded up again")
+
+ // A tiny hull pays the minimum exactly, and the bill says why.
+ var/list/tiny = ship_save_price_bill(list(), 150, 1)
+ TEST_ASSERT_EQUAL(tiny["total"], SHIP_SAVE_TEST_PRICE_MIN, "A tiny hull did not pay exactly the minimum")
+ TEST_ASSERT_EQUAL(tiny["cost"]["misc"], SHIP_SAVE_TEST_PRICE_MIN, "The minimum isn't paid in misc parts")
+ TEST_ASSERT_EQUAL(bill_line(tiny, "misc", "Yard minimum")?["parts"], SHIP_SAVE_TEST_PRICE_MIN - 2, "The minimum line doesn't make up the difference")
+ TEST_ASSERT_NULL(bill_line(tiny, "misc", "Yard minimum")?["count"], "The minimum line has a count")
+ var/list/nothing = ship_save_price_bill(list(), 0, 0)
+ TEST_ASSERT_EQUAL(length(bill_class(nothing, "misc")["items"]), 1, "An empty hull billed lines for nothing")
+ TEST_ASSERT_EQUAL(nothing["total"], SHIP_SAVE_TEST_PRICE_MIN, "An empty hull did not pay the minimum")
+ var/list/lines = list("Defense turret" = list("class" = "combat", "each" = 0.5, "count" = 6))
+ TEST_ASSERT_EQUAL(bill_line(ship_save_price_bill(lines, 0, 0), "combat", "Defense turret")?["parts"], 3, "Six turrets at 1 per 2 are not one line of 3")
+
+ set_parts(misc = SHIP_SAVE_TEST_PRICE_MIN)
+ GLOB.ship_saves.refresh("tinyhull")
+ TEST_ASSERT_NULL(GLOB.ship_saves.commit_save("tinyhull", make_snapshot(), null, 1, tiny), "A tiny hull could not pay the minimum")
+ TEST_ASSERT_EQUAL(economy.total(), 0, "A tiny hull did not pay exactly the minimum")
+
+/// Encodes rows given as text, one mark per character.
+/datum/unit_test/voidcrew_ship_saves/proc/encode_rows(...)
+ var/list/rows = list()
+ for(var/text in args)
+ var/list/row = list()
+ for(var/i in 1 to length(text))
+ row += copytext(text, i, i + 1)
+ rows += list(row)
+ return ship_save_encode_plan(rows)
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_plan()
+ TEST_ASSERT_EQUAL(encode_rows(".....###oo"), "5.3#2o", "Runs are wrong")
+ TEST_ASSERT_EQUAL(encode_rows("....", "..##", "....", "oo..", "...."), "2.2#//2o", "Empty rows and trailing empties were not trimmed")
+ TEST_ASSERT_NULL(encode_rows("....", "...."), "An empty plan was not null")
+ var/too_long = ""
+ for(var/i in 1 to (SHIP_SAVE_TEST_PLAN_MAX / 10 + 1))
+ too_long += "wswswswsws"
+ TEST_ASSERT_NULL(encode_rows(too_long), "An oversized plan was sent")
+
+ // A small known hull: priority on a shared tile, then walls, windows and plain floor.
+ var/turf/corner = run_loc_floor_bottom_left
+ var/turf/up_one = get_step(corner, NORTH)
+ var/turf/wall_tile = get_step(up_one, EAST)
+ var/turf/engine_tile = get_step(wall_tile, EAST)
+ var/wall_tile_type = wall_tile.type
+ var/list/rooms = list()
+ rooms[corner.loc] = TRUE
+ var/turf/second = get_step(corner, EAST)
+ var/turf/third = get_step(second, EAST)
+ var/turf/fourth = get_step(third, EAST)
+ allocate(/obj/machinery/ship_combat/laser_turret, corner)
+ allocate(/obj/machinery/chem_dispenser, corner)
+ allocate(/obj/machinery/computer/voidcrew_cargo, corner)
+ allocate(/obj/machinery/chem_dispenser, second)
+ allocate(/obj/machinery/computer/voidcrew_cargo, second)
+ allocate(/obj/structure/grille, second)
+ allocate(/obj/machinery/computer/voidcrew_cargo, third)
+ allocate(/obj/structure/grille, third)
+ allocate(/obj/structure/grille, fourth)
+ // A refused object draws nothing.
+ allocate(/obj/structure/statue/gold/hos, up_one)
+ allocate(/obj/machinery/power/shuttle_engine/ship, engine_tile)
+ wall_tile.ChangeTurf(/turf/closed/wall)
+ allocate(/obj/structure/grille, wall_tile)
+ var/list/survey = ship_save_survey_block(corner, 4, 2, rooms, null)
+ TEST_ASSERT_EQUAL(survey["plan"], "o#eo/wstg", "A small hull's plan is wrong")
+ // Outside the owned rooms is empty, and an empty top row is trimmed.
+ survey = ship_save_survey_block(corner, 4, 2, list(), null)
+ TEST_ASSERT_NULL(survey["plan"], "A hull with no rooms has a plan")
+ for(var/turf/tile as anything in list(corner, second, third, fourth, up_one, wall_tile, engine_tile))
+ for(var/obj/thing in tile)
+ if(!iseffect(thing))
+ qdel(thing)
+ wall_tile.ChangeTurf(wall_tile_type)
+
+/datum/unit_test/voidcrew_ship_saves/proc/check_summary()
+ TEST_ASSERT_NULL(ship_save_read_summary(null), "A row with no summary has a survey")
+ var/bad_json = "{not json"
+ TEST_ASSERT_NULL(ship_save_read_summary(bad_json), "Bad JSON has a survey")
+ TEST_ASSERT_NULL(ship_save_read_summary("5"), "A number has a survey")
+ TEST_ASSERT_NULL(ship_save_read_summary(json_encode(list(1, 2))), "A list has a survey")
+ TEST_ASSERT_NULL(ship_save_read_summary(json_encode(list("mass" = "heavy", "crew" = 1, "total" = 1, "bill" = list()))), "A text mass has a survey")
+ var/list/good = ship_save_read_summary(json_encode(make_survey(list("misc" = 5))))
+ TEST_ASSERT_EQUAL(good?["mass"], 650, "A good summary lost its mass")
+ TEST_ASSERT_EQUAL(good?["plan"], "2#/2o", "A good summary lost its plan")
+ TEST_ASSERT(!("cost" in good), "A read summary can charge something")
+
+ // Through the table: the row still lists, without a survey, and the screens get no survey keys.
+ var/player_ckey = "summarytester"
+ add_stored_save(player_ckey, 1)
+ var/list/entry = store.saved["[player_ckey]|1"]
+ var/list/meta = entry["meta"]
+ meta["summary"] = "{not json"
+ GLOB.ship_saves.refresh(player_ckey)
+ var/list/row = GLOB.ship_saves.saved_row(player_ckey, 1)
+ TEST_ASSERT_NOTNULL(row, "Bad JSON lost the row")
+ TEST_ASSERT_NULL(row["survey"], "Bad JSON came back as a survey")
+ var/list/listed = GLOB.ship_saves.saved_ship_entry(player_ckey, row, null)
+ TEST_ASSERT_EQUAL(listed["slot"], 1, "An old row did not list")
+ TEST_ASSERT(!("mass" in listed), "A row with no survey sent survey keys")
+ TEST_ASSERT_NULL(GLOB.ship_saves.retrieve_denial(player_ckey, 1), "A row with no survey can't be retrieved")
+
+/// What commit_save() is told a hull costs.
+/datum/unit_test/voidcrew_ship_saves/proc/make_survey(list/cost)
+ var/total = 0
+ var/list/bill = list()
+ for(var/part_class in cost)
+ total += cost[part_class]
+ bill += list(list("class" = part_class, "parts" = cost[part_class], "items" = list()))
+ return list("mass" = 650, "crew" = 3, "plan" = "2#/2o", "bill" = bill, "total" = total, "cost" = cost)
/datum/unit_test/voidcrew_ship_saves/proc/check_part_plans()
set_parts(combat = 2, science = 9, trade = 4)
@@ -403,52 +605,124 @@
/datum/unit_test/voidcrew_ship_saves/proc/check_commit()
var/player_ckey = "savetester"
var/datum/ship_saves/saves = GLOB.ship_saves
+ var/list/price = make_survey(list("combat" = 1, "science" = 2, "misc" = 3))
saves.refresh(player_ckey)
TEST_ASSERT_EQUAL(length(saves.saves_for(player_ckey)), 0, "A new player was not loaded with no saves")
- TEST_ASSERT_EQUAL(saves.free_slot(player_ckey), 1, "The first free slot is not 1")
// Not enough parts: nothing is written.
set_parts()
- TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A save with no parts went through")
- TEST_ASSERT_EQUAL(length(store.saved), 0, "A save nobody paid for reached the table")
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price), "You need 1 combat, 2 science and 3 misc parts.", "A save with no parts went through")
+ TEST_ASSERT_NULL(store.saved["[player_ckey]|2"], "A save nobody paid for reached the table")
+
+ // Short of one class: plenty of the rest doesn't help, and nothing is taken.
+ set_parts(combat = 50, science = 1, trade = 50, misc = 50)
+ TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price), "A save short of one class went through")
+ TEST_ASSERT_EQUAL(economy.total(), 151, "A refused save took parts")
+ TEST_ASSERT_NULL(store.saved["[player_ckey]|2"], "A save short of one class reached the table")
// A failed write takes nothing.
- set_parts(combat = 20)
+ set_parts(combat = 5, science = 5, trade = 5, misc = 5)
store.fail_writes = TRUE
- TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A failed write reported success")
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price), "Shipyard records are offline.", "A failed write reported success")
TEST_ASSERT_EQUAL(economy.total(), 20, "A failed write kept the payment")
TEST_ASSERT_EQUAL(length(saves.saves_for(player_ckey)), 0, "A failed write reached the cache")
store.fail_writes = FALSE
+ TEST_ASSERT(!saves.retrievals[saves.slot_key(player_ckey, 2)], "A failed write kept the slot locked")
// A failed read is not "no saves".
var/list/loaded = saves.saves_for(player_ckey)
TEST_ASSERT_NOTNULL(loaded, "The cache was lost")
TEST_ASSERT_NULL(saves.saves_for("nobodyatall"), "An unloaded player reads as having no saves")
- TEST_ASSERT_NULL(saves.free_slot("nobodyatall"), "An unloaded player has a free slot")
- // Three saves fill the slots in order, charging each one.
- for(var/slot in 1 to SHIP_SAVE_TEST_SLOTS)
- TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(275), null), "Save [slot] failed")
- TEST_ASSERT_NOTNULL(store.saved["[player_ckey]|[slot]"], "Save [slot] went to the wrong slot")
- TEST_ASSERT_EQUAL(economy.total(), 20 - 2 * slot, "Save [slot] charged the wrong amount")
- var/list/entry = store.saved["[player_ckey]|1"]
+ // The chosen slot is used, and the matching classes pay.
+ TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price), "A paid save failed")
+ TEST_ASSERT_NOTNULL(store.saved["[player_ckey]|2"], "The save did not go to the chosen slot")
+ TEST_ASSERT_NULL(store.saved["[player_ckey]|1"], "The save went to the lowest slot instead")
+ TEST_ASSERT_EQUAL(economy.held["combat"], 4, "Combat parts were charged wrong")
+ TEST_ASSERT_EQUAL(economy.held["science"], 3, "Science parts were charged wrong")
+ TEST_ASSERT_EQUAL(economy.held["trade"], 5, "A class the hull doesn't need was charged")
+ TEST_ASSERT_EQUAL(economy.held["misc"], 2, "Misc parts were charged wrong")
+ TEST_ASSERT(!saves.retrievals[saves.slot_key(player_ckey, 2)], "A finished save kept the slot locked")
+ var/list/entry = store.saved["[player_ckey]|2"]
var/list/meta = entry["meta"]
TEST_ASSERT_EQUAL(meta["tile_count"], 275, "The row has the wrong tile count")
TEST_ASSERT_EQUAL(meta["ship_name"], "Test Hull", "The row has the wrong name")
var/list/stored = json_decode(entry["payload"])
TEST_ASSERT_EQUAL(meta["format_version"], stored["format"], "The row has the wrong format")
TEST_ASSERT_EQUAL(meta["raw_bytes"], length(entry["payload"]), "The row has the wrong size")
- TEST_ASSERT_NULL(saves.free_slot(player_ckey), "Three saves left a slot free")
+ var/list/stored_summary = json_decode(meta["summary"])
+ TEST_ASSERT_EQUAL(stored_summary["total"], 6, "The row's summary lost its total")
+ TEST_ASSERT(!("cost" in stored_summary), "The row's summary kept the charge")
+ var/list/row = saves.saved_row(player_ckey, 2)
+ TEST_ASSERT_EQUAL(row?["survey"]["total"], 6, "The cached row lost its survey")
- // Slots full: refused before any charge.
+ // A locked slot takes no save and no parts, and neither does a slot that isn't one.
var/parts_before = economy.total()
- TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null), "All slots are full.", "A fourth save was not refused")
- TEST_ASSERT_EQUAL(economy.total(), parts_before, "A refused save was charged")
-
- // A paid save is never overwritten: a write to a taken slot fails, and refunds.
+ TEST_ASSERT(saves.begin_retrieval(player_ckey, 3), "A free slot could not be locked")
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null, 3, price), "Already under way.", "A save went to a locked slot")
+ TEST_ASSERT_EQUAL(saves.slot_denial(player_ckey, 3), "Already under way.", "A locked slot was offered")
+ saves.cancel_retrieval(player_ckey, 3)
+ TEST_ASSERT_EQUAL(economy.total(), parts_before, "A save to a locked slot was charged")
+ TEST_ASSERT_NULL(saves.slot_denial(player_ckey, 3), "A free slot was refused")
+ for(var/bad_slot in list(0, SHIP_SAVE_TEST_SLOTS + 1, 1.5, "text", null))
+ TEST_ASSERT_EQUAL(saves.slot_denial(player_ckey, bad_slot), "No such slot.", "Slot [bad_slot] was accepted")
+
+/// Saving over a used slot: a new charge, one row, and nothing lost if it fails.
+/datum/unit_test/voidcrew_ship_saves/proc/check_overwrite()
+ var/player_ckey = "overwritetester"
+ var/datum/ship_saves/saves = GLOB.ship_saves
+ var/list/price = make_survey(list("misc" = 5))
+ saves.refresh(player_ckey)
+ set_parts(misc = 20)
+ TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(10), null, 1, price), "The first save failed")
+ var/key = saves.slot_key(player_ckey, 1)
+ // The old row's source, as if saved from a real hull.
+ var/datum/weakref/old_source = WEAKREF(allocate(/datum/ship_checkpoint))
+ saves.live_sources[key] = old_source
+
+ // A write that fails refunds and keeps everything about the old row.
+ store.fail_writes = TRUE
+ TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(99), null, 1, price), "A failed overwrite reported success")
+ store.fail_writes = FALSE
+ TEST_ASSERT_EQUAL(economy.total(), 15, "A failed overwrite kept the payment")
+ TEST_ASSERT_EQUAL(saves.saved_row(player_ckey, 1)?["tile_count"], 10, "A failed overwrite changed the cached row")
+ var/list/entry = store.saved["[player_ckey]|1"]
+ TEST_ASSERT_EQUAL(entry["meta"]["tile_count"], 10, "A failed overwrite changed the stored row")
+ TEST_ASSERT_EQUAL(saves.live_sources[key], old_source, "A failed overwrite lost the old row's source")
+
+ // An overwrite replaces the row, charges again and forgets the old row's source.
+ TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(99), null, 1, price), "An overwrite failed")
+ TEST_ASSERT_EQUAL(economy.total(), 10, "An overwrite was not charged")
+ entry = store.saved["[player_ckey]|1"]
+ TEST_ASSERT_EQUAL(entry["meta"]["tile_count"], 99, "An overwrite did not replace the stored row")
+ var/list/rows = saves.saves_for(player_ckey)
+ TEST_ASSERT_EQUAL(length(rows), 1, "An overwrite appended a second row")
+ TEST_ASSERT_EQUAL(saves.saved_row(player_ckey, 1)?["tile_count"], 99, "An overwrite did not replace the cached row")
+ TEST_ASSERT(!saves.live_sources[key], "An overwrite kept the old row's source")
+
+ // Overwrite only goes through what the cache knows: a row it can't see is not overwritten.
saves.cache[player_ckey] = list()
- TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(275), null), "A taken slot was overwritten")
- TEST_ASSERT_EQUAL(economy.total(), parts_before, "A duplicate key kept the payment")
+ TEST_ASSERT_NOTNULL(saves.commit_save(player_ckey, make_snapshot(5), null, 1, price), "A row the cache didn't show was overwritten")
+ TEST_ASSERT_EQUAL(economy.total(), 10, "A refused write kept the payment")
+ saves.refresh(player_ckey)
+
+ // A used save stays locked until its row is gone, and a failed delete doesn't block the slot for good.
+ saves.spend(player_ckey, 1)
+ TEST_ASSERT(store.locked_during_remove, "The slot wasn't locked while its row was being deleted")
+ TEST_ASSERT(!saves.retrievals[key], "A finished delete kept the slot locked")
+ TEST_ASSERT_NULL(store.saved["[player_ckey]|1"], "A used save stayed in the table")
+ saves.refresh(player_ckey)
+ TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(7), null, 1, price), "A freed slot could not be saved to")
+ store.fail_removes = TRUE
+ saves.spend(player_ckey, 1)
+ store.fail_removes = FALSE
+ TEST_ASSERT(!saves.retrievals[key], "A failed delete kept the slot locked")
+ TEST_ASSERT_NOTNULL(store.saved["[player_ckey]|1"], "The failed delete removed the row anyway")
+ TEST_ASSERT_NULL(saves.saved_row(player_ckey, 1), "A spent save stayed in the cache")
+ TEST_ASSERT_NULL(saves.commit_save(player_ckey, make_snapshot(8), null, 1, price), "A failed delete blocked the slot")
+ TEST_ASSERT(!saves.spent_rows[key], "A new save left the slot marked as spent")
+ entry = store.saved["[player_ckey]|1"]
+ TEST_ASSERT_EQUAL(entry["meta"]["tile_count"], 8, "A failed delete's row was not replaced")
/// Puts a row in the table, as if saved in an earlier round unless told otherwise.
/datum/unit_test/voidcrew_ship_saves/proc/add_stored_save(player_ckey, slot, saved_round = SHIP_SAVE_TEST_ROUND - 1, format = SHIP_SAVE_TEST_FORMAT)
@@ -467,7 +741,7 @@
// Every denial.
TEST_ASSERT_NULL(saves.retrieve_denial(player_ckey, 1), "A save from an earlier round was refused")
- TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 2), "Ready next round.", "A save made this round was offered")
+ TEST_ASSERT_NULL(saves.retrieve_denial(player_ckey, 2), "A save from this round with no ship behind it was refused")
TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 3), "This record can't be read.", "An unknown format was offered")
TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, SHIP_SAVE_TEST_SLOTS + 1), "No such record.", "An empty slot was offered")
TEST_ASSERT_EQUAL(saves.retrieve_denial("nobodyatall", 1), "No such record.", "An unloaded player was offered a save")
@@ -505,7 +779,6 @@
saves.spend(player_ckey, 1)
TEST_ASSERT_NULL(saves.saved_row(player_ckey, 1), "A spent save stayed in the cache")
TEST_ASSERT_NULL(store.saved["[player_ckey]|1"], "A spent save stayed in the table")
- TEST_ASSERT_EQUAL(saves.free_slot(player_ckey), 1, "A spent save did not free its slot")
TEST_ASSERT_EQUAL(saves.retrieve_denial(player_ckey, 1), "No such record.", "A spent save was still offered")
TEST_ASSERT(saves.begin_retrieval(player_ckey, 1), "Spending kept the claim")
saves.cancel_retrieval(player_ckey, 1)
@@ -534,6 +807,20 @@
/datum/ship_checkpoint_ui/registry_test/create_retrieval_job(mob/living/user, datum/ship_checkpoint/snapshot, slot)
return new /datum/checkpoint_construction/saved(src, snapshot, user, slot, manual_jobs)
+/// Sees the question, and can change things while the box is open.
+/datum/ship_checkpoint_ui/registry_test/save_prompt
+ var/last_prompt
+ var/datum/callback/while_open
+
+/datum/ship_checkpoint_ui/registry_test/save_prompt/Destroy()
+ while_open = null
+ return ..()
+
+/datum/ship_checkpoint_ui/registry_test/save_prompt/confirm_save(mob/user, prompt_text, title = "Shipyard")
+ last_prompt = prompt_text
+ while_open?.Invoke()
+ return ..()
+
/// The panel and the password, on a real hull in a real bay.
/datum/unit_test/voidcrew_ship_saves_hull
parent_type = /datum/unit_test/voidcrew_outpost_management
@@ -579,7 +866,7 @@
test_ship.enlist_crewmember(captain)
test_ship.claimed_captain = captain.mind
test_ship.enlist_crewmember(deckhand)
- var/datum/ship_checkpoint_ui/registry_test/panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
+ var/datum/ship_checkpoint_ui/registry_test/save_prompt/panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
var/datum/ship_checkpoint_ui/registry_test/deckhand_panel = allocate(__IMPLIED_TYPE__, home, terminal, deckhand)
TEST_ASSERT_NOTNULL(GLOB.ship_saves.saves_for(captain.ckey), "Opening the panel did not load the captain's saves")
@@ -598,34 +885,57 @@
bay.on_ship_docked(test_ship)
TEST_ASSERT(bay.is_ship_present(), "The hull did not land in the bay")
- TEST_ASSERT_NULL(panel.saved_ship_denial(captain, bay), "The captain could not save a docked ship")
- TEST_ASSERT_EQUAL(deckhand_panel.saved_ship_denial(deckhand, bay), "Only the ship's current captain can save it.", "A deckhand could save the ship")
+ TEST_ASSERT_NULL(panel.saved_ship_denial(captain, bay, 1), "The captain could not save a docked ship")
+ TEST_ASSERT_EQUAL(deckhand_panel.saved_ship_denial(deckhand, bay, 1), "Only the ship's current captain can save it.", "A deckhand could save the ship")
// No cryopods, no way to spawn on it later.
var/list/spawn_points = test_ship.shuttle.spawn_points
test_ship.shuttle.spawn_points = list()
- TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "This hull has no cryopods.", "A hull without cryopods could be saved")
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay, 1), "This hull has no cryopods.", "A hull without cryopods could be saved")
test_ship.shuttle.spawn_points = spawn_points
- // Declining costs nothing; accepting charges once and saves the real hull.
- set_parts(40)
+ // The survey of a real hull.
+ var/list/survey = test_ship.save_survey(fresh = TRUE)
+ TEST_ASSERT_NOTNULL(survey, "A docked hull has no survey")
+ TEST_ASSERT_EQUAL(survey["mass"], test_ship.calculate_mass(), "The survey's mass isn't the hull's")
+ TEST_ASSERT(survey["total"] >= SHIP_SAVE_TEST_PRICE_MIN, "A real hull is priced under the minimum")
+ var/plan = survey["plan"]
+ TEST_ASSERT(istext(plan) && length(plan), "A real hull has no plan")
+ TEST_ASSERT(!findtext(plan, "./") && copytext(plan, -1) != "." && copytext(plan, -1) != "/" && copytext(plan, 1, 2) != "/", "The plan was not trimmed: [plan]")
+ TEST_ASSERT(survey == test_ship.save_survey(), "An open window rescanned the hull inside its cache time")
+
+ // A slot is a whole number from 1 to 3.
+ set_each_part(40)
+ for(var/bad_slot in list(0, SHIP_SAVE_TEST_SLOTS + 1, 1.5, "text", null))
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay, bad_slot), "No such slot.", "Slot [bad_slot] was accepted")
+ TEST_ASSERT(!panel.save_ship(captain, bay, bad_slot), "A ship was saved to slot [bad_slot]")
+ TEST_ASSERT_EQUAL(panel.error, "No such slot.", "Slot [bad_slot] was refused with the wrong words")
+ TEST_ASSERT_EQUAL(length(store.saved), 0, "A bad slot reached the table")
+
+ // Declining costs nothing; accepting charges once and saves the real hull to the chosen slot.
panel.accept_save = FALSE
- TEST_ASSERT(!panel.save_ship(captain, bay), "Declining still saved the ship")
+ TEST_ASSERT(!panel.save_ship(captain, bay, 2), "Declining still saved the ship")
TEST_ASSERT_EQUAL(length(store.saved), 0, "Declining reached the table")
- TEST_ASSERT_EQUAL(parts_held(), 40, "Declining was charged")
+ TEST_ASSERT_EQUAL(parts_held(), 160, "Declining was charged")
+ TEST_ASSERT_EQUAL(panel.last_prompt, "Save [test_ship.name] to slot 2 for [ship_save_cost_text(survey["cost"])]?", "Wrong question for a new slot")
panel.accept_save = TRUE
- TEST_ASSERT(panel.save_ship(captain, bay), "Saving failed: [panel.error]")
+ TEST_ASSERT(panel.save_ship(captain, bay, 2), "Saving failed: [panel.error]")
TEST_ASSERT_NULL(panel.error, "A successful save left an error")
TEST_ASSERT_EQUAL(panel.notice, "[test_ship.name] saved.", "A successful save said nothing")
TEST_ASSERT(!panel.working, "The panel stayed busy after saving")
- var/list/entry = store.saved["savecaptain|1"]
- TEST_ASSERT_NOTNULL(entry, "The save did not reach slot 1")
+ var/list/entry = store.saved["savecaptain|2"]
+ TEST_ASSERT_NOTNULL(entry, "The save did not reach slot 2")
+ TEST_ASSERT_NULL(store.saved["savecaptain|1"], "The save ignored the chosen slot")
+ TEST_ASSERT_EQUAL(parts_held(), 160 - survey["total"], "The hull was charged the wrong price")
+ for(var/list/billed as anything in survey["bill"])
+ TEST_ASSERT_EQUAL(economy.held[billed["class"]], 40 - billed["parts"], "Wrong charge in [billed["class"]] parts")
+ var/datum/weakref/live_source = GLOB.ship_saves.live_sources[GLOB.ship_saves.slot_key("savecaptain", 2)]
+ TEST_ASSERT_EQUAL(live_source?.resolve(), test_ship, "The save does not remember the hull it came from")
// What was stored is a usable copy with nothing per machine in it.
var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(entry["payload"])
TEST_ASSERT_NOTNULL(decoded, "The stored hull did not decode")
- TEST_ASSERT_EQUAL(parts_held(), 40 - GLOB.ship_saves.price_for(decoded.owned_tile_count()), "The hull was charged the wrong price")
- TEST_ASSERT_EQUAL(hull_owned_tile_count(test_ship.shuttle), decoded.owned_tile_count(), "The price on the screen would differ from the price charged")
+ TEST_ASSERT_EQUAL(owned_tiles(test_ship.shuttle), decoded.owned_tile_count(), "The stored hull lost tiles")
TEST_ASSERT_EQUAL(decoded.class_name, test_ship.source_template.short_name, "The stored hull lost its class")
TEST_ASSERT_EQUAL(length(decoded.jobs), length(test_ship.job_definitions), "The stored hull lost its roster")
var/datum/parsed_map/parsed = new(decoded.tgm)
@@ -637,21 +947,61 @@
qdel(template)
qdel(decoded)
- // Slots full, and records offline, are refused by name.
+ // A full set of slots is no refusal: any of them can be overwritten. Records offline still are.
var/list/rows = GLOB.ship_saves.saves_for(captain.ckey)
- for(var/slot in 2 to SHIP_SAVE_TEST_SLOTS)
- rows += list(list("slot" = slot, "ship_name" = "Filler", "ship_class" = null))
- TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "All slots are full.", "Full slots were not refused")
+ for(var/slot in list(1, 3))
+ rows += list(list("slot" = slot, "ship_name" = "Filler", "ship_class" = null, "saved_round" = 1))
+ TEST_ASSERT_NULL(panel.saved_ship_denial(captain, bay, 1), "Full slots were refused")
+ TEST_ASSERT(panel.save_ship(captain, bay, 1), "A used slot could not be overwritten: [panel.error]")
+ TEST_ASSERT_EQUAL(panel.last_prompt, "Overwrite Filler with [test_ship.name] for [ship_save_cost_text(survey["cost"])]?", "Wrong question for a used slot")
+ TEST_ASSERT_EQUAL(parts_held(), 160 - survey["total"] * 2, "An overwrite was not charged again")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.saved_row(captain.ckey, 1)["ship_name"], test_ship.name, "An overwrite left the old row in the cache")
+ TEST_ASSERT_EQUAL(length(store.saved), 2, "An overwrite left the wrong rows in the table")
+
+ // A locked slot is refused, and so is one that changes while the box is open.
+ GLOB.ship_saves.refresh(captain.ckey)
+ var/parts_before = parts_held()
+ TEST_ASSERT(GLOB.ship_saves.begin_retrieval(captain.ckey, 1), "A free slot could not be locked")
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay, 1), "Already under way.", "A locked slot could be overwritten")
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1), "A locked slot was overwritten")
+ GLOB.ship_saves.cancel_retrieval(captain.ckey, 1)
+ panel.while_open = CALLBACK(src, PROC_REF(change_slot), captain.ckey, 3)
+ TEST_ASSERT(!panel.save_ship(captain, bay, 3), "A slot that changed under the box was written")
+ TEST_ASSERT_EQUAL(panel.error, "That slot changed. Try again.", "A changed slot gave the wrong refusal")
+ TEST_ASSERT(!panel.working, "A refused save left the panel busy")
+ panel.while_open = null
+ TEST_ASSERT_EQUAL(parts_held(), parts_before, "A refused save was charged")
+ TEST_ASSERT_NULL(store.saved["savecaptain|3"], "A refused save reached the table")
+ GLOB.ship_saves.refresh(captain.ckey)
+ TEST_ASSERT_EQUAL(panel.saved_ship_changed_denial(captain, bay, null, 1), "The docked ship changed. Try again.", "A ship swapped under the box was saved")
+
GLOB.ship_saves.cache -= captain.ckey
- TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay), "Shipyard records are offline.", "An unloaded player read as having slots")
+ TEST_ASSERT_EQUAL(panel.saved_ship_denial(captain, bay, 1), "Shipyard records are offline.", "An unloaded player read as having slots")
// The panel's data never needs the database, and matches what the screen is promised.
GLOB.ship_saves.refresh(captain.ckey)
+ economy.parts_cache[captain.ckey] = list("combat" = 1, "science" = 2, "trade" = 3, "misc" = 4)
var/list/saved_ships = panel.saved_ships_data(captain)
TEST_ASSERT_EQUAL(saved_ships["slots"], SHIP_SAVE_TEST_SLOTS, "The panel has the wrong slot count")
- TEST_ASSERT_EQUAL(length(saved_ships["saves"]), 1, "The panel did not list the save")
+ TEST_ASSERT_EQUAL(saved_ships["parts"]["trade"], 3, "The panel does not show parts by class")
+ TEST_ASSERT_EQUAL(length(saved_ships["saves"]), 2, "The panel did not list the saves")
+ var/list/listed_save = saved_ships["saves"][1]
+ TEST_ASSERT_EQUAL(listed_save["slot"], 1, "The first listed save is not in slot 1")
+ TEST_ASSERT_EQUAL(listed_save["name"], test_ship.name, "A listed save lost its name")
+ TEST_ASSERT_EQUAL(listed_save["mass"], survey["mass"], "A listed save lost its mass")
+ TEST_ASSERT_EQUAL(listed_save["locked"], 0, "A free save is listed as locked")
+ TEST_ASSERT(!("cost" in listed_save), "A listed save carries the charge")
var/list/listed_ships = saved_ships["ships"]
TEST_ASSERT_EQUAL(length(listed_ships), 1, "The panel did not list the docked ship")
+ var/list/listed_ship = listed_ships[1]
+ TEST_ASSERT_EQUAL(listed_ship["total"], survey["total"], "The price on the screen differs from the price charged")
+ TEST_ASSERT_EQUAL(listed_ship["class"], test_ship.source_template.short_name, "The docked ship lost its class")
+ TEST_ASSERT(!("cost" in listed_ship), "The docked ship carries the charge")
+ TEST_ASSERT(GLOB.ship_saves.begin_retrieval(captain.ckey, 1), "A free save could not be locked")
+ var/list/locked_view = panel.saved_ships_data(captain)
+ var/list/locked_save = locked_view["saves"][1]
+ TEST_ASSERT_EQUAL(locked_save["locked"], 1, "A locked save is not listed as locked")
+ GLOB.ship_saves.cancel_retrieval(captain.ckey, 1)
var/list/deckhand_view = panel.saved_ships_data(deckhand)
TEST_ASSERT_EQUAL(length(deckhand_view["ships"]), 0, "The panel offered a deckhand the ship")
@@ -660,6 +1010,20 @@
/datum/unit_test/voidcrew_ship_saves_hull/proc/set_parts(misc)
economy.held = list("combat" = 0, "science" = 0, "trade" = 0, "misc" = misc)
+/datum/unit_test/voidcrew_ship_saves_hull/proc/set_each_part(amount)
+ economy.held = list("combat" = amount, "science" = amount, "trade" = amount, "misc" = amount)
+
+/// Tiles in the rooms this hull owns.
+/datum/unit_test/voidcrew_ship_saves_hull/proc/owned_tiles(obj/docking_port/mobile/port)
+ . = 0
+ for(var/area/room as anything in hull_owned_areas(port))
+ . += length(room.get_turfs_by_zlevel(port.z))
+
+/// Something else writes to the slot while the confirm box is open.
+/datum/unit_test/voidcrew_ship_saves_hull/proc/change_slot(player_ckey, slot)
+ var/list/rows = GLOB.ship_saves.saves_for(player_ckey)
+ rows += list(list("slot" = slot, "ship_name" = "Interloper", "ship_class" = null, "saved_round" = 1))
+
/datum/unit_test/voidcrew_ship_saves_hull/proc/parts_held()
return economy.total()
@@ -742,15 +1106,16 @@
test_ship.state = "idle"
bay.on_ship_docked(test_ship)
- // A save made this round waits for the next one.
- set_parts(40)
- TEST_ASSERT(panel.save_ship(captain, bay), "Saving failed: [panel.error]")
+ // A save made this round waits for the ship it came from.
+ set_each_part(40)
+ TEST_ASSERT(panel.save_ship(captain, bay, 1), "Saving failed: [panel.error]")
saved_name = test_ship.name
saved_class = test_ship.source_template.short_name
saved_jobs = length(test_ship.job_definitions)
- TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "Ready next round.", "A ship was offered in the round it was saved")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "The original hull is still in service.", "A ship was offered while the one it came from is in service")
- // The same save as made last round, in every slot here and in a lobby player's first.
+ // The same save as made last round, so with no ship behind it, in the other slots here and in a lobby
+ // player's first. Slot 1 keeps its source, and is free once that hull is lost.
var/list/first = store.saved["savecaptain|1"]
var/list/first_meta = first["meta"]
first_meta["saved_round"] = SHIP_SAVE_TEST_ROUND - 1
@@ -763,12 +1128,12 @@
// The source is still in the bay.
var/list/panel_data = panel.saved_ships_data(captain)
var/list/listed_saves = panel_data["saves"]
- var/list/first_listed = listed_saves[1]
- TEST_ASSERT_EQUAL(first_listed["denial"], "The ship bay is occupied.", "The panel offered a save with the bay full")
- TEST_ASSERT(!panel.retrieve_ship(captain, 1), "A save was built with the bay full")
+ var/list/second_listed = listed_saves[2]
+ TEST_ASSERT_EQUAL(second_listed["denial"], "The ship bay is occupied.", "The panel offered a save with the bay full")
+ TEST_ASSERT(!panel.retrieve_ship(captain, 2), "A save was built with the bay full")
TEST_ASSERT_EQUAL(panel.error, "The ship bay is occupied.", "A full bay gave the wrong refusal")
vacate_bay(home)
- TEST_ASSERT_NULL(panel.saved_ship_retrieve_denial(captain, 1), "The panel refused a save with the bay empty")
+ TEST_ASSERT_NULL(panel.saved_ship_retrieve_denial(captain, 1), "The panel refused a save whose hull was lost, with the bay empty")
// Stopped before the first piece: the save is untouched and can be tried again.
TEST_ASSERT(panel.retrieve_ship(captain, 1), "Could not start building a save: [panel.error]")
@@ -792,7 +1157,6 @@
TEST_ASSERT(job.committed, "The first piece did not commit the build")
TEST_ASSERT_NULL(store.saved["savecaptain|2"], "The first piece did not spend the save")
TEST_ASSERT_NULL(GLOB.ship_saves.saved_row("savecaptain", 2), "The first piece left the save in the cache")
- TEST_ASSERT_EQUAL(GLOB.ship_saves.free_slot("savecaptain"), 2, "The first piece did not free the slot")
job.abort("Stopped for the test.")
TEST_ASSERT_NULL(store.saved["savecaptain|2"], "Stopping a build gave back a spent save")
TEST_ASSERT_NULL(GLOB.ship_saves.saved_row("savecaptain", 2), "Stopping a build put a spent save back in the cache")
@@ -833,7 +1197,6 @@
TEST_ASSERT(launched.password_cleared_ckeys["savelobby"], "The player is not cleared to board their own ship")
TEST_ASSERT_NULL(store.saved["savelobby|1"], "The lobby did not spend the save")
TEST_ASSERT_NULL(GLOB.ship_saves.saved_row("savelobby", 1), "The lobby left the save in the cache")
- TEST_ASSERT_EQUAL(GLOB.ship_saves.free_slot("savelobby"), 1, "The lobby did not free the slot")
check_retrieved(launched, "Lobby")
// The same save after its room and floor types were removed from the code still loads whole.
@@ -855,7 +1218,7 @@
TEST_ASSERT_NOTNULL(stale_ship, "A save with removed types did not load")
retrieved_ships += stale_ship
TEST_ASSERT_NULL(store.saved["savestale|1"], "A save with removed types was not spent")
- TEST_ASSERT_EQUAL(hull_owned_tile_count(stale_ship.shuttle), first_meta["tile_count"], "A save with removed types lost tiles")
+ TEST_ASSERT_EQUAL(owned_tiles(stale_ship.shuttle), first_meta["tile_count"], "A save with removed types lost tiles")
/// What a player could try to carry between rounds: an upgraded machine, a tank of plasma beside a tank of air.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/fit_source(obj/docking_port/mobile/voidcrew/port)
@@ -878,6 +1241,10 @@
/// As lose_original() does for the checkpoint tests: flies the source away and deletes it.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/vacate_bay(obj/structure/overmap/dynamic/player_outpost/home)
+ undock_source(home)
+ test_ship.shuttle.admin_delete_shuttle()
+
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/undock_source(obj/structure/overmap/dynamic/player_outpost/home)
var/obj/docking_port/stationary/transit/transit = test_ship.shuttle.assigned_transit || SSshuttle.generate_transit_dock(test_ship.shuttle)
test_ship.shuttle.mode = SHUTTLE_PREARRIVAL
test_ship.shuttle.initiate_docking(transit)
@@ -886,7 +1253,6 @@
test_ship.forceMove(get_turf(home))
test_ship.state = "flying"
home.on_ship_undock_complete(test_ship)
- test_ship.shuttle.admin_delete_shuttle()
/// Everything a retrieved ship must be, however it got here.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/check_retrieved(obj/structure/overmap/ship/ship, where)
@@ -933,6 +1299,146 @@
TEST_ASSERT(plasma_tanks, "[where]: the plasma tank did not come back")
TEST_ASSERT(air_tanks, "[where]: the air tank did not come back")
+/// A save loaded in the round it was made: only once the old hull is out of service, and it then replaces it.
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/same_round
+
+/datum/unit_test/voidcrew_ship_saves_hull/retrieval/same_round/Run()
+ var/obj/structure/overmap/dynamic/player_outpost/registry_test/home = allocate(__IMPLIED_TYPE__)
+ home.shell_template = allocate(/datum/map_template/player_outpost/test_fixture)
+ home.founder_ckey = "savecaptain"
+ TEST_ASSERT(home.load_level(), "The outpost did not load")
+ TEST_ASSERT_NULL(home.enable_ship_bays(), "The ship bay did not load")
+ var/obj/machinery/computer/ship_checkpoint/terminal = outpost_bay_shipyard_console(LAZYACCESS(home.bay_berths, 1))
+ TEST_ASSERT_NOTNULL(terminal, "The ship bay has no shipyard console")
+ var/mob/living/carbon/human/captain = make_player(get_turf(terminal), "savecaptain")
+ test_ship = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
+ TEST_ASSERT_NOTNULL(test_ship, "Could not create the hull")
+ test_ship.enlist_crewmember(captain)
+ test_ship.claimed_captain = captain.mind
+ var/datum/ship_checkpoint_ui/registry_test/panel = allocate(__IMPLIED_TYPE__, home, terminal, captain)
+ var/datum/outpost_berth/ship_bay/bay = home.allocate_ship_bay(test_ship)
+ TEST_ASSERT_NOTNULL(bay, "The hull was not given a bay")
+ adjust_reserve_dock_to_shuttle(bay.dock, test_ship.shuttle)
+ test_ship.shuttle.mode = SHUTTLE_PREARRIVAL
+ test_ship.shuttle.initiate_docking(bay.dock)
+ test_ship.shuttle.mode = SHUTTLE_IDLE
+ test_ship.docked = home
+ test_ship.forceMove(home)
+ test_ship.state = "idle"
+ bay.on_ship_docked(test_ship)
+ test_ship.ship_account.account_balance = 500
+
+ // Two saves of one hull.
+ set_each_part(100)
+ TEST_ASSERT(panel.save_ship(captain, bay, 1), "The first save failed: [panel.error]")
+ TEST_ASSERT(panel.save_ship(captain, bay, 2), "The second save failed: [panel.error]")
+
+ // In service, in the bay or out of it, nothing loads.
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "The original hull is still in service.", "A save was offered while its hull is in service")
+ undock_source(home)
+ TEST_ASSERT(!panel.retrieve_ship(captain, 1), "A save was built while its hull is in service")
+ TEST_ASSERT_EQUAL(panel.error, "The original hull is still in service.", "An in-service hull gave the wrong refusal")
+ TEST_ASSERT_NOTNULL(store.saved["savecaptain|1"], "A refused retrieval spent the save")
+ TEST_ASSERT(!test_ship.checkpoint_rebuilding, "A refused retrieval locked the hull")
+
+ // A job given a save of a crewed hull won't build it either, and lets go when it stops.
+ var/datum/ship_checkpoint/in_service_copy = GLOB.ship_saves.read_save("savecaptain", 1)
+ TEST_ASSERT(istype(in_service_copy), "A save would not read: [in_service_copy]")
+ TEST_ASSERT(GLOB.ship_saves.begin_retrieval("savecaptain", 1), "A free save could not be claimed")
+ var/datum/checkpoint_construction/saved/in_service = panel.create_retrieval_job(captain, in_service_copy, 1)
+ TEST_ASSERT_NOTNULL(in_service.bay, "A job could not reserve the empty bay")
+ TEST_ASSERT_NOTNULL(in_service.source_denial(), "A crewed original permitted a second hull")
+ TEST_ASSERT(test_ship.checkpoint_rebuilding, "A job did not lock its original hull")
+ qdel(in_service)
+ TEST_ASSERT(!test_ship.checkpoint_rebuilding, "A cancelled job kept a lock on the original")
+ TEST_ASSERT(!GLOB.ship_saves.retrievals[GLOB.ship_saves.slot_key("savecaptain", 1)], "A cancelled job kept its claim on the save")
+ TEST_ASSERT(bay.is_available(), "A cancelled job kept the bay")
+
+ // Abandoned, it can load.
+ test_ship.abandon_ship(crash = FALSE)
+ TEST_ASSERT_NULL(panel.saved_ship_retrieve_denial(captain, 1), "An abandoned hull blocks its save")
+
+ // One build at a time from one hull, across its saves.
+ TEST_ASSERT(panel.retrieve_ship(captain, 2), "Could not start building a save: [panel.error]")
+ var/datum/checkpoint_construction/saved/job = home.checkpoint_jobs[1]
+ TEST_ASSERT(test_ship.checkpoint_rebuilding, "A build did not lock the hull it replaces")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 1), "Already under way.", "A second save of a hull being replaced was offered")
+
+ // Another build got to its first piece before this one did: refused there, with nothing spent.
+ test_ship.retired_by_checkpoint = TRUE
+ job.fast_forward(1)
+ TEST_ASSERT(QDELETED(job), "A build of a replaced hull kept running")
+ TEST_ASSERT(!job.committed, "A build of a replaced hull placed a piece")
+ TEST_ASSERT_EQUAL(panel.error, "The original hull has already been replaced. Your saved ship is safe.", "The late refusal gave the wrong words")
+ TEST_ASSERT_NOTNULL(store.saved["savecaptain|2"], "The late refusal spent the save")
+ TEST_ASSERT_NOTNULL(GLOB.ship_saves.saved_row("savecaptain", 2), "The late refusal lost the cached save")
+ TEST_ASSERT(!GLOB.ship_saves.retrievals[GLOB.ship_saves.slot_key("savecaptain", 2)], "The late refusal kept its claim on the save")
+ TEST_ASSERT_EQUAL(test_ship.ship_account.account_balance, 500, "The late refusal moved the balance")
+ test_ship.retired_by_checkpoint = FALSE
+ var/deadline = world.time + 30 SECONDS
+ UNTIL(bay.is_available() || world.time > deadline)
+ TEST_ASSERT(bay.is_available(), "The late refusal kept the bay")
+
+ // A whole build retires the old hull at the first piece and pays its balance to the new one.
+ TEST_ASSERT(panel.retrieve_ship(captain, 1), "Could not start building a save: [panel.error]")
+ job = home.checkpoint_jobs[1]
+ job.fast_forward(1)
+ TEST_ASSERT(job.committed, "The first piece did not commit the build")
+ TEST_ASSERT(test_ship.retired_by_checkpoint, "The first piece did not retire the old hull")
+ TEST_ASSERT(!test_ship.checkpoint_rebuilding, "A retired hull kept its lock")
+ TEST_ASSERT_EQUAL(test_ship.ship_account.account_balance, 0, "The old hull kept its balance")
+ TEST_ASSERT_EQUAL(job.held_balance, 500, "The build isn't holding the old hull's balance")
+ TEST_ASSERT_NULL(GLOB.ship_saves.live_sources[GLOB.ship_saves.slot_key("savecaptain", 1)], "A spent save kept its source")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 2), "The original hull has already been replaced.", "A second save of a replaced hull was offered")
+ job.fast_forward()
+ TEST_ASSERT(QDELETED(job), "The build did not finish")
+ var/obj/structure/overmap/ship/rebuilt = bay.ship
+ TEST_ASSERT_NOTNULL(rebuilt, "The finished build is not in the bay")
+ retrieved_ships += rebuilt
+ TEST_ASSERT_EQUAL(rebuilt.ship_account.account_balance, 500, "The new ship did not get the old hull's balance")
+
+ // With the hull gone, the retired record still refuses. An unretired one wouldn't.
+ var/obj/docking_port/mobile/old_port = test_ship.detach_shuttle()
+ old_port.jumpToNullSpace()
+ TEST_ASSERT(!QDELETED(test_ship) && !test_ship.shuttle, "The fixture still has a hull")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.retrieve_denial("savecaptain", 2), "The original hull has already been replaced.", "A retired hull-less record was bypassed")
+ test_ship.retired_by_checkpoint = FALSE
+ TEST_ASSERT_NULL(GLOB.ship_saves.retrieve_denial("savecaptain", 2), "A hull-less record blocks its save")
+
+ // The lobby, with no job to do any of this for it.
+ var/obj/structure/overmap/ship/lobby_source = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
+ TEST_ASSERT_NOTNULL(lobby_source, "Could not create the lobby's hull")
+ retrieved_ships += lobby_source
+ lobby_source.ship_account.account_balance = 300
+ var/datum/ship_checkpoint/lobby_copy = new
+ var/capture_denial = lobby_copy.capture_hull(lobby_source, "savelobby")
+ TEST_ASSERT_NULL(capture_denial, "The lobby's hull was not captured: [capture_denial]")
+ GLOB.ship_saves.refresh("savelobby")
+ var/commit_error = GLOB.ship_saves.commit_save("savelobby", lobby_copy, null, 1, lobby_source.save_survey(fresh = TRUE))
+ qdel(lobby_copy)
+ TEST_ASSERT_NULL(commit_error, "The lobby's save failed: [commit_error]")
+
+ SSticker.current_state = GAME_STATE_PLAYING
+ var/mob/dead/new_player/ship_save_test/lobby = allocate(__IMPLIED_TYPE__)
+ lobby.key = "savelobby"
+ GLOB.ship_saves.launch(lobby, 1)
+ TEST_ASSERT_NULL(lobby.seated_on, "The lobby loaded a save while its hull is in service")
+ TEST_ASSERT_NOTNULL(store.saved["savelobby|1"], "A refused launch spent the save")
+ TEST_ASSERT(!lobby_source.checkpoint_rebuilding, "A refused launch locked the hull")
+ TEST_ASSERT(!lobby.spawning_ship, "A refused launch left the player stuck preparing a ship")
+
+ lobby_source.abandon_ship(crash = FALSE)
+ GLOB.ship_saves.launch(lobby, 1)
+ var/obj/structure/overmap/ship/launched = lobby.seated_on
+ TEST_ASSERT_NOTNULL(launched, "The lobby did not load a save whose hull was abandoned")
+ retrieved_ships += launched
+ TEST_ASSERT(lobby_source.retired_by_checkpoint, "The lobby did not retire the old hull")
+ TEST_ASSERT(!lobby_source.checkpoint_rebuilding, "The lobby left the old hull locked")
+ TEST_ASSERT_EQUAL(lobby_source.ship_account.account_balance, 0, "The old hull kept its balance")
+ TEST_ASSERT_EQUAL(launched.ship_account.account_balance, 300, "The new ship did not get the old hull's balance")
+ TEST_ASSERT_NULL(store.saved["savelobby|1"], "The lobby did not spend the save")
+ TEST_ASSERT_NULL(GLOB.ship_saves.live_sources[GLOB.ship_saves.slot_key("savelobby", 1)], "A spent save kept its source")
+
/**
* The real table, through the real store and the game's database connection. Skipped with no database.
* Everything it writes belongs to one test ckey, and is deleted before and after.
@@ -1020,6 +1526,40 @@
TEST_ASSERT_EQUAL(first_row["raw_bytes"], meta["raw_bytes"], "The row has the wrong size")
TEST_ASSERT(!store.write(test_ckey, 1, meta, "another hull"), "A taken slot was overwritten")
TEST_ASSERT(store.read_payload(test_ckey, 1) == payload, "A refused write changed the saved hull")
+ TEST_ASSERT_NULL(first_row["survey"], "A row with no summary has a survey")
+
+ // An overwrite replaces every column and keeps one row, and the summary comes back.
+ var/list/replacement = meta.Copy()
+ replacement["ship_name"] = "Replacement"
+ replacement["tile_count"] = 7
+ replacement["raw_bytes"] = 12
+ replacement["summary"] = json_encode(list("mass" = 650, "crew" = 3, "plan" = "2#/2o", "bill" = list(list("class" = "misc", "parts" = 5, "items" = list(list("name" = "Hull, 650 t", "count" = null, "parts" = 5)))), "total" = 5))
+ TEST_ASSERT(store.write(test_ckey, 1, replacement, "another hull", overwrite = TRUE), "An overwrite was not written")
+ rows = store.read_meta(test_ckey)
+ TEST_ASSERT_EQUAL(length(rows), 2, "An overwrite did not keep one row per slot")
+ first_row = rows[1]
+ TEST_ASSERT_EQUAL(first_row["ship_name"], "Replacement", "An overwrite did not replace the name")
+ TEST_ASSERT_EQUAL(first_row["tile_count"], 7, "An overwrite did not replace the tile count")
+ TEST_ASSERT_EQUAL(first_row["raw_bytes"], 12, "An overwrite did not replace the size")
+ TEST_ASSERT(store.read_payload(test_ckey, 1) == "another hull", "An overwrite did not replace the map")
+ var/list/survey = first_row["survey"]
+ TEST_ASSERT_EQUAL(survey?["mass"], 650, "The summary did not come back from the table")
+ TEST_ASSERT_EQUAL(survey?["plan"], "2#/2o", "The plan did not come back from the table")
+ var/list/bill_entry = survey?["bill"][1]
+ TEST_ASSERT_EQUAL(bill_entry?["class"], "misc", "The bill did not come back from the table")
+
+ // An overwrite of an empty slot is an ordinary write, and bad JSON in the column reads as no survey.
+ replacement["summary"] = "{not json"
+ TEST_ASSERT(store.write(test_ckey, 3, replacement, "a third hull", overwrite = TRUE), "An overwrite of an empty slot failed")
+ rows = store.read_meta(test_ckey)
+ var/list/third_row = rows[3]
+ TEST_ASSERT_EQUAL(third_row["slot"], 3, "The third row is not slot 3")
+ TEST_ASSERT_NULL(third_row["survey"], "Bad JSON in the column came back as a survey")
+ TEST_ASSERT(store.remove(test_ckey, 3), "The third save was not removed")
+
+ // A plain write to a used slot still fails, whatever overwrite did before it.
+ TEST_ASSERT(!store.write(test_ckey, 1, meta, payload), "A plain write replaced a used slot")
+ TEST_ASSERT(store.read_payload(test_ckey, 1) == "another hull", "A refused plain write changed the saved hull")
// Using a save removes it once.
TEST_ASSERT(store.remove(test_ckey, 1), "A save was not removed")
@@ -1034,7 +1574,9 @@
#undef SHIP_SAVE_TEST_GONE_PATH
#undef SHIP_SAVE_TEST_SLOTS
#undef SHIP_SAVE_TEST_PRICE_MIN
-#undef SHIP_SAVE_TEST_PRICE_MAX
+#undef SHIP_SAVE_TEST_MASS_PER_PART
+#undef SHIP_SAVE_TEST_MACHINES_PER_PART
+#undef SHIP_SAVE_TEST_PLAN_MAX
#undef SHIP_SAVE_TEST_PASSWORD_PRICE
#undef SHIP_SAVE_TEST_TOO_LONG
#undef SHIP_SAVE_TEST_TILE_IRON
diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md
index d49385db58e..5861b325064 100644
--- a/wiki/content/player-outposts.md
+++ b/wiki/content/player-outposts.md
@@ -169,9 +169,13 @@ The bay's own **shipyard console**, found only in the bay itself, has two tabs:
### Saved ships
-You have three save slots. Dock a ship you command in a ship bay and pay [ship parts](ship-parts.md) to save it: bigger ships cost more, up to 15 parts, and any class of part will do. The hull needs at least one cryopod. A full set of slots means no more saves until you use one.
+You have three save slots. Dock a ship you command in a ship bay, choose a slot and pay [ship parts](ship-parts.md) to save it there. The hull needs at least one cryopod. Saving to a slot that already holds a ship overwrites that save, and you pay again.
-A saved ship is ready from the next round. Get it back for free, either from **Saved Ships** in the lobby's join menu, which launches it with you aboard, or from the **Saved ships** tab of a shipyard console in any ship bay, where the delivery drones build it. Each save works once: using it uses it up and frees the slot.
+The price depends on what is aboard, and the console lists it before you pay. Weapons and defenses cost combat parts, research and chemistry machines cost science parts, cargo and mining machines cost trade parts, and the hull and everything else cost misc parts. Every save costs at least 5 parts in total. You need enough of each class. Whatever you take off the ship before saving is not charged for, and is not in the save.
+
+Get a saved ship back for free, either from **Saved Ships** in the lobby's join menu, which launches it with you aboard, or from the **Saved ships** tab of a shipyard console in any ship bay, where the delivery drones build it. Each save works once: using it uses it up and frees the slot.
+
+You can load a save in the same round you made it once the ship it came from is lost or abandoned. Loading it retires the old hull and moves its account to the new ship.
| Comes back | Stays behind |
|---|---|
diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md
index 2a6910a4ea0..f0c6b2dd7ba 100644
--- a/wiki/content/ship-parts.md
+++ b/wiki/content/ship-parts.md
@@ -81,4 +81,6 @@ Open the **Shipyard** from the join menu to see your balance and the prices of h
Unlocks are permanent. Buying a theme or module lets you select it for future launches of that hull without paying again, and losing a ship does not erase your purchases. See [Buying & Upgrading Ships](ship-upgrades.md) for choosing a loadout and [Ship Catalog](ship-catalog.md) for the available hulls.
-Parts of any class also pay for two things that are not unlocks: [saving a ship](player-outposts.md) in a ship bay, and a ship's join password. Setting or changing the password costs **3 parts**; clearing it is free.
+[Saving a ship](player-outposts.md) in a ship bay also costs parts, in the classes that match what is aboard: combat parts for weapons, science parts for research and chemistry machines, trade parts for cargo and mining machines, and misc parts for the hull and everything else. Every save costs at least 5 parts in total.
+
+A ship's join password can be paid for with parts of any class. Setting or changing it costs **3 parts**; clearing it is free.
From e5d38edb9e7da99dca307d357fc4a69dbbdbf2f1 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 18:59:55 -0800
Subject: [PATCH 11/72] Bring a loaded ship's safe fixtures back stocked
---
.../outpost_checkpoint_machinery.dm | 16 ++--
.../outpost_checkpoint_rebuild.dm | 5 +-
.../outpost_checkpoint_snapshot.dm | 12 +--
.../modules/ship_saves/ship_save_capture.dm | 82 +++++++++++++++++-
.../unit_tests/voidcrew_checkpoints.dm | 8 +-
.../voidcrew_checkpoints_every_ship.dm | 5 +-
.../modules/unit_tests/voidcrew_ship_saves.dm | 86 ++++++++++++++++++-
wiki/content/player-outposts.md | 3 +-
8 files changed, 197 insertions(+), 20 deletions(-)
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
index e90db4dbde9..a1edf49a1c2 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
@@ -4,6 +4,9 @@
/obj/machinery/proc/checkpoint_type()
if(is_type_in_typecache(src, GLOB.outpost_checkpoint_excluded))
return null
+ // Its stock comes from its type, and a board's product is the plain, empty version.
+ if(is_type_in_typecache(src, GLOB.ship_save_stocked_fixtures))
+ return type
if(anchored && istype(circuit) && ispath(circuit.build_path, /obj/machinery))
return circuit.build_path
return type
@@ -47,15 +50,18 @@
* at the default tier it initialized with.
*/
/proc/checkpoint_restore_machine(obj/machinery/machine)
+ // Scrubbing its refill and rereading its parts would empty a vendor that comes back stocked.
+ var/stocked_vendor = istype(machine, /obj/machinery/vending) && is_type_in_typecache(machine, GLOB.ship_save_stocked_fixtures)
if(machine.circuit)
// Retain the newly initialized circuit and its machine-specific configuration.
var/obj/item/circuitboard/board = machine.circuit
- for(var/obj/item/part in machine.component_parts)
- if(part != board)
- checkpoint_scrub_part(part)
ship_save_strip_board_salvage(board)
- machine.RefreshParts()
- if(istype(machine, /obj/machinery/vending))
+ if(!stocked_vendor)
+ for(var/obj/item/part in machine.component_parts)
+ if(part != board)
+ checkpoint_scrub_part(part)
+ machine.RefreshParts()
+ if(istype(machine, /obj/machinery/vending) && !stocked_vendor)
var/obj/machinery/vending/vendor = machine
for(var/datum/data/vending_product/product as anything in vendor.product_records + vendor.hidden_records + vendor.coin_records)
product.amount = 0
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
index 74670469441..7332ab1a7e6 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
@@ -9,6 +9,8 @@
for(var/atom/movable/object as anything in tile.get_all_contents())
if(QDELETED(object))
continue
+ if(isitem(object) && checkpoint_held_by_restocked(object, restocked_types))
+ continue
var/restocked = is_type_in_typecache(object, restocked_types)
if(!restocked)
object.reagents?.clear_reagents()
@@ -40,7 +42,8 @@
QDEL_NULL(crate.tank)
if(istype(object, /obj/structure/tank_dispenser))
var/obj/structure/tank_dispenser/dispenser = object
- dispenser.oxygentanks = 0
+ if(!restocked)
+ dispenser.oxygentanks = 0
dispenser.plasmatanks = 0
dispenser.update_appearance()
if(istype(object, /obj/machinery/power/port_gen/pacman))
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
index eab91ea000b..72e23ecb2ab 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
@@ -128,16 +128,16 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
if(istype(machine, /obj/machinery/power))
keys += "cable_layer"
if(istype(object, /obj/structure/closet))
- // Closets keep their own type, so they look the same; their stock is never generated
- // (below) and anything they spawn is scrubbed on load. Emergency closets are the
- // exception: they can delete or replace themselves as they initialize.
+ // Closets keep their own type, so they look the same. Emergency closets are the exception:
+ // they can delete or replace themselves as they initialize.
if(istype(object, /obj/structure/closet/emcloset))
- saved_type = /obj/structure/closet
+ saved_type = /obj/structure/closet/ship_emergency
if(istype(object, /obj/structure/closet/crate))
keys += list("lid_icon", "lid_icon_state")
keys += list("icon", "icon_door", "base_icon_state", "enable_door_overlay", "has_opened_overlay", "has_closed_overlay", "wall_mounted", "horizontal", "locked", "req_one_access")
- // Closet stock is otherwise generated lazily on first opening.
- properties += "contents_initialized = 1"
+ // Closet stock is generated lazily on first opening. Only a stocked fixture's ever is.
+ if(!GLOB.ship_save_stocked_fixtures[saved_type])
+ properties += "contents_initialized = 1"
if(istype(object, /obj/machinery/light))
// Lights otherwise materialize a charged mock battery after map loading.
properties += "start_with_cell = 0"
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index 06830d49a98..745ce1a9cc0 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -61,9 +61,74 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
/proc/ship_save_keeps_object(obj/object)
return !ship_save_refuses_object(object) && outpost_checkpoint_saves(object)
-/// Basics a saved ship keeps as they load, full: breathable air tanks, welding fuel and water tanks, and
-/// plumbing water. Every other supply is scrubbed. Not plasma tanks, or a hull full of them would be gas
-/// to sell next round: its engines run on the heater reserve instead.
+/**
+ * Fixtures a loaded ship gets back full, as a new one of that type comes. What they hold is their own fresh stock:
+ * a save stores no items, so nothing the crew put in them carries over. Weapons, armor and command gear stay off
+ * this list, and everything off it comes back empty. A machine here keeps its type rather than its board's product.
+ */
+GLOBAL_LIST_INIT(ship_save_stocked_fixtures, ship_save_build_stocked_fixtures())
+
+/proc/ship_save_build_stocked_fixtures()
+ var/list/stocked = typecacheof(list(
+ /obj/structure/tank_dispenser,
+ /obj/structure/extinguisher_cabinet,
+ /obj/structure/closet/ship_emergency,
+ /obj/structure/closet/firecloset,
+ /obj/structure/closet/radiation,
+ /obj/structure/closet/toolcloset,
+ /obj/structure/closet/l3closet,
+ /obj/structure/closet/wardrobe,
+ /obj/structure/closet/athletic_mixed,
+ /obj/structure/closet/crate/freezer/blood,
+ /obj/structure/closet/secure_closet/engineering_electrical,
+ /obj/structure/closet/secure_closet/engineering_welding,
+ /obj/structure/closet/secure_closet/engineering_personal,
+ /obj/structure/closet/secure_closet/atmospherics,
+ /obj/structure/closet/secure_closet/medical1,
+ /obj/structure/closet/secure_closet/medical2,
+ /obj/structure/closet/secure_closet/medical3,
+ /obj/structure/closet/secure_closet/chemical,
+ /obj/structure/closet/secure_closet/personal,
+ /obj/structure/closet/secure_closet/freezer/fridge,
+ /obj/structure/closet/secure_closet/freezer/kitchen,
+ /obj/machinery/suit_storage_unit/standard_unit,
+ /obj/machinery/suit_storage_unit/engine,
+ /obj/machinery/suit_storage_unit/atmos,
+ /obj/machinery/suit_storage_unit/medical,
+ /obj/machinery/suit_storage_unit/mining,
+ /obj/machinery/smartfridge/chemistry/preloaded,
+ /obj/machinery/vending/coffee,
+ /obj/machinery/vending/snack,
+ /obj/machinery/vending/cigarette,
+ /obj/machinery/vending/hydroseeds,
+ /obj/machinery/vending/hydronutrients,
+ /obj/machinery/vending/clothing,
+ /obj/machinery/vending/medical,
+ /obj/machinery/vending/wallmed,
+ ))
+ stocked -= typecacheof(/obj/machinery/vending/medical/syndicate)
+ return stocked
+
+/**
+ * An emergency closet, as a saved ship has it. The map's own type can delete or replace itself as it
+ * initializes, and may hold nothing at all.
+ */
+/obj/structure/closet/ship_emergency
+ name = "emergency closet"
+ desc = "It's a storage unit for emergency breath masks and O2 tanks."
+ icon_state = "emergency"
+
+/obj/structure/closet/ship_emergency/PopulateContents()
+ ..()
+ new /obj/item/storage/toolbox/emergency(src)
+ new /obj/item/storage/medkit/emergency(src)
+ for(var/i in 1 to 2)
+ new /obj/item/tank/internals/emergency_oxygen(src)
+ new /obj/item/clothing/mask/breath(src)
+
+/// What a saved ship keeps as it loads, full: breathable air tanks, welding fuel and water tanks, plumbing
+/// water and the stocked fixtures. Every other supply is scrubbed. Not plasma tanks, or a hull full of them
+/// would be gas to sell next round: its engines run on the heater reserve instead.
/proc/ship_save_restocked()
var/static/list/restocked
if(!restocked)
@@ -76,8 +141,19 @@ GLOBAL_LIST_INIT(ship_save_precious_materials, typecacheof(list(
/obj/machinery/shower,
/obj/structure/sink,
))
+ for(var/fixture_type in GLOB.ship_save_stocked_fixtures)
+ restocked[fixture_type] = TRUE
return restocked
+/// Whether this item is the stock of a fixture that keeps what it loads with.
+/proc/checkpoint_held_by_restocked(obj/item/item, list/restocked_types)
+ var/atom/holder = item.loc
+ while(holder && !isturf(holder))
+ if(is_type_in_typecache(holder, restocked_types))
+ return TRUE
+ holder = holder.loc
+ return FALSE
+
/// Text macros are raw bytes the database connection would mangle. A saved name goes without them.
/proc/ship_save_plain_text(text)
return replacetext(replacetext(text, "\improper", ""), "\proper", "")
diff --git a/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm b/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
index 322ec7e25fb..a60f622b5b5 100644
--- a/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
@@ -432,8 +432,12 @@
if(!(get_area(tile) in rebuilt.shuttle.shuttle_areas))
continue
for(var/obj/structure/closet/storage in tile)
- storage.dump_contents() // First use must not spawn fresh default stock.
+ // First use must not spawn fresh default stock, unless it is a stocked fixture.
+ if(!is_type_in_typecache(storage, GLOB.ship_save_stocked_fixtures))
+ storage.dump_contents()
for(var/obj/item/item in tile.get_all_contents())
+ if(checkpoint_held_by_restocked(item, ship_save_restocked()))
+ continue
if(ismachinery(item.loc))
var/obj/machinery/machine = item.loc
if(istype(machine, /obj/machinery/computer/camera_advanced/base_construction/ship) && (istype(item, /obj/item/construction) || istype(item, /obj/item/pipe_dispenser) || istype(item, /obj/item/airlock_painter)))
@@ -481,6 +485,8 @@
for(var/obj/structure/bedsheetbin/bin in tile)
TEST_ASSERT_EQUAL(bin.amount, 0, "Recovery restocked a bedsheet bin")
for(var/obj/machinery/vending/vendor in tile)
+ if(is_type_in_typecache(vendor, GLOB.ship_save_stocked_fixtures))
+ continue
for(var/datum/data/vending_product/product as anything in vendor.product_records + vendor.hidden_records + vendor.coin_records)
TEST_ASSERT_EQUAL(product.amount, 0, "Recovery restocked a vendor")
for(var/obj/machinery/atmospherics/machine in tile)
diff --git a/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm b/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm
index f53e0bb3bc6..e4609603813 100644
--- a/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_checkpoints_every_ship.dm
@@ -143,6 +143,9 @@
if(!(get_area(tile) in port.shuttle_areas))
continue
for(var/obj/item/item in tile.get_all_contents())
+ // A stocked fixture comes back with what a new one holds.
+ if(checkpoint_held_by_restocked(item, ship_save_restocked()))
+ continue
if(ismachinery(item.loc))
var/obj/machinery/machine = item.loc
if((item in machine.component_parts) || item == machine.circuit || istype(item, /obj/item/radio))
@@ -171,7 +174,7 @@
var/obj/machinery/atmospherics/components/tank/stored = object
if(stored.air_contents?.total_moles())
. += "[object.type] holds [stored.air_contents.total_moles()] moles"
- if(istype(object, /obj/machinery/vending))
+ if(istype(object, /obj/machinery/vending) && !is_type_in_typecache(object, GLOB.ship_save_stocked_fixtures))
var/obj/machinery/vending/vendor = object
for(var/datum/data/vending_product/product as anything in vendor.product_records + vendor.hidden_records + vendor.coin_records)
if(product.amount)
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 6e83c7dcc34..abef1eac420 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -241,9 +241,28 @@
TEST_ASSERT(findtext(door_text, "/obj/structure/mineral_door/iron{"), "A gold door was not saved as an iron one: [door_text]")
TEST_ASSERT(!findtext(door_text, "gold"), "A gold door kept its name or look: [door_text]")
- // An emergency closet saves as a plain closet but keeps its look: only false walls and doors go plain.
+ // An emergency closet saves as one that can't delete itself, keeping its look.
var/obj/structure/closet/emcloset/closet = allocate(/obj/structure/closet/emcloset)
- TEST_ASSERT(findtext(saved.atom_text(closet), "name = "), "An emergency closet lost its name")
+ var/closet_text = saved.atom_text(closet)
+ TEST_ASSERT(findtext(closet_text, "name = "), "An emergency closet lost its name")
+ TEST_ASSERT(findtext(closet_text, "/obj/structure/closet/ship_emergency{"), "An emergency closet can still delete itself as it loads: [closet_text]")
+ TEST_ASSERT(!findtext(closet_text, "contents_initialized"), "An emergency closet comes back empty")
+
+ // Lockers on the list fill themselves on first opening, and the rest never do.
+ var/obj/structure/closet/firecloset/fire_locker = allocate(/obj/structure/closet/firecloset)
+ TEST_ASSERT(!findtext(saved.atom_text(fire_locker), "contents_initialized"), "A fire closet comes back empty")
+ var/obj/structure/closet/secure_closet/security/security_locker = allocate(/obj/structure/closet/secure_closet/security)
+ TEST_ASSERT(findtext(saved.atom_text(security_locker), "contents_initialized"), "A security locker comes back full of armor")
+ var/obj/structure/closet/secure_closet/captains/captains_locker = allocate(/obj/structure/closet/secure_closet/captains)
+ TEST_ASSERT(findtext(saved.atom_text(captains_locker), "contents_initialized"), "A captain's locker comes back full")
+
+ // A stocked machine keeps its type. Any other goes back to what its board builds, which is empty.
+ var/obj/machinery/suit_storage_unit/engine/engine_unit = allocate(/obj/machinery/suit_storage_unit/engine)
+ TEST_ASSERT_EQUAL(engine_unit.checkpoint_type(), /obj/machinery/suit_storage_unit/engine, "An engineering suit unit comes back empty")
+ var/obj/machinery/suit_storage_unit/security/security_unit = allocate(/obj/machinery/suit_storage_unit/security)
+ TEST_ASSERT_EQUAL(security_unit.checkpoint_type(), /obj/machinery/suit_storage_unit, "A security suit unit comes back with its armor")
+ var/obj/machinery/suit_storage_unit/syndicate/syndicate_unit = allocate(/obj/machinery/suit_storage_unit/syndicate)
+ TEST_ASSERT_EQUAL(syndicate_unit.checkpoint_type(), /obj/machinery/suit_storage_unit, "A syndicate suit unit comes back with its armor")
// A mineral wall and floor keep their look but come apart into iron, even after a shuttle move.
var/turf/wall_tile = run_loc_floor_bottom_left
@@ -1569,6 +1588,69 @@
TEST_ASSERT_EQUAL(length(store.read_meta(test_ckey)), 0, "Rows were left behind")
qdel(store)
+/// What a loaded hull's fixtures hold after its stock is cleared, on and off the stocked list.
+/datum/unit_test/voidcrew_ship_saves_stock
+ var/obj/structure/overmap/ship/test_ship
+
+/datum/unit_test/voidcrew_ship_saves_stock/Destroy()
+ if(!QDELETED(test_ship))
+ qdel(test_ship)
+ return ..()
+
+/datum/unit_test/voidcrew_ship_saves_stock/Run()
+ test_ship = SSshuttle.create_ship(/datum/map_template/shuttle/voidcrew/box)
+ TEST_ASSERT_NOTNULL(test_ship, "Could not create the hull")
+ var/obj/docking_port/mobile/voidcrew/port = test_ship.shuttle
+ var/turf/open/tile
+ for(var/turf/open/floor/candidate in port.return_turfs())
+ if((get_area(candidate) in port.shuttle_areas) && !candidate.is_blocked_turf(exclude_mobs = TRUE))
+ tile = candidate
+ break
+ TEST_ASSERT_NOTNULL(tile, "The hull has no clear floor")
+
+ var/obj/machinery/suit_storage_unit/engine/kept_unit = allocate(/obj/machinery/suit_storage_unit/engine, tile)
+ TEST_ASSERT_NOTNULL(kept_unit.mod, "The fixture started without its modsuit")
+ var/obj/item/mod/control/kept_suit = kept_unit.mod
+ var/obj/machinery/suit_storage_unit/security/scrubbed_unit = allocate(/obj/machinery/suit_storage_unit/security, tile)
+ var/obj/item/mod/control/scrubbed_suit = scrubbed_unit.mod
+ var/obj/structure/extinguisher_cabinet/cabinet = allocate(/obj/structure/extinguisher_cabinet, tile)
+ var/obj/item/extinguisher/kept_extinguisher = cabinet.stored_extinguisher
+ var/fresh_foam = kept_extinguisher.reagents.total_volume
+ var/obj/structure/tank_dispenser/dispenser = allocate(/obj/structure/tank_dispenser, tile)
+ var/obj/machinery/vending/coffee/kept_vendor = allocate(/obj/machinery/vending/coffee, tile)
+ var/obj/machinery/vending/cola/scrubbed_vendor = allocate(/obj/machinery/vending/cola, tile)
+ // Loot stashed anywhere, including in a locker off the list, doesn't survive.
+ var/obj/structure/closet/plain_locker = allocate(/obj/structure/closet, tile)
+ var/obj/item/wrench/stashed = allocate(/obj/item/wrench, plain_locker)
+ var/obj/item/wrench/loose = allocate(/obj/item/wrench, tile)
+
+ checkpoint_clear_stock(port, ship_save_restocked())
+ checkpoint_restore_machine(kept_unit)
+ checkpoint_restore_machine(scrubbed_unit)
+ checkpoint_restore_machine(kept_vendor)
+ checkpoint_restore_machine(scrubbed_vendor)
+
+ TEST_ASSERT(!QDELETED(kept_suit) && kept_unit.mod == kept_suit, "An engineering suit unit lost its modsuit")
+ TEST_ASSERT(QDELETED(scrubbed_suit), "A security suit unit kept its modsuit")
+ TEST_ASSERT(!QDELETED(kept_extinguisher), "An extinguisher cabinet lost its extinguisher")
+ TEST_ASSERT_EQUAL(kept_extinguisher.reagents.total_volume, fresh_foam, "An extinguisher came back empty")
+ TEST_ASSERT(dispenser.oxygentanks > 0, "A tank dispenser lost its oxygen tanks")
+ TEST_ASSERT_EQUAL(dispenser.plasmatanks, 0, "A tank dispenser kept plasma tanks to sell")
+ var/kept_stock = 0
+ for(var/datum/data/vending_product/product as anything in kept_vendor.product_records)
+ kept_stock += product.amount
+ TEST_ASSERT(kept_stock > 0, "A coffee machine came back empty")
+ for(var/datum/data/vending_product/product as anything in scrubbed_vendor.product_records + scrubbed_vendor.hidden_records + scrubbed_vendor.coin_records)
+ TEST_ASSERT_EQUAL(product.amount, 0, "A vendor off the list came back stocked")
+ TEST_ASSERT(QDELETED(stashed), "Loot in a plain locker survived")
+ TEST_ASSERT(QDELETED(loose), "Loose loot survived")
+
+ // The saved emergency closet holds its fixed stock, and an opened fixture really has it.
+ var/obj/structure/closet/ship_emergency/emergency = allocate(/obj/structure/closet/ship_emergency, tile)
+ emergency.PopulateContents()
+ TEST_ASSERT(locate(/obj/item/tank/internals/emergency_oxygen) in emergency, "The emergency closet holds no oxygen")
+ TEST_ASSERT(locate(/obj/item/clothing/mask/breath) in emergency, "The emergency closet holds no masks")
+
#undef SHIP_SAVE_TEST_ROUND
#undef SHIP_SAVE_TEST_FORMAT
#undef SHIP_SAVE_TEST_GONE_PATH
diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md
index 5861b325064..e7a42caab7e 100644
--- a/wiki/content/player-outposts.md
+++ b/wiki/content/player-outposts.md
@@ -179,10 +179,11 @@ You can load a save in the same round you made it once the ship it came from is
| Comes back | Stays behind |
|---|---|
-| Hull and rooms | Cargo and supplies |
+| Hull and rooms | Everything the crew put aboard |
| Machines, with standard parts | Upgraded parts |
| Crew jobs | Credits |
| Charged batteries and engine fuel | Statues and other fittings of precious materials |
+| A fresh fill in emergency, fire, tool, engineering, medical and clothing lockers, EVA and work suit units, oxygen dispensers, extinguisher cabinets and everyday vendors | Security, armory and command lockers and suit units stay empty, and so does any plain locker |
Walls and floors of precious materials keep their look, but only give iron when taken apart.
From 68d6591e0649cb841f499efedfabc40d33d3e9da Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 19:03:08 -0800
Subject: [PATCH 12/72] Say so when a saved ship has no hull drawing
---
.../voidcrew_tgui/interfaces/SavedShipCards.tsx | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx b/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
index c49dcf13645..d26e5db3b2e 100644
--- a/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
+++ b/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
@@ -87,7 +87,11 @@ const GRID: CSSProperties = {
};
/** The hull from above, as the yard's drawing of it. */
-export const HullPlan = (props: { plan?: string | null; height: number }) => {
+export const HullPlan = (props: {
+ plan?: string | null;
+ height: number;
+ missingText?: string;
+}) => {
const { plan, height } = props;
const drawing = useMemo(() => (plan ? readPlan(plan) : null), [plan]);
return (
@@ -124,6 +128,9 @@ export const HullPlan = (props: { plan?: string | null; height: number }) => {
)}
)}
+ {!drawing && !!props.missingText && (
+ {props.missingText}
+ )}
);
};
@@ -239,7 +246,11 @@ export const SlotCard = (props: {
{save ? (
Slot {slot}
-
+
Date: Thu, 5 Nov 2026 19:08:24 -0800
Subject: [PATCH 13/72] Test a build with no source hull instead of the removed
copy option
---
.../unit_tests/voidcrew_outpost_admin_checkpoints.dm | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm
index 1c5180e476f..3a11f251887 100644
--- a/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_outpost_admin_checkpoints.dm
@@ -137,13 +137,14 @@
sleep(2)
TEST_ASSERT(!drones_left, "Drones did not go home after the build")
- // An original still in service is left alone: the rebuild is an extra copy.
+ // A snapshot with no source, as a load from an earlier round has, leaves other ships alone.
rebuilt.abandoned = FALSE
rebuilt.ship_account.account_balance = 777
var/datum/ship_checkpoint/copy_source = capture_copy(home, rebuilt, "copyowner")
TEST_ASSERT_NOTNULL(copy_source, "Could not capture the rebuilt ship")
+ copy_source.source_ship = null
send_to_transit(home, rebuilt)
- job = new(null, copy_source, operator, FALSE, TRUE)
+ job = new(null, copy_source, operator)
TEST_ASSERT(job.prepare(), "The copy rebuild did not start: [job.error]")
TEST_ASSERT_NULL(job.original_ref, "A copy rebuild took over the ship in service")
job.rush()
@@ -160,7 +161,8 @@
// Stop before the first piece: the bay is freed and nothing is left behind.
var/datum/ship_checkpoint/stop_source = capture_copy(home, rebuilt, "copyowner")
TEST_ASSERT_NOTNULL(stop_source, "Could not capture the ship to test Stop")
- job = new(null, stop_source, operator, FALSE, TRUE)
+ stop_source.source_ship = null
+ job = new(null, stop_source, operator)
TEST_ASSERT(job.prepare(), "The rebuild to stop did not start: [job.error]")
TEST_ASSERT(!job.committed, "The rebuild placed a piece before it could be stopped")
panel.manage_outpost(home, operator, "rebuild_stop", list("ref" = REF(job)))
From c434a0d7e0630fcf86e0d79bed51f43affa4c6b2 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Thu, 5 Nov 2026 20:08:44 -0800
Subject: [PATCH 14/72] Check stocked and empty fixtures on both load paths
---
voidcrew/modules/unit_tests/voidcrew_ship_saves.dm | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index abef1eac420..00b1da6a0ef 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -1283,12 +1283,16 @@
var/lathes = 0
var/plasma_tanks = 0
var/air_tanks = 0
+ var/stocked_vendors = 0
var/obj/docking_port/mobile/voidcrew/port = ship.shuttle
for(var/turf/tile as anything in port.return_turfs())
if(!(get_area(tile) in port.shuttle_areas))
continue
for(var/obj/item/loose in tile)
TEST_FAIL("[where]: the ship came with a loose [loose.type]")
+ // A locker on the list fills itself on first opening. Any other never does.
+ for(var/obj/structure/closet/locker in tile)
+ TEST_ASSERT_EQUAL(!!locker.contents_initialized, !is_type_in_typecache(locker, GLOB.ship_save_stocked_fixtures), "[where]: [locker.type] has the wrong stock")
for(var/obj/machinery/machine in tile)
if(istype(machine, /obj/machinery/autolathe))
lathes++
@@ -1306,8 +1310,14 @@
TEST_ASSERT(controller.cell?.charge > 0, "[where]: an APC came back with no charged cell")
if(istype(machine, /obj/machinery/vending))
var/obj/machinery/vending/vendor = machine
+ var/vendor_stock = 0
for(var/datum/data/vending_product/product as anything in vendor.product_records + vendor.hidden_records + vendor.coin_records)
- TEST_ASSERT_EQUAL(product.amount, 0, "[where]: a vending machine came back stocked")
+ vendor_stock += product.amount
+ if(is_type_in_typecache(vendor, GLOB.ship_save_stocked_fixtures))
+ stocked_vendors++
+ TEST_ASSERT(vendor_stock > 0, "[where]: [vendor.type] should come back stocked")
+ else
+ TEST_ASSERT_EQUAL(vendor_stock, 0, "[where]: [vendor.type] should come back empty")
if(istype(machine, /obj/machinery/door))
var/obj/machinery/door/door = machine
TEST_ASSERT(!length(door.req_access) && !length(door.req_one_access), "[where]: a door kept its access")
@@ -1317,6 +1327,7 @@
TEST_ASSERT(lathes, "[where]: the upgraded autolathe did not come back")
TEST_ASSERT(plasma_tanks, "[where]: the plasma tank did not come back")
TEST_ASSERT(air_tanks, "[where]: the air tank did not come back")
+ TEST_ASSERT(stocked_vendors, "[where]: the hull's stocked vendor did not come back")
/// A save loaded in the round it was made: only once the old hull is out of service, and it then replaces it.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/same_round
From 7a90596451ef56ee3945ff8fd6bf582d2aad5b7f Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Sat, 7 Nov 2026 11:47:20 -0800
Subject: [PATCH 15/72] Charge misc parts for a ship save by hull mass only
---
voidcrew/_DEFINES/ship_saves.dm | 2 --
.../modules/ship_saves/ship_save_survey.dm | 15 +++-----
.../modules/unit_tests/voidcrew_ship_saves.dm | 36 ++++++++-----------
wiki/content/player-outposts.md | 2 +-
wiki/content/ship-parts.md | 2 +-
5 files changed, 20 insertions(+), 37 deletions(-)
diff --git a/voidcrew/_DEFINES/ship_saves.dm b/voidcrew/_DEFINES/ship_saves.dm
index ad7a1aafc6b..f9f2d0f9e0f 100644
--- a/voidcrew/_DEFINES/ship_saves.dm
+++ b/voidcrew/_DEFINES/ship_saves.dm
@@ -6,8 +6,6 @@
#define SHIP_SAVE_PRICE_MIN 5
/// A hull costs one misc part per this much mass, rounded up.
#define SHIP_SAVE_MASS_PER_PART 150
-/// Machines the price table doesn't name cost one misc part per this many, rounded up.
-#define SHIP_SAVE_MACHINES_PER_PART 15
/// How long a hull's survey is good for while a window is open on it.
#define SHIP_SAVE_SURVEY_LIFE (10 SECONDS)
/// A hull plan longer than this isn't sent to the screen or kept.
diff --git a/voidcrew/modules/ship_saves/ship_save_survey.dm b/voidcrew/modules/ship_saves/ship_save_survey.dm
index b27bff30b14..7ebcda8660a 100644
--- a/voidcrew/modules/ship_saves/ship_save_survey.dm
+++ b/voidcrew/modules/ship_saves/ship_save_survey.dm
@@ -3,8 +3,8 @@
#define SHIP_SAVE_PRICE(part_class, each_machine, label_text) list("class" = part_class, "each" = each_machine, "label" = label_text)
/**
- * What the machines a save keeps cost. Subtypes inherit, and a later, more specific entry wins: a null
- * takes a subtype back off the table, so it counts among the other machines.
+ * What the machines a save keeps cost. Machines not on this table are free. Subtypes inherit, and a later,
+ * more specific entry wins: a null takes a subtype back off the table.
*/
GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
/obj/machinery/ship_combat/laser_turret = SHIP_SAVE_PRICE(PART_CLASS_COMBAT, 1, null),
@@ -76,7 +76,6 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
var/static/list/plan_rank = list("w" = 1, "s" = 2, "t" = 3, "e" = 4, "#" = 5, "g" = 6, "o" = 7)
var/static/list/class_mark = list(PART_CLASS_COMBAT = "w", PART_CLASS_SCIENCE = "s", PART_CLASS_TRADE = "t")
var/mass = 0
- var/other_machines = 0
/// Line name -> list("class", "each", "count")
var/list/lines = list()
var/list/plan_rows = list()
@@ -110,8 +109,6 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
object_mark = class_mark[price["class"]]
else if(istype(machine, /obj/machinery/power/shuttle_engine))
object_mark = "e"
- else if(!is_type_in_typecache(machine, GLOB.outpost_checkpoint_infrastructure))
- other_machines++
if(object_mark && plan_rank[object_mark] < plan_rank[mark])
mark = object_mark
plan_row += mark
@@ -119,14 +116,14 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
var/crew = 0
for(var/list/definition in job_definitions)
crew += definition["slots"] || 1
- var/list/survey = ship_save_price_bill(lines, mass, other_machines)
+ var/list/survey = ship_save_price_bill(lines, mass)
survey["mass"] = mass
survey["crew"] = crew
survey["plan"] = ship_save_encode_plan(plan_rows)
return survey
/// Rounds each line up and adds them into bill, total and cost.
-/proc/ship_save_price_bill(list/lines, mass, other_machines)
+/proc/ship_save_price_bill(list/lines, mass)
SHOULD_NOT_SLEEP(TRUE)
var/list/items_by_class = list()
var/total = 0
@@ -142,10 +139,6 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
if(hull_parts)
misc_items += list(list("name" = "Hull, [mass] t", "count" = null, "parts" = hull_parts))
total += hull_parts
- var/other_parts = CEILING(other_machines / SHIP_SAVE_MACHINES_PER_PART, 1)
- if(other_parts)
- misc_items += list(list("name" = "Other machines", "count" = other_machines, "parts" = other_parts))
- total += other_parts
if(total < SHIP_SAVE_PRICE_MIN)
misc_items += list(list("name" = "Yard minimum", "count" = null, "parts" = SHIP_SAVE_PRICE_MIN - total))
total = SHIP_SAVE_PRICE_MIN
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 00b1da6a0ef..08f9fd123b5 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -2,7 +2,6 @@
#define SHIP_SAVE_TEST_SLOTS 3 // SHIP_SAVE_SLOTS
#define SHIP_SAVE_TEST_PRICE_MIN 5 // SHIP_SAVE_PRICE_MIN
#define SHIP_SAVE_TEST_MASS_PER_PART 150 // SHIP_SAVE_MASS_PER_PART
-#define SHIP_SAVE_TEST_MACHINES_PER_PART 15 // SHIP_SAVE_MACHINES_PER_PART
#define SHIP_SAVE_TEST_PLAN_MAX 8000 // SHIP_SAVE_PLAN_MAX_LENGTH
#define SHIP_SAVE_TEST_PASSWORD_PRICE 3 // SHIP_PASSWORD_PRICE
#define SHIP_SAVE_TEST_FORMAT 1 // SHIP_SAVE_FORMAT
@@ -452,7 +451,7 @@
var/list/classes = list()
for(var/list/entry as anything in survey["bill"])
classes += entry["class"]
- TEST_ASSERT_EQUAL(classes.Join(","), "combat,science,trade,misc", "The bill's classes are in the wrong order")
+ TEST_ASSERT_EQUAL(classes.Join(","), "combat,science,trade", "The bill's classes are in the wrong order")
TEST_ASSERT_EQUAL(bill_class(survey, "combat")["parts"], 3, "Wrong combat total")
var/list/combat_items = bill_class(survey, "combat")["items"]
var/list/first_combat = combat_items[1]
@@ -465,16 +464,13 @@
TEST_ASSERT_EQUAL(bill_line(survey, "science", "Chem dispenser")?["parts"], 1, "A chem dispenser did not cost 1 science")
TEST_ASSERT_EQUAL(bill_class(survey, "science")["parts"], 1, "A drink dispenser counted as a chem dispenser")
TEST_ASSERT_EQUAL(bill_line(survey, "trade", "Cargo console")?["parts"], 1, "A cargo console did not cost 1 trade")
- var/list/others = bill_line(survey, "misc", "Other machines")
- TEST_ASSERT_EQUAL(others?["count"], 1, "Only the drink dispenser should be an other machine")
- TEST_ASSERT_EQUAL(others?["parts"], 1, "One other machine did not round up to a part")
- TEST_ASSERT_NULL(bill_line(survey, "misc", "Yard minimum"), "A hull over the minimum paid it")
- TEST_ASSERT_EQUAL(survey["total"], 6, "Wrong total")
+ TEST_ASSERT_NULL(bill_class(survey, "misc"), "Machines off the price table were charged in misc")
+ TEST_ASSERT_EQUAL(survey["total"], 5, "Wrong total")
TEST_ASSERT_EQUAL(survey["cost"]["combat"], 3, "The charge disagrees with the bill")
TEST_ASSERT_EQUAL(survey["cost"]["science"], 1, "The charge disagrees with the bill")
TEST_ASSERT_EQUAL(survey["cost"]["trade"], 1, "The charge disagrees with the bill")
- TEST_ASSERT_EQUAL(survey["cost"]["misc"], 1, "The charge disagrees with the bill")
- TEST_ASSERT_EQUAL(ship_save_cost_text(survey["cost"]), "3 combat, 1 science, 1 trade and 1 misc parts", "Wrong words for the price")
+ TEST_ASSERT_NULL(survey["cost"]["misc"], "The charge disagrees with the bill")
+ TEST_ASSERT_EQUAL(ship_save_cost_text(survey["cost"]), "3 combat, 1 science and 1 trade parts", "Wrong words for the price")
// A label wins, a subtype inherits, and a name with no label comes from the type.
var/list/price = GLOB.ship_save_priced_machines[/obj/machinery/rnd/production/protolathe/department/science]
@@ -487,30 +483,27 @@
if(!iseffect(thing))
qdel(thing)
-/// The misc lines and the minimum, with nothing but numbers.
+/// The hull line and the minimum, with nothing but numbers.
/datum/unit_test/voidcrew_ship_saves/proc/check_bill_rounding()
- var/list/kitted = ship_save_price_bill(list(), 650, 25)
- var/list/hull_line = bill_line(kitted, "misc", "Hull, 650 t")
+ var/list/hull_only = ship_save_price_bill(list(), 650)
+ var/list/hull_line = bill_line(hull_only, "misc", "Hull, 650 t")
TEST_ASSERT_EQUAL(hull_line?["parts"], 5, "650 t did not round up to 5 parts")
TEST_ASSERT_NULL(hull_line?["count"], "The hull line has a count")
- var/list/other_line = bill_line(kitted, "misc", "Other machines")
- TEST_ASSERT_EQUAL(other_line?["parts"], 2, "25 machines did not round up to 2 parts")
- TEST_ASSERT_EQUAL(other_line?["count"], 25, "The other machines line lost its count")
- TEST_ASSERT_EQUAL(kitted["total"], 7, "Wrong total for a kitted hull")
- var/list/exact = ship_save_price_bill(list(), SHIP_SAVE_TEST_MASS_PER_PART * 5, SHIP_SAVE_TEST_MACHINES_PER_PART)
+ TEST_ASSERT_EQUAL(hull_only["total"], 5, "Wrong total for a bare hull")
+ var/list/exact = ship_save_price_bill(list(), SHIP_SAVE_TEST_MASS_PER_PART * 6)
TEST_ASSERT_EQUAL(exact["total"], 6, "An exact multiple was rounded up again")
// A tiny hull pays the minimum exactly, and the bill says why.
- var/list/tiny = ship_save_price_bill(list(), 150, 1)
+ var/list/tiny = ship_save_price_bill(list(), 150)
TEST_ASSERT_EQUAL(tiny["total"], SHIP_SAVE_TEST_PRICE_MIN, "A tiny hull did not pay exactly the minimum")
TEST_ASSERT_EQUAL(tiny["cost"]["misc"], SHIP_SAVE_TEST_PRICE_MIN, "The minimum isn't paid in misc parts")
- TEST_ASSERT_EQUAL(bill_line(tiny, "misc", "Yard minimum")?["parts"], SHIP_SAVE_TEST_PRICE_MIN - 2, "The minimum line doesn't make up the difference")
+ TEST_ASSERT_EQUAL(bill_line(tiny, "misc", "Yard minimum")?["parts"], SHIP_SAVE_TEST_PRICE_MIN - 1, "The minimum line doesn't make up the difference")
TEST_ASSERT_NULL(bill_line(tiny, "misc", "Yard minimum")?["count"], "The minimum line has a count")
- var/list/nothing = ship_save_price_bill(list(), 0, 0)
+ var/list/nothing = ship_save_price_bill(list(), 0)
TEST_ASSERT_EQUAL(length(bill_class(nothing, "misc")["items"]), 1, "An empty hull billed lines for nothing")
TEST_ASSERT_EQUAL(nothing["total"], SHIP_SAVE_TEST_PRICE_MIN, "An empty hull did not pay the minimum")
var/list/lines = list("Defense turret" = list("class" = "combat", "each" = 0.5, "count" = 6))
- TEST_ASSERT_EQUAL(bill_line(ship_save_price_bill(lines, 0, 0), "combat", "Defense turret")?["parts"], 3, "Six turrets at 1 per 2 are not one line of 3")
+ TEST_ASSERT_EQUAL(bill_line(ship_save_price_bill(lines, 0), "combat", "Defense turret")?["parts"], 3, "Six turrets at 1 per 2 are not one line of 3")
set_parts(misc = SHIP_SAVE_TEST_PRICE_MIN)
GLOB.ship_saves.refresh("tinyhull")
@@ -1668,7 +1661,6 @@
#undef SHIP_SAVE_TEST_SLOTS
#undef SHIP_SAVE_TEST_PRICE_MIN
#undef SHIP_SAVE_TEST_MASS_PER_PART
-#undef SHIP_SAVE_TEST_MACHINES_PER_PART
#undef SHIP_SAVE_TEST_PLAN_MAX
#undef SHIP_SAVE_TEST_PASSWORD_PRICE
#undef SHIP_SAVE_TEST_TOO_LONG
diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md
index e7a42caab7e..895b3153786 100644
--- a/wiki/content/player-outposts.md
+++ b/wiki/content/player-outposts.md
@@ -171,7 +171,7 @@ The bay's own **shipyard console**, found only in the bay itself, has two tabs:
You have three save slots. Dock a ship you command in a ship bay, choose a slot and pay [ship parts](ship-parts.md) to save it there. The hull needs at least one cryopod. Saving to a slot that already holds a ship overwrites that save, and you pay again.
-The price depends on what is aboard, and the console lists it before you pay. Weapons and defenses cost combat parts, research and chemistry machines cost science parts, cargo and mining machines cost trade parts, and the hull and everything else cost misc parts. Every save costs at least 5 parts in total. You need enough of each class. Whatever you take off the ship before saving is not charged for, and is not in the save.
+The price depends on what is aboard, and the console lists it before you pay. Weapons and defenses cost combat parts, research and chemistry machines cost science parts, cargo and mining machines cost trade parts, and the hull costs misc parts. Every save costs at least 5 parts in total. You need enough of each class. Whatever you take off the ship before saving is not charged for, and is not in the save.
Get a saved ship back for free, either from **Saved Ships** in the lobby's join menu, which launches it with you aboard, or from the **Saved ships** tab of a shipyard console in any ship bay, where the delivery drones build it. Each save works once: using it uses it up and frees the slot.
diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md
index f0c6b2dd7ba..3e7e2de1300 100644
--- a/wiki/content/ship-parts.md
+++ b/wiki/content/ship-parts.md
@@ -81,6 +81,6 @@ Open the **Shipyard** from the join menu to see your balance and the prices of h
Unlocks are permanent. Buying a theme or module lets you select it for future launches of that hull without paying again, and losing a ship does not erase your purchases. See [Buying & Upgrading Ships](ship-upgrades.md) for choosing a loadout and [Ship Catalog](ship-catalog.md) for the available hulls.
-[Saving a ship](player-outposts.md) in a ship bay also costs parts, in the classes that match what is aboard: combat parts for weapons, science parts for research and chemistry machines, trade parts for cargo and mining machines, and misc parts for the hull and everything else. Every save costs at least 5 parts in total.
+[Saving a ship](player-outposts.md) in a ship bay also costs parts, in the classes that match what is aboard: combat parts for weapons, science parts for research and chemistry machines, trade parts for cargo and mining machines, and misc parts for the hull. Every save costs at least 5 parts in total.
A ship's join password can be paid for with parts of any class. Setting or changing it costs **3 parts**; clearing it is free.
From f4827ce3f4c3bc36491ef04e9d7b5e7ba36aa0ff Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Sat, 7 Nov 2026 11:53:02 -0800
Subject: [PATCH 16/72] Stop reading the removed checkpoint prices in the gear
catalog
---
tools/gear_catalog/offers.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/tools/gear_catalog/offers.py b/tools/gear_catalog/offers.py
index ba71d9a219e..7d252b7a3d3 100644
--- a/tools/gear_catalog/offers.py
+++ b/tools/gear_catalog/offers.py
@@ -214,8 +214,6 @@ def add(shop,name,source,price=0,vouchers=0,**kw):
add('Player outpost upgrades',dm.val(upgrade,'name'),dm.loc(upgrade),dm.val(upgrade,'price'),sku_type=upgrade,stock='installation gated',notes='Treasury buys upgrade blueprint; not a ship purchase',field_sources=f_sources(dm,upgrade,['price','id']))
# Other paid registry services; ship purchase prices deliberately excluded.
for file,needle,name,const in [
- ('voidcrew/modules/player_outposts/outpost_checkpoints.dm','OUTPOST_CHECKPOINT_SAVE_COST','save ship checkpoint','OUTPOST_CHECKPOINT_SAVE_COST'),
- ('voidcrew/modules/player_outposts/outpost_checkpoints.dm','OUTPOST_CHECKPOINT_UPDATE_COST','update ship checkpoint','OUTPOST_CHECKPOINT_UPDATE_COST'),
('voidcrew/modules/player_outposts/outpost_management_ui.dm','OUTPOST_ADVERT_COST','galaxy advertisement','OUTPOST_ADVERT_COST'),
('voidcrew/modules/player_outposts/outpost_ship_bay.dm','OUTPOST_SHIP_BAY_COST','install ship bay','OUTPOST_SHIP_BAY_COST')]:
add('Player outpost registry',name,dm.cite(file,needle),dm.eval(const),notes='Paid from treasury; runtime eligibility applies')
From abfe59821c48dadefbec51799dd7f920a40003a0 Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:41:44 -0800
Subject: [PATCH 17/72] Add an admin verb that gives a ship's R&D server
research
---
voidcrew/modules/admin/feature_testing.dm | 51 +++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/voidcrew/modules/admin/feature_testing.dm b/voidcrew/modules/admin/feature_testing.dm
index 86b9469d882..11fe27eefb8 100644
--- a/voidcrew/modules/admin/feature_testing.dm
+++ b/voidcrew/modules/admin/feature_testing.dm
@@ -840,3 +840,54 @@ ADMIN_VERB(spawn_contested_cache_now, R_ADMIN|R_DEBUG, "Overmap: Spawn Contested
message_admins("[key_name_admin(user)] surfaced a contested cache at overmap ([coords[1]], [coords[2]]).")
log_admin("[key_name(user)] surfaced a contested cache.")
BLACKBOX_LOG_ADMIN_VERB("Spawn Contested Cache")
+
+// ===== RESEARCH =====
+
+ADMIN_VERB(give_ship_research, R_ADMIN|R_DEBUG, "Research: Give Ship Research", "Research nodes on a ship's R&D server for free, without points, rewards or gates.", ADMIN_CATEGORY_DEBUG)
+ var/obj/structure/overmap/ship/ship = voidcrew_admin_pick_ship(user, "Give Ship Research")
+ if(!ship)
+ return
+ var/obj/machinery/rnd/server/ship/server
+ for(var/obj/machinery/rnd/server/ship/candidate as anything in GLOB.ship_research_servers)
+ if(!QDELETED(candidate) && candidate.stored_research && (get_area(candidate) in ship.shuttle?.shuttle_areas))
+ server = candidate
+ break
+ if(!server)
+ to_chat(user, span_warning("[ship.display_name || ship.name] has no ship R&D server with a disk installed."))
+ return
+ var/datum/techweb/web = server.stored_research
+ var/mode = tgui_alert(user, "[web.researched_nodes.len] nodes researched. Give how many?", "Give Ship Research", list("Some", "All", "Cancel"))
+ if(!mode || mode == "Cancel")
+ return
+ var/amount = INFINITY
+ if(mode == "Some")
+ amount = tgui_input_number(user, "How many nodes, following the tree from what is already researched?", "Give Ship Research", 20, length(SSresearch.techweb_nodes), 1)
+ if(!amount)
+ return
+ if(QDELETED(server) || server.stored_research != web)
+ to_chat(user, span_warning("The server or its disk changed."))
+ return
+ var/given = 0
+ if(mode == "All")
+ for(var/node_id in SSresearch.techweb_nodes)
+ if(web.researched_nodes[node_id])
+ continue
+ web.hidden_nodes -= node_id
+ web.research_node(SSresearch.techweb_nodes[node_id], TRUE, FALSE, FALSE)
+ given++
+ else
+ // Researching a node makes its children available, so the tree fills in order.
+ var/list/refused = list()
+ while(given < amount)
+ var/list/open = web.available_nodes - refused
+ if(!length(open))
+ break
+ var/node_id = pick(open)
+ if(web.research_node(SSresearch.techweb_nodes[node_id], TRUE, FALSE, FALSE))
+ given++
+ else
+ refused[node_id] = TRUE
+ to_chat(user, span_notice("Gave [given] nodes to [ship.display_name || ship.name]. It now has [server.saved_research_count()] beyond the starting tree."))
+ message_admins("[key_name_admin(user)] gave [given] research nodes to [ship.display_name || ship.name].")
+ log_admin("[key_name(user)] gave [given] research nodes to [ship.display_name || ship.name].")
+ BLACKBOX_LOG_ADMIN_VERB("Give Ship Research")
From 035f2656e3a329b43a1642411cd184ccf4deddac Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:41:44 -0800
Subject: [PATCH 18/72] Pay for the hull in credits and carry upgrades,
research and funds in ship saves
Credits stand in for misc parts only. Weapons, lab and cargo machines still
need their parts, and in return a save keeps their stock part upgrades, the
ship R&D server's researched nodes and completed experiments, and ship funds
bought with trade parts. Also keeps restocked fixtures stocked after master's
clear_stock and suit unit changes.
---
tgstation.dme | 1 +
.../interfaces/SavedShipCards.tsx | 22 +-
.../interfaces/ShipCheckpoint.tsx | 161 ++++++++++-
voidcrew/_DEFINES/ship_saves.dm | 10 +-
.../outpost_checkpoint_machinery.dm | 4 +-
.../outpost_checkpoint_rebuild.dm | 8 +-
.../outpost_checkpoint_snapshot.dm | 15 +-
.../player_outposts/outpost_checkpoints.dm | 11 +-
voidcrew/modules/research/server.dm | 12 +-
.../modules/ship_saves/ship_save_capture.dm | 9 +
.../modules/ship_saves/ship_save_console.dm | 58 +++-
.../ship_saves/ship_save_construction.dm | 10 +
.../modules/ship_saves/ship_save_lobby.dm | 5 +
.../modules/ship_saves/ship_save_progress.dm | 260 +++++++++++++++++
.../modules/ship_saves/ship_save_survey.dm | 113 +++++++-
voidcrew/modules/ship_saves/ship_saves.dm | 31 +-
.../unit_tests/voidcrew_checkpoints.dm | 4 +-
.../modules/unit_tests/voidcrew_ship_saves.dm | 266 +++++++++++++++++-
wiki/content/player-outposts.md | 13 +-
wiki/content/ship-parts.md | 2 +-
20 files changed, 948 insertions(+), 67 deletions(-)
create mode 100644 voidcrew/modules/ship_saves/ship_save_progress.dm
diff --git a/tgstation.dme b/tgstation.dme
index ac431c1dc3e..6a695475e47 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -7613,6 +7613,7 @@
#include "voidcrew\modules\ship_saves\ship_save_console.dm"
#include "voidcrew\modules\ship_saves\ship_save_construction.dm"
#include "voidcrew\modules\ship_saves\ship_save_lobby.dm"
+#include "voidcrew\modules\ship_saves\ship_save_progress.dm"
#include "voidcrew\modules\ship_saves\ship_save_store.dm"
#include "voidcrew\modules\ship_saves\ship_save_survey.dm"
#include "voidcrew\modules\ship_saves\ship_saves.dm"
diff --git a/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx b/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
index d26e5db3b2e..f11b4f1fea3 100644
--- a/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
+++ b/tgui/packages/voidcrew_tgui/interfaces/SavedShipCards.tsx
@@ -9,6 +9,7 @@ export type BillLine = {
name: string;
count: number | null;
parts: number;
+ carry?: BooleanLike;
};
export type BillClass = {
@@ -24,6 +25,8 @@ export type ShipSurvey = {
plan?: string | null;
bill?: BillClass[];
total?: number;
+ research?: number;
+ funds?: number;
};
export type SavedShip = ShipSurvey & {
@@ -180,10 +183,9 @@ const FitLine = (props: {
);
const machineCount = (bill: BillClass[], id: PartClass) =>
- (bill.find((line) => line.class === id)?.items || []).reduce(
- (total, item) => total + (item.count || 1),
- 0,
- );
+ (bill.find((line) => line.class === id)?.items || [])
+ .filter((item) => !item.carry)
+ .reduce((total, item) => total + (item.count || 1), 0);
/** Weapons by name, then how much lab and cargo gear it carries. */
const Fit = (props: { bill: BillClass[] }) => {
@@ -275,6 +277,18 @@ export const SlotCard = (props: {
line.class === id);
const need = entry?.parts || 0;
const held = props.parts ? props.parts[id] : null;
- const short = held !== null && need > held;
+ // Misc parts can be made up in credits, which the save button checks.
+ const short = held !== null && need > held && id !== 'misc';
return (
);
};
-/** Why this captain can't afford the save, or null. */
+/** The bill with the funds on it, as ship_save_add_funds() prices it. */
+const billWithFunds = (
+ bill: BillClass[],
+ fundsParts: number,
+ fundsPerPart: number,
+): BillClass[] => {
+ if (fundsParts <= 0) {
+ return bill;
+ }
+ const line = {
+ name: `Funds, ${credits(fundsParts * fundsPerPart)}`,
+ count: null,
+ parts: fundsParts,
+ carry: 1,
+ };
+ const trade = bill.find((entry) => entry.class === 'trade');
+ if (trade) {
+ return bill.map((entry) =>
+ entry === trade
+ ? {
+ ...entry,
+ parts: entry.parts + fundsParts,
+ items: [...entry.items, line],
+ }
+ : entry,
+ );
+ }
+ return [...bill, { class: 'trade', parts: fundsParts, items: [line] }];
+};
+
+/** Parts of a class the bill wants beyond what's held. */
+const missingParts = (
+ bill: BillClass[],
+ parts: Record,
+ id: PartClass,
+) => {
+ const need = bill.find((entry) => entry.class === id)?.parts || 0;
+ return Math.max(need - parts[id], 0);
+};
+
+/** Why this captain can't afford the save, or null. Only misc parts can be had for credits. */
const shortOfParts = (
bill: BillClass[],
parts: Record | null,
+ balance: number,
+ partCredits: number,
) => {
if (!parts) {
return null;
}
- const missing = bill
- .filter((entry) => entry.parts > parts[entry.class])
- .map((entry) => `${entry.parts - parts[entry.class]} ${entry.class}`);
- return missing.length ? `Short ${missing.join(', ')}.` : null;
+ const missing = PART_CLASSES.filter(({ id }) => id !== 'misc')
+ .map(({ id }) => ({ id, short: missingParts(bill, parts, id) }))
+ .filter(({ short }) => short > 0)
+ .map(({ id, short }) => `${short} ${id}`);
+ if (missing.length) {
+ return `Short ${missing.join(', ')}.`;
+ }
+ const short = missingParts(bill, parts, 'misc') * partCredits - balance;
+ return short > 0 ? `Short ${credits(short)}.` : null;
};
const SavedShipsTab = () => {
const { data, act } = useBackend();
- const { online, parts, slots, saves, ships } = data.saved_ships;
+ const { online, parts, part_credits, funds_per_part, slots, saves, ships } =
+ data.saved_ships;
const [shipRef, setShipRef] = useState(null);
+ const [wantedFunds, setFundsParts] = useState(0);
if (!online) {
return (
@@ -317,9 +414,31 @@ const SavedShipsTab = () => {
);
}
const ship = ships.find((entry) => entry.ref === shipRef) || ships[0];
+ const baseBill = ship?.bill || [];
+ // What the ship's account and trade parts can spare once the rest of the bill is paid.
+ const tradeSpare = parts
+ ? parts.trade -
+ (baseBill.find((entry) => entry.class === 'trade')?.parts || 0)
+ : 0;
+ const miscCredits = parts
+ ? missingParts(baseBill, parts, 'misc') * part_credits
+ : 0;
+ const maxFundsParts = ship
+ ? Math.max(
+ Math.min(
+ tradeSpare,
+ Math.floor((ship.balance - miscCredits) / funds_per_part),
+ ),
+ 0,
+ )
+ : 0;
+ const fundsParts = Math.min(wantedFunds, maxFundsParts);
+ const fundsCredits = fundsParts * funds_per_part;
+ const bill = billWithFunds(baseBill, fundsParts, funds_per_part);
const saveDenial = !ship
? 'Dock a ship you command.'
- : ship.denial || shortOfParts(ship.bill || [], parts);
+ : ship.denial ||
+ shortOfParts(bill, parts, ship.balance - fundsCredits, part_credits);
return (
<>
{
}
>
{ship ? (
-
+
) : (
Dock a ship you command.
)}
@@ -400,7 +529,11 @@ const SavedShipsTab = () => {
(save?.locked ? 'Already under way.' : undefined)
}
onClick={() =>
- act('ship_save_store', { ref: ship?.ref, slot })
+ act('ship_save_store', {
+ ref: ship?.ref,
+ slot,
+ funds_parts: fundsParts,
+ })
}
>
{save ? 'Overwrite' : 'Save'}
diff --git a/voidcrew/_DEFINES/ship_saves.dm b/voidcrew/_DEFINES/ship_saves.dm
index f9f2d0f9e0f..02a908bc24a 100644
--- a/voidcrew/_DEFINES/ship_saves.dm
+++ b/voidcrew/_DEFINES/ship_saves.dm
@@ -1,11 +1,19 @@
/// Bump this when the payload layout or the list of saved vars changes. Older saves are then refused.
-#define SHIP_SAVE_FORMAT 1
+#define SHIP_SAVE_FORMAT 2
/// How many ships one player can have saved at once.
#define SHIP_SAVE_SLOTS 3
/// The least a save can cost, in ship parts of every class together.
#define SHIP_SAVE_PRICE_MIN 5
/// A hull costs one misc part per this much mass, rounded up.
#define SHIP_SAVE_MASS_PER_PART 150
+/// What a captain pays in credits for each misc part of a save's price they don't pay in parts.
+#define SHIP_SAVE_CREDITS_PER_PART 2500
+/// A save's research costs one science part per this many nodes above what a fresh web starts with, rounded up.
+#define SHIP_SAVE_NODES_PER_PART 20
+/// How many credits one trade part carries in a save.
+#define SHIP_SAVE_FUNDS_PER_TRADE_PART 5000
+/// The most trade parts a captain can ask to spend on funds at once. Keeps the request a sane number.
+#define SHIP_SAVE_MAX_FUNDS_PARTS 1000
/// How long a hull's survey is good for while a window is open on it.
#define SHIP_SAVE_SURVEY_LIFE (10 SECONDS)
/// A hull plan longer than this isn't sent to the screen or kept.
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
index 59d6218acd7..ed39edcee8d 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_machinery.dm
@@ -53,8 +53,8 @@
refill.premium = list()
/**
- * Puts a loaded machine's parts and supplies right. A capture keeps nothing per machine, so it stays
- * at the default tier it initialized with.
+ * Puts a loaded machine's parts and supplies right. It stays at the default tier it initialized with,
+ * until restore_progress() gives a machine on the price table its saved parts.
*/
/proc/checkpoint_restore_machine(obj/machinery/machine)
// Scrubbing its refill and rereading its parts would empty a vendor that comes back stocked.
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
index cbbbfb33c5c..8fa940872fc 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_rebuild.dm
@@ -37,13 +37,13 @@
for(var/datum/gas_mixture/mix as anything in stored)
mix?.gases.Cut()
// Stock kept outside contents or in counters rather than as items.
- if(istype(object, /obj/structure/closet))
+ if(!restocked && istype(object, /obj/structure/closet))
var/obj/structure/closet/closet = object
closet.contents_initialized = TRUE
if(istype(object, /obj/structure/closet/crate/secure/loot))
var/obj/structure/closet/crate/secure/loot/freight = object
freight.consume_freight_loot()
- if(istype(object, /obj/machinery/suit_storage_unit))
+ if(!restocked && istype(object, /obj/machinery/suit_storage_unit))
var/obj/machinery/suit_storage_unit/unit = object
unit.suit = null
unit.helmet = null
@@ -85,8 +85,10 @@
var/obj/machinery/atmospherics/components/unary/shuttle/heater/heater = object
heater.fuel_tank = null
-/// Mapped suit rewards are fittings only when rebuilt, even if unbolted at save time.
+/// Mapped suit rewards are fittings only when rebuilt, even if unbolted at save time. A stocked fixture keeps its type.
/obj/machinery/suit_storage_unit/checkpoint_type()
if(!..())
return null
+ if(is_type_in_typecache(src, GLOB.ship_save_stocked_fixtures))
+ return type
return /obj/machinery/suit_storage_unit
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
index 03016a21359..451f105807b 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm
@@ -83,8 +83,8 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
return isstructure(object)
/**
- * A captured hull: its map text and rooms. It stores no per-machine data, so machines come back at
- * default tier, and it only ever comes apart into iron (ship_save_capture.dm).
+ * A captured hull: its map text and rooms. Machines come back at default tier unless they are on the
+ * price table (ship_save_progress.dm), and it only ever comes apart into iron (ship_save_capture.dm).
*/
/datum/ship_checkpoint
/// The outpost a construction job builds this in.
@@ -106,6 +106,14 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
var/class_name
/// The crew roster as template job definitions. Null leaves the commissioned default.
var/list/jobs
+ /// machine_key() -> one list of stock part paths per priced machine standing there.
+ var/list/machine_parts
+ /// One R&D server's research, from ship_save_read_research(). Null with none aboard.
+ var/list/research
+ /// What capture_progress() saw, to tell whether it changed while the captain decided.
+ var/progress_print
+ /// Credits this save holds for the ship it loads as.
+ var/carried_funds = 0
/datum/ship_checkpoint/Destroy()
outpost = null
@@ -114,6 +122,8 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
tgm = null
rooms = null
jobs = null
+ machine_parts = null
+ research = null
return ..()
/// Ports are handled separately and cannot carry NPC types.
@@ -262,6 +272,7 @@ GLOBAL_LIST_INIT(outpost_checkpoint_refused_terrain, typecacheof(list(
ship_name = ship.name
class_name = ship.source_template?.short_name
jobs = ship.job_definitions?.Copy()
+ capture_progress(port)
saved_at = world.time
return null
diff --git a/voidcrew/modules/player_outposts/outpost_checkpoints.dm b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
index 5678d895588..29b2a6ed59d 100644
--- a/voidcrew/modules/player_outposts/outpost_checkpoints.dm
+++ b/voidcrew/modules/player_outposts/outpost_checkpoints.dm
@@ -122,8 +122,13 @@
/datum/ship_checkpoint_ui/ui_static_data(mob/user)
return shop_static_data()
-/datum/ship_checkpoint_ui/proc/confirm_save(mob/user, prompt_text, title = "Shipyard")
- return tgui_alert(user, prompt_text, title, list("Confirm", "Cancel")) == "Confirm"
+/// Which of the options (label -> payment) the captain picked, or null if they backed out.
+/datum/ship_checkpoint_ui/proc/choose_save_payment(mob/user, prompt_text, list/options)
+ var/list/buttons = list()
+ for(var/label in options)
+ buttons += label
+ var/choice = tgui_alert(user, prompt_text, "Shipyard", buttons + "Cancel")
+ return options[choice] ? choice : null
/datum/ship_checkpoint_ui/ui_data(mob/user)
var/list/rebuilds = list()
@@ -152,7 +157,7 @@
switch(action)
if("ship_save_store")
var/datum/outpost_berth/ship_bay/bay = locate(params["ref"]) in outpost.bay_berths
- save_ship(user, bay, text2num(params["slot"]))
+ save_ship(user, bay, text2num(params["slot"]), params["funds_parts"] ? text2num(params["funds_parts"]) : 0)
if("ship_save_retrieve")
retrieve_ship(user, text2num(params["slot"]))
else
diff --git a/voidcrew/modules/research/server.dm b/voidcrew/modules/research/server.dm
index 8baa14d041f..d944ad2e1df 100644
--- a/voidcrew/modules/research/server.dm
+++ b/voidcrew/modules/research/server.dm
@@ -109,10 +109,7 @@ GLOBAL_LIST_EMPTY(ship_research_servers)
if(!attacking_item.forceMove(src))
balloon_alert(user, "won't fit!")
return
- source_code_hdd = attacking_item
- RegisterSignal(source_code_hdd, COMSIG_QDELETING, PROC_REF(on_source_disk_deleted))
- stored_research = source_code_hdd.stored_research
- stored_research.techweb_servers |= src
+ adopt_source_disk(attacking_item)
record_source_disk_installed(user) // round metrics, voidcrew/modules/metrics/research_metrics.dm
balloon_alert(user, "disk uploaded!")
claim_unlinked_experiment_handlers()
@@ -120,6 +117,13 @@ GLOBAL_LIST_EMPTY(ship_research_servers)
return
return ..()
+/// Runs this server off a disk that is already inside it.
+/obj/machinery/rnd/server/ship/proc/adopt_source_disk(obj/item/computer_disk/ship_disk/disk)
+ source_code_hdd = disk
+ RegisterSignal(source_code_hdd, COMSIG_QDELETING, PROC_REF(on_source_disk_deleted))
+ stored_research = source_code_hdd.stored_research
+ stored_research.techweb_servers |= src
+
/**
* Adopts every experiment handler aboard this ship that has no techweb link.
*
diff --git a/voidcrew/modules/ship_saves/ship_save_capture.dm b/voidcrew/modules/ship_saves/ship_save_capture.dm
index 745ce1a9cc0..69585fe1126 100644
--- a/voidcrew/modules/ship_saves/ship_save_capture.dm
+++ b/voidcrew/modules/ship_saves/ship_save_capture.dm
@@ -229,6 +229,9 @@ GLOBAL_LIST_INIT(ship_save_stocked_fixtures, ship_save_build_stocked_fixtures())
"rooms" = rooms,
"jobs" = jobs,
"tgm" = snapshot.tgm,
+ "machine_parts" = snapshot.machine_parts,
+ "research" = snapshot.research,
+ "funds" = snapshot.carried_funds,
))
/**
@@ -256,6 +259,12 @@ GLOBAL_LIST_INIT(ship_save_stocked_fixtures, ship_save_build_stocked_fixtures())
snapshot.port_x = data["port_x"]
snapshot.port_y = data["port_y"]
snapshot.tgm = data["tgm"]
+ if(islist(data["machine_parts"]))
+ snapshot.machine_parts = data["machine_parts"]
+ if(islist(data["research"]))
+ snapshot.research = data["research"]
+ if(isnum(data["funds"]) && data["funds"] > 0)
+ snapshot.carried_funds = data["funds"]
for(var/list/room in data["rooms"])
var/room_type = text2path(room["type"])
if(!ispath(room_type, /area))
diff --git a/voidcrew/modules/ship_saves/ship_save_console.dm b/voidcrew/modules/ship_saves/ship_save_console.dm
index e723ea5c514..49910843715 100644
--- a/voidcrew/modules/ship_saves/ship_save_console.dm
+++ b/voidcrew/modules/ship_saves/ship_save_console.dm
@@ -31,10 +31,13 @@
/// Captures the ship, asks the captain, and charges them for what it holds. Sleeps at every step, so
/// everything is checked again afterwards.
-/datum/ship_checkpoint_ui/proc/save_ship(mob/living/user, datum/outpost_berth/ship_bay/bay, slot)
+/datum/ship_checkpoint_ui/proc/save_ship(mob/living/user, datum/outpost_berth/ship_bay/bay, slot, funds_parts = 0)
error = saved_ship_denial(user, bay, slot)
if(error)
return FALSE
+ if(!isnum(funds_parts) || funds_parts < 0 || funds_parts != round(funds_parts) || funds_parts > SHIP_SAVE_MAX_FUNDS_PARTS)
+ error = "That's not an amount."
+ return FALSE
working = TRUE
notice = null
var/obj/structure/overmap/ship/ship = bay.ship
@@ -43,27 +46,63 @@
var/datum/ship_checkpoint/snapshot = new
var/failure = snapshot.capture(ship, user) || saved_ship_changed_denial(user, bay, ship, slot)
var/list/survey
+ var/list/payment
+ var/datum/bank_account/account = ship.ship_account
if(!failure)
survey = ship.save_survey(fresh = TRUE)
if(!survey)
failure = "The source hull is no longer available."
+ else
+ // The price the screen shows, with whatever the captain asked to carry on top.
+ survey = ship_save_add_funds(survey, funds_parts)
+ var/list/old_row
+ var/old_name
+ var/old_round
+ var/list/options
+ if(!failure)
+ old_row = GLOB.ship_saves.saved_row(player_ckey, slot)
+ old_name = old_row?["ship_name"]
+ old_round = old_row?["saved_round"]
+ GLOB.ship_economy_db.invalidate_cache(player_ckey, "parts")
+ var/list/held = GLOB.ship_economy_db.get_parts(player_ckey)
+ var/funds = funds_parts * SHIP_SAVE_FUNDS_PER_TRADE_PART
+ var/balance_left = (account?.account_balance || 0) - funds
+ options = balance_left >= 0 ? ship_save_payment_options(survey["cost"], held, balance_left) : list()
+ if(!held)
+ failure = "Shipyard records are offline."
+ else if(balance_left < 0)
+ failure = "You need [ship_save_credits_text(funds)]."
+ else if(!length(options))
+ failure = ship_save_payment_denial(survey["cost"], held)
+ else
+ for(var/label in options)
+ var/list/option = options[label]
+ option["funds"] = funds
if(!failure)
- var/list/old_row = GLOB.ship_saves.saved_row(player_ckey, slot)
- var/old_name = old_row?["ship_name"]
- var/old_round = old_row?["saved_round"]
- var/cost_text = ship_save_cost_text(survey["cost"])
- var/question = old_row ? "Overwrite [old_name] with [ship_name] for [cost_text]?" : "Save [ship_name] to slot [slot] for [cost_text]?"
- if(!confirm_save(user, question, "Shipyard"))
+ var/list/prices = list()
+ for(var/label in options)
+ prices += ship_save_payment_text(options[label])
+ var/price_text = english_list(prices, and_text = " or ")
+ var/question = old_row ? "Overwrite [old_name] with [ship_name] for [price_text]?" : "Save [ship_name] to slot [slot] for [price_text]?"
+ var/list/old_survey = old_row?["survey"]
+ if(old_survey?["funds"] > 0)
+ question += " The [ship_save_credits_text(old_survey["funds"])] held with [old_name] will be lost."
+ var/choice = choose_save_payment(user, question, options)
+ if(!choice)
qdel(snapshot)
working = FALSE
return FALSE
+ payment = options[choice]
failure = saved_ship_changed_denial(user, bay, ship, slot)
+ // Parts pulled or research done while the box was open would be saved as it was, and priced as it was.
+ if(!failure && snapshot.progress_changed(ship.shuttle))
+ failure = "The ship changed. Try again."
if(!failure)
var/list/new_row = GLOB.ship_saves.saved_row(player_ckey, slot)
if(new_row?["ship_name"] != old_name || new_row?["saved_round"] != old_round)
failure = "That slot changed. Try again."
if(!failure)
- failure = GLOB.ship_saves.commit_save(player_ckey, snapshot, user, slot, survey)
+ failure = GLOB.ship_saves.commit_save(player_ckey, snapshot, user, slot, survey, payment, account)
qdel(snapshot)
if(QDELETED(src))
return !failure
@@ -154,10 +193,13 @@
"name" = ship.name,
"class" = ship.source_template?.short_name,
"denial" = saved_ship_bay_denial(user, bay),
+ "balance" = ship.ship_account?.account_balance || 0,
), ship.save_survey()))
return list(
"online" = !!GLOB.ship_save_store.online(),
"parts" = GLOB.ship_economy_db.cached_parts(user.ckey),
+ "part_credits" = SHIP_SAVE_CREDITS_PER_PART,
+ "funds_per_part" = SHIP_SAVE_FUNDS_PER_TRADE_PART,
"slots" = SHIP_SAVE_SLOTS,
"saves" = saves,
"ships" = ships,
diff --git a/voidcrew/modules/ship_saves/ship_save_construction.dm b/voidcrew/modules/ship_saves/ship_save_construction.dm
index 5db4f3cc250..0557700dfc8 100644
--- a/voidcrew/modules/ship_saves/ship_save_construction.dm
+++ b/voidcrew/modules/ship_saves/ship_save_construction.dm
@@ -4,9 +4,12 @@
build_noun = "Construction"
/// The player's save slot this ship is being built from.
var/save_slot
+ /// Credits the save holds. They join the old hull's balance when the save is spent.
+ var/carried_funds = 0
/datum/checkpoint_construction/saved/New(datum/ship_checkpoint_ui/terminal, datum/ship_checkpoint/blueprint, mob/living/user, slot, manual_drive = FALSE)
save_slot = slot
+ carried_funds = blueprint.carried_funds
blueprint.outpost = terminal.outpost
..(terminal, blueprint, user, manual_drive)
@@ -25,6 +28,7 @@
. = ..()
if(!.)
return
+ held_balance += carried_funds
GLOB.ship_saves.spend(captain_ckey, save_slot)
log_game("[captain_ckey] spent ship save slot [save_slot] on [ship_name] at [home.name].")
@@ -35,8 +39,14 @@
if(!committed)
GLOB.ship_saves.cancel_retrieval(captain_ckey, save_slot)
+/datum/checkpoint_construction/saved/prepare_copy()
+ ..()
+ snapshot.restore_progress(port)
+ mark_phase("restore_progress")
+
/datum/checkpoint_construction/saved/after_ship_loaded()
place_ship_landmarks(port)
+ vessel.settle_saved_research()
/datum/checkpoint_construction/saved/finished_text()
return "launched from a saved ship"
diff --git a/voidcrew/modules/ship_saves/ship_save_lobby.dm b/voidcrew/modules/ship_saves/ship_save_lobby.dm
index 91d8f5f0a8a..7c044dff5ea 100644
--- a/voidcrew/modules/ship_saves/ship_save_lobby.dm
+++ b/voidcrew/modules/ship_saves/ship_save_lobby.dm
@@ -28,6 +28,7 @@
var/obj/machinery/door/door = machine
door.req_access = null
door.req_one_access = null
+ blueprint.restore_progress(loaded)
blueprint = null
/**
@@ -106,6 +107,7 @@
var/datum/map_template/shuttle/voidcrew/commissioned/checkpoint/template = new(snapshot)
// Not holding the old hull while this waits: it can be deleted in the meantime.
original = null
+ var/carried_funds = snapshot.carried_funds
var/obj/structure/overmap/ship/target = SSshuttle.create_ship(template)
qdel(snapshot)
original = source?.resolve()
@@ -149,6 +151,9 @@
var/balance = original.retire_replaced_hull()
if(balance > 0)
target.ship_account.adjust_money(balance, "Recovered ship account")
+ if(carried_funds > 0)
+ target.ship_account.adjust_money(carried_funds, "Saved funds")
+ target.settle_saved_research()
target.password_cleared_ckeys[player_ckey] = TRUE
if(QDELETED(player))
return
diff --git a/voidcrew/modules/ship_saves/ship_save_progress.dm b/voidcrew/modules/ship_saves/ship_save_progress.dm
new file mode 100644
index 00000000000..723a5f19f54
--- /dev/null
+++ b/voidcrew/modules/ship_saves/ship_save_progress.dm
@@ -0,0 +1,260 @@
+/**
+ * What a paid-for save keeps beyond the hull: the stock parts in the machines on the price table, and one
+ * R&D server's research. The carried funds are in the payload too, but nothing has to be done to the hull
+ * for those. Everything here is what the map text can't say, stored as text so saves outlive code changes.
+ */
+
+/// Power parts a priced machine can come back with. Exact types: no unique, rigged or self-charging cell.
+GLOBAL_LIST_INIT(ship_save_kept_power_parts, typecacheof(list(
+ /obj/item/stock_parts/power_store/cell,
+ /obj/item/stock_parts/power_store/cell/empty,
+ /obj/item/stock_parts/power_store/cell/upgraded,
+ /obj/item/stock_parts/power_store/cell/upgraded/plus,
+ /obj/item/stock_parts/power_store/cell/high,
+ /obj/item/stock_parts/power_store/cell/high/empty,
+ /obj/item/stock_parts/power_store/cell/super,
+ /obj/item/stock_parts/power_store/cell/super/empty,
+ /obj/item/stock_parts/power_store/cell/hyper,
+ /obj/item/stock_parts/power_store/cell/hyper/empty,
+ /obj/item/stock_parts/power_store/cell/bluespace,
+ /obj/item/stock_parts/power_store/cell/bluespace/empty,
+ /obj/item/stock_parts/power_store/battery,
+ /obj/item/stock_parts/power_store/battery/empty,
+ /obj/item/stock_parts/power_store/battery/upgraded,
+ /obj/item/stock_parts/power_store/battery/high,
+ /obj/item/stock_parts/power_store/battery/high/empty,
+ /obj/item/stock_parts/power_store/battery/super,
+ /obj/item/stock_parts/power_store/battery/super/empty,
+ /obj/item/stock_parts/power_store/battery/hyper,
+ /obj/item/stock_parts/power_store/battery/hyper/empty,
+ /obj/item/stock_parts/power_store/battery/bluespace,
+ /obj/item/stock_parts/power_store/battery/bluespace/empty,
+), only_root_path = TRUE))
+
+/// Researched nodes a fresh web doesn't start with, if this server's disk is really installed. Else 0.
+/obj/machinery/rnd/server/ship/proc/saved_research_count()
+ SHOULD_NOT_SLEEP(TRUE)
+ if(QDELETED(source_code_hdd) || source_code_hdd.loc != src || stored_research != source_code_hdd.stored_research)
+ return 0
+ . = 0
+ for(var/node_id in stored_research.researched_nodes)
+ if(!SSresearch.techweb_nodes_starting[node_id])
+ .++
+
+/// A priced machine's stock parts as text. Board stacks and other contents aren't parts of its progress.
+/proc/ship_save_part_paths(obj/machinery/machine)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/list/paths = list()
+ for(var/part in machine.component_parts)
+ if(istype(part, /datum/stock_part))
+ var/datum/stock_part/stock_part = part
+ paths += "[stock_part.type]"
+ else if(isitem(part))
+ var/obj/item/item_part = part
+ if(GLOB.ship_save_kept_power_parts[item_part.type])
+ paths += "[item_part.type]"
+ return paths
+
+/// Where a machine is on its hull, whichever bay or shipyard the hull is in, and what it comes back as.
+/datum/ship_checkpoint/proc/machine_key(obj/machinery/machine, obj/docking_port/mobile/port, saved_type)
+ return "[machine.x - port.x + port_x],[machine.y - port.y + port_y],[saved_type]"
+
+/// The parts and research this hull holds now. Doesn't sleep, so it can be read twice and compared.
+/datum/ship_checkpoint/proc/read_progress(obj/docking_port/mobile/voidcrew/port)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/list/parts = list()
+ var/obj/machinery/rnd/server/ship/best_server
+ var/best_count = 0
+ var/list/rooms_here = list()
+ for(var/area/room as anything in hull_owned_areas(port))
+ rooms_here[room] = TRUE
+ for(var/turf/tile as anything in port.return_turfs())
+ if(!rooms_here[tile.loc])
+ continue
+ for(var/obj/machinery/machine in tile)
+ if(!ship_save_keeps_object(machine))
+ continue
+ var/saved_type = machine.checkpoint_type()
+ if(GLOB.ship_save_priced_machines[saved_type])
+ var/list/part_paths = ship_save_part_paths(machine)
+ if(length(part_paths))
+ var/key = machine_key(machine, port, saved_type)
+ if(!parts[key])
+ parts[key] = list()
+ parts[key] += list(part_paths)
+ if(istype(machine, /obj/machinery/rnd/server/ship))
+ var/obj/machinery/rnd/server/ship/server = machine
+ var/count = server.saved_research_count()
+ if(count > best_count)
+ best_count = count
+ best_server = server
+ return list("parts" = parts, "research" = best_server ? ship_save_read_research(best_server, port, src) : null)
+
+/proc/ship_save_read_research(obj/machinery/rnd/server/ship/server, obj/docking_port/mobile/port, datum/ship_checkpoint/snapshot)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/datum/techweb/web = server.stored_research
+ var/list/nodes = list()
+ for(var/node_id in web.researched_nodes)
+ if(!SSresearch.techweb_nodes_starting[node_id])
+ nodes += node_id
+ var/list/experiments = list()
+ for(var/experiment_type in web.completed_experiments)
+ experiments += "[experiment_type]"
+ return list(
+ "x" = server.x - port.x + snapshot.port_x,
+ "y" = server.y - port.y + snapshot.port_y,
+ "nodes" = nodes,
+ "experiments" = experiments,
+ )
+
+/// Takes down what read_progress() sees, and remembers it for progress_changed().
+/datum/ship_checkpoint/proc/capture_progress(obj/docking_port/mobile/voidcrew/port)
+ var/list/progress = read_progress(port)
+ machine_parts = progress["parts"]
+ research = progress["research"]
+ progress_print = json_encode(progress)
+
+/// Whether the parts or research aboard differ from what this snapshot took down and priced.
+/datum/ship_checkpoint/proc/progress_changed(obj/docking_port/mobile/voidcrew/port)
+ if(QDELETED(port))
+ return TRUE
+ return json_encode(read_progress(port)) != progress_print
+
+/**
+ * Puts the saved parts and research into a freshly loaded, scrubbed copy of the hull. In one tick, like the
+ * scrubbing before it. A machine that no longer matches stays at its defaults: nothing here is worth failing
+ * a load over, and the save is already paid for.
+ */
+/datum/ship_checkpoint/proc/restore_progress(obj/docking_port/mobile/voidcrew/port)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/restored_parts = 0
+ var/missed_parts = 0
+ var/obj/machinery/rnd/server/ship/research_server
+ var/obj/machinery/rnd/server/ship/any_server
+ for(var/turf/tile as anything in port.return_turfs())
+ if(!(get_area(tile) in port.shuttle_areas))
+ continue
+ for(var/obj/machinery/machine in tile)
+ if(length(machine_parts))
+ var/list/waiting = machine_parts[machine_key(machine, port, machine.type)]
+ if(length(waiting))
+ ship_save_restore_parts(machine, waiting[1])
+ waiting.Cut(1, 2)
+ restored_parts++
+ if(research && istype(machine, /obj/machinery/rnd/server/ship))
+ any_server = any_server || machine
+ if(machine.x - port.x + port_x == research["x"] && machine.y - port.y + port_y == research["y"])
+ research_server = machine
+ for(var/key in machine_parts)
+ var/list/waiting = machine_parts[key]
+ missed_parts += length(waiting)
+ if(research)
+ research_server = research_server || any_server
+ if(research_server)
+ research_server.restore_saved_research(research)
+ else
+ log_game("Ship save [ship_name] holds research, but its hull has no R&D server to put it in.")
+ if(missed_parts)
+ log_game("Ship save [ship_name]: [missed_parts] of [restored_parts + missed_parts] machines came back without their saved parts.")
+
+/// Swaps a default machine's parts for the saved ones, category by category.
+/proc/ship_save_restore_parts(obj/machinery/machine, list/part_paths)
+ var/list/available = machine.component_parts.Copy()
+ for(var/path_text in part_paths)
+ var/part_path = text2path(path_text)
+ if(ispath(part_path, /datum/stock_part))
+ var/datum/stock_part/saved_part = GLOB.stock_part_datums[part_path]
+ if(!saved_part)
+ continue
+ for(var/datum/stock_part/current in available)
+ if(current.physical_object_base_type != saved_part.physical_object_base_type)
+ continue
+ available -= current
+ machine.component_parts[machine.component_parts.Find(current)] = saved_part
+ break
+ else if(ispath(part_path, /obj/item/stock_parts/power_store) && GLOB.ship_save_kept_power_parts[part_path])
+ var/power_base = ispath(part_path, /obj/item/stock_parts/power_store/battery) ? /obj/item/stock_parts/power_store/battery : /obj/item/stock_parts/power_store/cell
+ for(var/obj/item/stock_parts/power_store/current in available)
+ if(!istype(current, power_base))
+ continue
+ available -= current
+ var/obj/item/stock_parts/power_store/saved_power = new part_path(machine)
+ checkpoint_scrub_part(saved_power)
+ // Recycling one must not hand back what the cell was made of.
+ saved_power.set_custom_materials(null)
+ machine.component_parts[machine.component_parts.Find(current)] = saved_power
+ qdel(current)
+ break
+ machine.RefreshParts()
+ machine.update_appearance()
+
+/**
+ * Gives a loaded server a new disk holding the saved web. Nodes are forced, so nothing is paid and no gate
+ * applies, and experiments are marked done without running their completion, which is what pays out. Links
+ * wait for settle_saved_research(), when the hull is somewhere it can link from.
+ */
+/obj/machinery/rnd/server/ship/proc/restore_saved_research(list/saved)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/obj/item/computer_disk/ship_disk/disk = new(src)
+ adopt_source_disk(disk)
+ var/datum/techweb/web = stored_research
+ // First, so researching the nodes below doesn't offer them again.
+ for(var/type_text in saved["experiments"])
+ var/experiment_type = text2path(type_text)
+ if(!ispath(experiment_type, /datum/experiment) || web.completed_experiments[experiment_type])
+ continue
+ var/datum/experiment/done
+ for(var/datum/experiment/offered as anything in web.available_experiments)
+ if(offered.type == experiment_type)
+ done = offered
+ break
+ web.available_experiments -= done
+ web.completed_experiments[experiment_type] = done || new experiment_type(web)
+ web.local_experiment_reward_ledger[experiment_type] = TRUE
+ for(var/node_id in saved["nodes"])
+ var/datum/techweb_node/node = SSresearch.techweb_nodes[node_id]
+ if(!node)
+ continue
+ web.hidden_nodes -= node_id
+ web.research_node(node, TRUE, FALSE, FALSE)
+
+/**
+ * Once a loaded hull has its crew and its place: its machines get the links they had to be multitooled to,
+ * and its crew has already been paid for what the saved web completed, so a second disk can't earn it again.
+ */
+/obj/structure/overmap/ship/proc/settle_saved_research()
+ if(QDELETED(shuttle))
+ return
+ var/datum/voidcrew_crew_record/crew = get_crew_record()
+ for(var/obj/machinery/rnd/server/ship/server as anything in GLOB.ship_research_servers)
+ if(QDELETED(server) || !server.stored_research || !(get_area(server) in shuttle.shuttle_areas))
+ continue
+ var/datum/techweb/web = server.stored_research
+ if(!QDELETED(crew))
+ for(var/experiment_type in web.completed_experiments)
+ crew.research_reward_ledger[experiment_type] = TRUE
+ for(var/area/room as anything in shuttle.shuttle_areas)
+ for(var/obj/machinery/machine in room)
+ if(machine == server)
+ continue
+ if(istype(machine, /obj/machinery/rnd))
+ var/obj/machinery/rnd/lab_machine = machine
+ if(!lab_machine.stored_research)
+ lab_machine.connect_techweb(web)
+ else if(istype(machine, /obj/machinery/mecha_part_fabricator))
+ var/obj/machinery/mecha_part_fabricator/fabricator = machine
+ if(!fabricator.stored_research)
+ fabricator.connect_techweb(web)
+ else if(istype(machine, /obj/machinery/component_printer))
+ var/obj/machinery/component_printer/printer = machine
+ if(!printer.techweb)
+ printer.connect_techweb(web)
+ else if(istype(machine, /obj/machinery/computer/rdconsole))
+ var/obj/machinery/computer/rdconsole/console = machine
+ if(!console.stored_research)
+ console.stored_research = web
+ web.connected_machines |= console
+ web.consoles_accessing |= console
+ server.claim_unlinked_experiment_handlers()
+ server.claim_unlinked_survey_console()
+ return
diff --git a/voidcrew/modules/ship_saves/ship_save_survey.dm b/voidcrew/modules/ship_saves/ship_save_survey.dm
index 7ebcda8660a..3ff1646910e 100644
--- a/voidcrew/modules/ship_saves/ship_save_survey.dm
+++ b/voidcrew/modules/ship_saves/ship_save_survey.dm
@@ -76,6 +76,7 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
var/static/list/plan_rank = list("w" = 1, "s" = 2, "t" = 3, "e" = 4, "#" = 5, "g" = 6, "o" = 7)
var/static/list/class_mark = list(PART_CLASS_COMBAT = "w", PART_CLASS_SCIENCE = "s", PART_CLASS_TRADE = "t")
var/mass = 0
+ var/research_nodes = 0
/// Line name -> list("class", "each", "count")
var/list/lines = list()
var/list/plan_rows = list()
@@ -109,6 +110,9 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
object_mark = class_mark[price["class"]]
else if(istype(machine, /obj/machinery/power/shuttle_engine))
object_mark = "e"
+ if(istype(machine, /obj/machinery/rnd/server/ship))
+ var/obj/machinery/rnd/server/ship/server = machine
+ research_nodes = max(research_nodes, server.saved_research_count())
if(object_mark && plan_rank[object_mark] < plan_rank[mark])
mark = object_mark
plan_row += mark
@@ -116,14 +120,15 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
var/crew = 0
for(var/list/definition in job_definitions)
crew += definition["slots"] || 1
- var/list/survey = ship_save_price_bill(lines, mass)
+ var/list/survey = ship_save_price_bill(lines, mass, research_nodes)
+ survey["research"] = research_nodes
survey["mass"] = mass
survey["crew"] = crew
survey["plan"] = ship_save_encode_plan(plan_rows)
return survey
/// Rounds each line up and adds them into bill, total and cost.
-/proc/ship_save_price_bill(list/lines, mass)
+/proc/ship_save_price_bill(list/lines, mass, research_nodes = 0)
SHOULD_NOT_SLEEP(TRUE)
var/list/items_by_class = list()
var/total = 0
@@ -134,6 +139,10 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
var/parts = CEILING(line["count"] * line["each"], 1)
items_by_class[line["class"]] += list(list("name" = line_name, "count" = line["count"], "parts" = parts))
total += parts
+ if(research_nodes > 0)
+ var/research_parts = CEILING(research_nodes / SHIP_SAVE_NODES_PER_PART, 1)
+ items_by_class[PART_CLASS_SCIENCE] += list(list("name" = "Research, [research_nodes] nodes", "count" = null, "parts" = research_parts, "carry" = 1))
+ total += research_parts
var/list/misc_items = items_by_class[PART_CLASS_MISC]
var/hull_parts = CEILING(mass / SHIP_SAVE_MASS_PER_PART, 1)
if(hull_parts)
@@ -210,7 +219,7 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
if(!islist(survey) || !isnum(survey["mass"]) || !isnum(survey["crew"]) || !isnum(survey["total"]) || !islist(survey["bill"]))
return null
var/plan = survey["plan"]
- return list("mass" = survey["mass"], "crew" = survey["crew"], "plan" = istext(plan) ? plan : null, "bill" = survey["bill"], "total" = survey["total"])
+ return list("mass" = survey["mass"], "crew" = survey["crew"], "plan" = istext(plan) ? plan : null, "bill" = survey["bill"], "total" = survey["total"], "research" = isnum(survey["research"]) ? survey["research"] : 0, "funds" = isnum(survey["funds"]) ? survey["funds"] : 0)
/// "3 combat, 4 science and 7 misc parts"
/proc/ship_save_cost_text(list/cost)
@@ -219,6 +228,104 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list(
amounts += "[cost[part_class]] [part_class]"
return "[english_list(amounts)] parts"
+/// One way to pay for a save: list("parts" = class -> parts, "credits" = credits, "short" = class -> parts
+/// missing). Only misc parts can be paid in credits. Uses the misc parts held first, or all_credits uses none.
+/proc/ship_save_payment(list/cost, list/held, all_credits = FALSE)
+ var/list/parts = list()
+ var/list/short = list()
+ var/credits = 0
+ for(var/part_class in cost)
+ var/due = cost[part_class]
+ var/used = min(due, held?[part_class] || 0)
+ if(part_class == PART_CLASS_MISC)
+ if(all_credits)
+ used = 0
+ credits += (due - used) * SHIP_SAVE_CREDITS_PER_PART
+ else if(due > used)
+ short[part_class] = due - used
+ if(used)
+ parts[part_class] = used
+ return list("parts" = parts, "credits" = credits, "short" = short)
+
+/**
+ * The ways a captain can afford to pay for a save, as button label -> payment, parts first. Empty if there
+ * are none. Only the hull's misc parts ever have a choice, so with none on the bill there is only one way.
+ */
+/proc/ship_save_payment_options(list/cost, list/held, balance)
+ var/list/options = list()
+ var/list/first = ship_save_payment(cost, held)
+ if(length(first["short"]))
+ return options
+ if(balance >= first["credits"])
+ options[ship_save_payment_label(first)] = first
+ var/list/second = ship_save_payment(cost, held, all_credits = TRUE)
+ if(second["credits"] != first["credits"] && balance >= second["credits"])
+ var/label = ship_save_payment_label(second)
+ // Both can use parts and credits, when the misc parts held only cover some of the hull.
+ options[options[label] ? "Hull in credits" : label] = second
+ return options
+
+/proc/ship_save_payment_label(list/payment)
+ if(!payment["credits"])
+ return "Parts"
+ return length(payment["parts"]) ? "Parts and credits" : "Credits"
+
+/// Why nothing in ship_save_payment_options() can be paid.
+/proc/ship_save_payment_denial(list/cost, list/held)
+ var/list/payment = ship_save_payment(cost, held)
+ if(length(payment["short"]))
+ return "You need [ship_save_cost_text(payment["short"])]."
+ return "You need [ship_save_credits_text(payment["credits"])]."
+
+/// "1 combat and 2 misc parts and 7,500 cr"
+/proc/ship_save_payment_text(list/payment)
+ var/list/amounts = list()
+ if(length(payment["parts"]))
+ amounts += ship_save_cost_text(payment["parts"])
+ if(payment["credits"])
+ amounts += ship_save_credits_text(payment["credits"])
+ return amounts.Join(" and ")
+
+/// "15,000 cr"
+/proc/ship_save_credits_text(credits)
+ var/digits = num2text(credits, 12)
+ var/list/groups = list()
+ while(length(digits) > 3)
+ groups.Insert(1, copytext(digits, -3))
+ digits = copytext(digits, 1, -3)
+ groups.Insert(1, digits)
+ return "[groups.Join(",")] cr"
+
+/// A copy of a survey with funds on its bill, which the screen adds the same way. The survey itself is cached.
+/proc/ship_save_add_funds(list/survey, funds_parts)
+ if(funds_parts <= 0)
+ return survey
+ var/funds = funds_parts * SHIP_SAVE_FUNDS_PER_TRADE_PART
+ var/list/line = list("name" = "Funds, [ship_save_credits_text(funds)]", "count" = null, "parts" = funds_parts, "carry" = 1)
+ var/list/old_bill = survey["bill"]
+ var/list/entries = list()
+ for(var/list/entry as anything in old_bill)
+ entries[entry["class"]] = entry
+ var/list/trade_entry = entries[PART_CLASS_TRADE]
+ var/list/trade_items = trade_entry ? trade_entry["items"] : list()
+ trade_items = trade_items.Copy()
+ trade_items += list(line)
+ sortTim(trade_items, GLOBAL_PROC_REF(cmp_ship_save_bill_line))
+ entries[PART_CLASS_TRADE] = list("class" = PART_CLASS_TRADE, "parts" = (trade_entry?["parts"] || 0) + funds_parts, "items" = trade_items)
+ var/list/bill = list()
+ for(var/part_class in GLOB.ship_part_classes)
+ if(entries[part_class])
+ bill += list(entries[part_class])
+ var/list/cost = survey["cost"]
+ cost = cost.Copy()
+ cost[PART_CLASS_TRADE] += funds_parts
+ var/list/priced = survey.Copy()
+ priced["bill"] = bill
+ priced["cost"] = cost
+ priced["total"] = survey["total"] + funds_parts
+ priced["funds"] = funds
+ return priced
+
/obj/structure/overmap/ship
/// This hull's last ship_save_survey(), so an open window doesn't rescan it every tick.
var/list/save_survey_cache
diff --git a/voidcrew/modules/ship_saves/ship_saves.dm b/voidcrew/modules/ship_saves/ship_saves.dm
index e79d9c243bb..5688f8e3f10 100644
--- a/voidcrew/modules/ship_saves/ship_saves.dm
+++ b/voidcrew/modules/ship_saves/ship_saves.dm
@@ -59,17 +59,19 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
/**
* Takes payment and writes a captured ship to the player's chosen slot, replacing whatever is there. Sleeps.
+ * With no payment (see ship_save_payment()) the whole price is paid in parts. Credits and carried funds
+ * come out of account, and the funds go into the save.
* The payment goes back if the write fails, and the old row stays. Returns an error for the player, or null
* on success. The slot stays locked throughout, so nothing can use or delete the old save under it.
*/
-/datum/ship_saves/proc/commit_save(ckey, datum/ship_checkpoint/snapshot, mob/user, slot, list/survey)
+/datum/ship_saves/proc/commit_save(ckey, datum/ship_checkpoint/snapshot, mob/user, slot, list/survey, list/payment, datum/bank_account/account)
ckey = ckey(ckey)
if(!begin_retrieval(ckey, slot))
return "Already under way."
- . = write_claimed_save(ckey, snapshot, user, slot, survey)
+ . = write_claimed_save(ckey, snapshot, user, slot, survey, payment, account)
cancel_retrieval(ckey, slot)
-/datum/ship_saves/proc/write_claimed_save(ckey, datum/ship_checkpoint/snapshot, mob/user, slot, list/survey)
+/datum/ship_saves/proc/write_claimed_save(ckey, datum/ship_checkpoint/snapshot, mob/user, slot, list/survey, list/payment, datum/bank_account/account)
PRIVATE_PROC(TRUE)
// A runtime below ends this proc with this answer, not with a null that reads as success.
. = "Shipyard records are offline."
@@ -84,11 +86,19 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
var/datum/weakref/source = snapshot.source_ship
var/datum/weakref/crew_ref = snapshot.crew_record_ref
var/obj/structure/overmap/ship/ship = source?.resolve()
+ var/funds_due = payment?["funds"] || 0
+ snapshot.carried_funds = funds_due
var/payload = encode(snapshot)
var/list/cost = survey["cost"]
+ var/list/parts_due = payment ? payment["parts"] : cost
+ var/credits_due = (payment?["credits"] || 0) + funds_due
GLOB.ship_economy_db.invalidate_cache(ckey, "parts")
- if(!GLOB.ship_economy_db.spend_parts(ckey, cost))
- return "You need [ship_save_cost_text(cost)]."
+ if(length(parts_due) && !GLOB.ship_economy_db.spend_parts(ckey, parts_due))
+ return "You need [ship_save_cost_text(parts_due)]."
+ // Last, because the parts sleep: the balance can't change between checking it and taking it.
+ if(credits_due && (QDELETED(account) || !account.adjust_money(-credits_due, "Shipyard: saved [name]")))
+ GLOB.ship_economy_db.refund_parts(ckey, parts_due)
+ return "You need [ship_save_credits_text(credits_due)]."
var/raw_bytes = length(payload)
var/list/summary = ship_save_add_survey(list(), survey)
var/list/row = list(
@@ -101,7 +111,12 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
"summary" = json_encode(summary),
)
if(!GLOB.ship_save_store.write(ckey, slot, row, payload, overwrite))
- GLOB.ship_economy_db.refund_parts(ckey, cost)
+ GLOB.ship_economy_db.refund_parts(ckey, parts_due)
+ if(credits_due)
+ if(QDELETED(account))
+ log_game("[ckey] lost [credits_due] credits on a failed ship save: the ship's account is gone.")
+ else
+ account.adjust_money(credits_due, "Shipyard: refund")
return
row -= "summary"
row["survey"] = summary
@@ -124,8 +139,8 @@ GLOBAL_DATUM_INIT(ship_saves, /datum/ship_saves, new)
rows -= list(replaced)
rows += list(row)
cache[ckey] = rows
- log_game("[key_name(user)] saved [name] to ship save slot [slot] for [ship_save_cost_text(cost)], [raw_bytes] bytes[old_name ? ", replacing [old_name]" : ""].")
- record_metric(METRIC_SHIP, "ship_saved", ckey = ckey, ship = ship, zone = ship_metric_zone(ship), subject = class, quantity = survey["total"], details = list("tiles" = tiles, "raw_bytes" = raw_bytes, "cost" = cost, "overwrote" = old_name))
+ log_game("[key_name(user)] saved [name] to ship save slot [slot] for [payment ? ship_save_payment_text(payment) : ship_save_cost_text(cost)][funds_due ? ", holding [funds_due] cr" : ""], [raw_bytes] bytes[old_name ? ", replacing [old_name]" : ""].")
+ record_metric(METRIC_SHIP, "ship_saved", ckey = ckey, ship = ship, zone = ship_metric_zone(ship), subject = class, quantity = survey["total"], details = list("tiles" = tiles, "raw_bytes" = raw_bytes, "cost" = cost, "credits" = credits_due - funds_due, "funds" = funds_due, "overwrote" = old_name))
refresh(ckey)
return null
diff --git a/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm b/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
index 18f7e67ef30..5cd669dcff5 100644
--- a/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_checkpoints.dm
@@ -4,8 +4,8 @@
/// Most cases place visits directly; one lets the real drones work.
var/manual_jobs = TRUE
-/datum/ship_checkpoint_ui/registry_test/confirm_save(mob/user, prompt_text)
- return accept_save
+/datum/ship_checkpoint_ui/registry_test/choose_save_payment(mob/user, prompt_text, list/options)
+ return accept_save ? options[1] : null
/// Refuse the bay reservation to exercise a rebuild refused before anything loads.
/obj/structure/overmap/dynamic/player_outpost/registry_test
diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
index 08f9fd123b5..d295bb320cd 100644
--- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
+++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm
@@ -2,9 +2,12 @@
#define SHIP_SAVE_TEST_SLOTS 3 // SHIP_SAVE_SLOTS
#define SHIP_SAVE_TEST_PRICE_MIN 5 // SHIP_SAVE_PRICE_MIN
#define SHIP_SAVE_TEST_MASS_PER_PART 150 // SHIP_SAVE_MASS_PER_PART
+#define SHIP_SAVE_TEST_CREDITS_PER_PART 2500 // SHIP_SAVE_CREDITS_PER_PART
+#define SHIP_SAVE_TEST_NODES_PER_PART 20 // SHIP_SAVE_NODES_PER_PART
+#define SHIP_SAVE_TEST_FUNDS_PER_PART 5000 // SHIP_SAVE_FUNDS_PER_TRADE_PART
#define SHIP_SAVE_TEST_PLAN_MAX 8000 // SHIP_SAVE_PLAN_MAX_LENGTH
#define SHIP_SAVE_TEST_PASSWORD_PRICE 3 // SHIP_PASSWORD_PRICE
-#define SHIP_SAVE_TEST_FORMAT 1 // SHIP_SAVE_FORMAT
+#define SHIP_SAVE_TEST_FORMAT 2 // SHIP_SAVE_FORMAT
#define SHIP_SAVE_TEST_TILE_IRON 100 // SHIP_RTD_TILE_IRON
/// GLOB.round_id for the length of these tests. It is null on a world with no database.
#define SHIP_SAVE_TEST_ROUND 777
@@ -485,6 +488,71 @@
/// The hull line and the minimum, with nothing but numbers.
/datum/unit_test/voidcrew_ship_saves/proc/check_bill_rounding()
+ // Credits stand in for misc parts only, and only for the misc parts the captain doesn't spend.
+ var/list/cost = list("combat" = 2, "misc" = 3)
+ var/rate = SHIP_SAVE_TEST_CREDITS_PER_PART
+ var/list/mixed = ship_save_payment_options(cost, list("combat" = 5, "misc" = 1), 1000000)
+ TEST_ASSERT_EQUAL(jointext(assoc_to_keys(mixed), ","), "Parts and credits,Hull in credits", "Wrong choices when short of misc parts")
+ TEST_ASSERT_EQUAL(mixed["Parts and credits"]["credits"], 2 * rate, "Wrong credits for the misc parts missing")
+ TEST_ASSERT_EQUAL(mixed["Parts and credits"]["parts"]["combat"], 2, "A mixed payment took the wrong parts")
+ TEST_ASSERT_EQUAL(mixed["Parts and credits"]["parts"]["misc"], 1, "A mixed payment took the wrong parts")
+ TEST_ASSERT_EQUAL(mixed["Hull in credits"]["credits"], 3 * rate, "Wrong credits for the whole hull")
+ TEST_ASSERT_EQUAL(mixed["Hull in credits"]["parts"]["combat"], 2, "Credits paid for a weapon")
+ TEST_ASSERT_NULL(mixed["Hull in credits"]["parts"]["misc"], "An all-credit hull took misc parts")
+ var/list/held_all = list("combat" = 2, "misc" = 3)
+ TEST_ASSERT_EQUAL(jointext(assoc_to_keys(ship_save_payment_options(cost, held_all, 1000000)), ","), "Parts,Parts and credits", "Wrong choices when holding every part")
+ TEST_ASSERT_EQUAL(jointext(assoc_to_keys(ship_save_payment_options(cost, held_all, 0)), ","), "Parts", "Offered credits nobody has")
+ // A combat part short: no amount of credits helps, and the refusal names it.
+ TEST_ASSERT_EQUAL(length(ship_save_payment_options(cost, list("combat" = 1, "misc" = 3), 1000000)), 0, "Credits paid for a missing weapon part")
+ TEST_ASSERT_EQUAL(ship_save_payment_denial(cost, list("combat" = 1, "misc" = 3)), "You need 1 combat parts.", "The refusal doesn't name the missing part")
+ // A bare hull with no parts at all.
+ var/list/hull_cost = list("misc" = 5)
+ TEST_ASSERT_EQUAL(length(ship_save_payment_options(hull_cost, list(), 5 * rate - 1)), 0, "Offered a payment nobody can make")
+ TEST_ASSERT_EQUAL(ship_save_payment_denial(hull_cost, list()), "You need 12,500 cr.", "Wrong refusal for a hull nobody can pay for")
+ TEST_ASSERT_EQUAL(jointext(assoc_to_keys(ship_save_payment_options(hull_cost, list(), 5 * rate)), ","), "Credits", "An exact balance could not pay")
+ TEST_ASSERT_EQUAL(jointext(assoc_to_keys(ship_save_payment_options(hull_cost, list("misc" = 2), 5 * rate)), ","), "Parts and credits,Credits", "Wrong choices for a part-paid hull")
+ // With nothing in misc there is one way to pay.
+ TEST_ASSERT_EQUAL(jointext(assoc_to_keys(ship_save_payment_options(list("science" = 1), list("science" = 1), 1000000)), ","), "Parts", "Offered a choice with no misc on the bill")
+ TEST_ASSERT_EQUAL(ship_save_credits_text(1234567), "1,234,567 cr", "Credits are written wrong")
+ TEST_ASSERT_EQUAL(ship_save_credits_text(500), "500 cr", "Credits are written wrong")
+
+ // Sizes SHIP_SAVE_NODES_PER_PART against the real tree.
+ log_test("Research nodes: [length(SSresearch.techweb_nodes)] total, [length(SSresearch.techweb_nodes_starting)] starting, [length(SSresearch.techweb_nodes_hidden)] hidden.")
+ // Research is a line of its own, by the node, and carries no machine count.
+ var/list/researched = ship_save_price_bill(list(), 0, SHIP_SAVE_TEST_NODES_PER_PART * 2 + 1)
+ var/list/research_line = bill_line(researched, "science", "Research, [SHIP_SAVE_TEST_NODES_PER_PART * 2 + 1] nodes")
+ TEST_ASSERT_EQUAL(research_line?["parts"], 3, "Research did not round up")
+ TEST_ASSERT_NULL(research_line?["count"], "The research line has a count")
+ TEST_ASSERT_EQUAL(researched["cost"]["science"], 3, "Research isn't paid in science parts")
+ TEST_ASSERT_EQUAL(researched["total"], SHIP_SAVE_TEST_PRICE_MIN, "Research doesn't count towards the minimum")
+
+ // Funds are a trade line on a copy: the survey itself is cached.
+ var/list/unfunded = ship_save_price_bill(list(), 650)
+ var/list/funded = ship_save_add_funds(unfunded, 2)
+ TEST_ASSERT(funded != unfunded, "Funds wrote on the cached survey")
+ TEST_ASSERT_NULL(unfunded["cost"]["trade"], "Funds changed the cached survey's price")
+ TEST_ASSERT_EQUAL(ship_save_add_funds(unfunded, 0), unfunded, "Carrying nothing made a copy")
+ TEST_ASSERT_EQUAL(funded["cost"]["trade"], 2, "Funds aren't paid in trade parts")
+ TEST_ASSERT_EQUAL(funded["total"], 7, "Funds weren't added to the total")
+ TEST_ASSERT_EQUAL(funded["funds"], 2 * SHIP_SAVE_TEST_FUNDS_PER_PART, "Wrong funds for the parts")
+ TEST_ASSERT_EQUAL(bill_line(funded, "trade", "Funds, 10,000 cr")?["parts"], 2, "The funds line is wrong")
+ var/list/funded_classes = list()
+ for(var/list/funded_entry as anything in funded["bill"])
+ funded_classes += funded_entry["class"]
+ TEST_ASSERT_EQUAL(funded_classes.Join(","), "trade,misc", "Funds put the bill's classes out of order")
+
+ // Everything a save carries survives the table, and nothing about it is required.
+ var/datum/ship_checkpoint/carried = make_snapshot()
+ carried.carried_funds = 15000
+ carried.machine_parts = list("3,4,/obj/machinery/rnd/production/protolathe" = list(list("/datum/stock_part/matter_bin/tier4")))
+ carried.research = list("x" = 3, "y" = 5, "nodes" = list("some_node"), "experiments" = list("/datum/experiment/physical/arcade_winner"))
+ var/datum/ship_checkpoint/carried_back = GLOB.ship_saves.decode(GLOB.ship_saves.encode(carried))
+ TEST_ASSERT_EQUAL(carried_back?.carried_funds, 15000, "The funds did not survive the table")
+ TEST_ASSERT_EQUAL(carried_back?.machine_parts?["3,4,/obj/machinery/rnd/production/protolathe"][1][1], "/datum/stock_part/matter_bin/tier4", "The machine parts did not survive the table")
+ TEST_ASSERT_EQUAL(carried_back?.research?["nodes"][1], "some_node", "The research did not survive the table")
+ qdel(carried)
+ qdel(carried_back)
+
var/list/hull_only = ship_save_price_bill(list(), 650)
var/list/hull_line = bill_line(hull_only, "misc", "Hull, 650 t")
TEST_ASSERT_EQUAL(hull_line?["parts"], 5, "650 t did not round up to 5 parts")
@@ -638,8 +706,20 @@
TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price), "Shipyard records are offline.", "A failed write reported success")
TEST_ASSERT_EQUAL(economy.total(), 20, "A failed write kept the payment")
TEST_ASSERT_EQUAL(length(saves.saves_for(player_ckey)), 0, "A failed write reached the cache")
+ // Credits come back from a failed write too.
+ var/datum/bank_account/fund = allocate(/datum/bank_account, "save fund", null, 1, FALSE)
+ fund.account_balance = 5000
+ var/list/part_paid = list("parts" = list("combat" = 1), "credits" = 2500, "funds" = 2500)
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price, part_paid, fund), "Shipyard records are offline.", "A failed write reported success")
+ TEST_ASSERT_EQUAL(fund.account_balance, 5000, "A failed write kept the credits")
+ TEST_ASSERT_EQUAL(economy.total(), 20, "A failed write kept the parts of a mixed payment")
store.fail_writes = FALSE
TEST_ASSERT(!saves.retrievals[saves.slot_key(player_ckey, 2)], "A failed write kept the slot locked")
+ // Short of credits: the parts of a mixed payment go back.
+ fund.account_balance = 4999
+ TEST_ASSERT_EQUAL(saves.commit_save(player_ckey, make_snapshot(275), null, 2, price, part_paid, fund), "You need 5,000 cr.", "A save short of credits went through")
+ TEST_ASSERT_EQUAL(economy.total(), 20, "A save short of credits took parts")
+ TEST_ASSERT_NULL(store.saved["[player_ckey]|2"], "A save short of credits reached the table")
// A failed read is not "no saves".
var/list/loaded = saves.saves_for(player_ckey)
@@ -823,15 +903,19 @@
/datum/ship_checkpoint_ui/registry_test/save_prompt
var/last_prompt
var/datum/callback/while_open
+ /// The button to press, if not the first.
+ var/pick
/datum/ship_checkpoint_ui/registry_test/save_prompt/Destroy()
while_open = null
return ..()
-/datum/ship_checkpoint_ui/registry_test/save_prompt/confirm_save(mob/user, prompt_text, title = "Shipyard")
+/datum/ship_checkpoint_ui/registry_test/save_prompt/choose_save_payment(mob/user, prompt_text, list/options)
last_prompt = prompt_text
while_open?.Invoke()
- return ..()
+ . = ..()
+ if(. && pick)
+ return pick
/// The panel and the password, on a real hull in a real bay.
/datum/unit_test/voidcrew_ship_saves_hull
@@ -842,6 +926,8 @@
var/datum/ship_save_store/fake/store
var/datum/ship_economy_db/fake/economy
var/obj/structure/overmap/ship/test_ship
+ /// Machines change_progress() placed.
+ var/list/progress_added = list()
/datum/unit_test/voidcrew_ship_saves_hull/New()
. = ..()
@@ -924,6 +1010,8 @@
TEST_ASSERT_EQUAL(panel.error, "No such slot.", "Slot [bad_slot] was refused with the wrong words")
TEST_ASSERT_EQUAL(length(store.saved), 0, "A bad slot reached the table")
+ // With nothing in the ship's account, parts are the only way to pay.
+ test_ship.ship_account.account_balance = 0
// Declining costs nothing; accepting charges once and saves the real hull to the chosen slot.
panel.accept_save = FALSE
TEST_ASSERT(!panel.save_ship(captain, bay, 2), "Declining still saved the ship")
@@ -944,7 +1032,7 @@
var/datum/weakref/live_source = GLOB.ship_saves.live_sources[GLOB.ship_saves.slot_key("savecaptain", 2)]
TEST_ASSERT_EQUAL(live_source?.resolve(), test_ship, "The save does not remember the hull it came from")
- // What was stored is a usable copy with nothing per machine in it.
+ // What was stored is a usable copy.
var/datum/ship_checkpoint/decoded = GLOB.ship_saves.decode(entry["payload"])
TEST_ASSERT_NOTNULL(decoded, "The stored hull did not decode")
TEST_ASSERT_EQUAL(owned_tiles(test_ship.shuttle), decoded.owned_tile_count(), "The stored hull lost tiles")
@@ -970,6 +1058,77 @@
TEST_ASSERT_EQUAL(GLOB.ship_saves.saved_row(captain.ckey, 1)["ship_name"], test_ship.name, "An overwrite left the old row in the cache")
TEST_ASSERT_EQUAL(length(store.saved), 2, "An overwrite left the wrong rows in the table")
+ // Credits can pay for the hull, from the ship's account. Everything else still takes parts.
+ var/misc_price = survey["cost"]["misc"] * SHIP_SAVE_TEST_CREDITS_PER_PART
+ var/parts_kept = parts_held()
+ test_ship.ship_account.account_balance = misc_price
+ panel.pick = "Parts and credits"
+ TEST_ASSERT(panel.save_ship(captain, bay, 1), "Saving the hull for credits failed: [panel.error]")
+ panel.pick = null
+ TEST_ASSERT_EQUAL(test_ship.ship_account.account_balance, 0, "Saving the hull for credits charged the wrong amount")
+ TEST_ASSERT_EQUAL(parts_held(), parts_kept - (survey["total"] - survey["cost"]["misc"]), "Saving the hull for credits took the wrong parts")
+ // No misc parts and not quite enough credits: refused, and nothing is taken.
+ var/list/parts_aside = economy.held.Copy()
+ economy.held["misc"] = 0
+ test_ship.ship_account.account_balance = misc_price - 1
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1), "A save nobody could pay for went through")
+ TEST_ASSERT_EQUAL(panel.error, "You need [ship_save_credits_text(misc_price)].", "Wrong refusal for a save nobody could pay for")
+ TEST_ASSERT_EQUAL(test_ship.ship_account.account_balance, misc_price - 1, "A refused save took credits")
+ economy.held["misc"] = parts_aside["misc"]
+
+ // A request for funds has to be a real one.
+ test_ship.ship_account.account_balance = 3 * SHIP_SAVE_TEST_FUNDS_PER_PART
+ for(var/bad_funds in list(-1, 1.5, "text", 1000000))
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1, bad_funds), "Funds of [bad_funds] were accepted")
+ TEST_ASSERT_EQUAL(panel.error, "That's not an amount.", "Funds of [bad_funds] were refused with the wrong words")
+ // More than the account holds, and more trade parts than are held.
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1, 4), "Funds the ship doesn't have were carried")
+ TEST_ASSERT_EQUAL(panel.error, "You need 20,000 cr.", "Funds the ship doesn't have got the wrong refusal")
+ economy.held["trade"] = survey["cost"]["trade"] || 0
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1, 1), "Funds were carried with no trade part to pay for them")
+ TEST_ASSERT_EQUAL(panel.error, "You need 1 trade parts.", "Funds with no trade part got the wrong refusal")
+ economy.held["trade"] = parts_aside["trade"]
+
+ // Funds leave the account, go into the save, and show on its card.
+ var/trade_before = economy.held["trade"]
+ TEST_ASSERT(panel.save_ship(captain, bay, 1, 3), "Saving with funds failed: [panel.error]")
+ TEST_ASSERT_EQUAL(test_ship.ship_account.account_balance, 0, "Funds stayed in the account as well")
+ TEST_ASSERT_EQUAL(economy.held["trade"], trade_before - (survey["cost"]["trade"] || 0) - 3, "Funds took the wrong trade parts")
+ var/datum/ship_checkpoint/funded_save = GLOB.ship_saves.decode(store.saved["savecaptain|1"]["payload"])
+ TEST_ASSERT_EQUAL(funded_save?.carried_funds, 3 * SHIP_SAVE_TEST_FUNDS_PER_PART, "The funds did not go into the save")
+ qdel(funded_save)
+ var/list/funded_row = GLOB.ship_saves.saved_row(captain.ckey, 1)
+ TEST_ASSERT_EQUAL(funded_row?["survey"]["funds"], 3 * SHIP_SAVE_TEST_FUNDS_PER_PART, "The save's card doesn't know its funds")
+ TEST_ASSERT_NULL(survey["funds"], "Funds wrote on the hull's cached survey")
+
+ // Overwriting one says what goes with it.
+ panel.accept_save = FALSE
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1), "Declining an overwrite saved the ship")
+ TEST_ASSERT(findtext(panel.last_prompt, "The 15,000 cr held with [test_ship.name] will be lost."), "The question hid the funds it loses: [panel.last_prompt]")
+ panel.accept_save = TRUE
+
+ // A failed write gives the funds and the parts back, and keeps the old save.
+ test_ship.ship_account.account_balance = SHIP_SAVE_TEST_FUNDS_PER_PART
+ var/parts_before_failure = parts_held()
+ store.fail_writes = TRUE
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1, 1), "A failed write reported success")
+ store.fail_writes = FALSE
+ TEST_ASSERT_EQUAL(test_ship.ship_account.account_balance, SHIP_SAVE_TEST_FUNDS_PER_PART, "A failed write kept the funds")
+ TEST_ASSERT_EQUAL(parts_held(), parts_before_failure, "A failed write kept the parts")
+ TEST_ASSERT_EQUAL(GLOB.ship_saves.saved_row(captain.ckey, 1)?["survey"]["funds"], 3 * SHIP_SAVE_TEST_FUNDS_PER_PART, "A failed write lost the old save's funds")
+
+ // Parts swapped or research done while the box is open aren't what was priced.
+ panel.while_open = CALLBACK(src, PROC_REF(change_progress))
+ TEST_ASSERT(!panel.save_ship(captain, bay, 1), "A ship that changed under the box was saved")
+ TEST_ASSERT_EQUAL(panel.error, "The ship changed. Try again.", "A changed ship gave the wrong refusal")
+ panel.while_open = null
+ TEST_ASSERT_EQUAL(parts_held(), parts_before_failure, "A refused save was charged")
+ for(var/obj/machinery/rnd/production/protolathe/added as anything in progress_added)
+ qdel(added)
+ progress_added.Cut()
+ test_ship.ship_account.account_balance = 0
+ panel.error = null
+
// A locked slot is refused, and so is one that changes while the box is open.
GLOB.ship_saves.refresh(captain.ckey)
var/parts_before = parts_held()
@@ -1032,6 +1191,13 @@
. += length(room.get_turfs_by_zlevel(port.z))
/// Something else writes to the slot while the confirm box is open.
+/// A priced machine appears aboard while the captain is deciding.
+/datum/unit_test/voidcrew_ship_saves_hull/proc/change_progress()
+ for(var/turf/open/floor/tile in test_ship.shuttle.return_turfs())
+ if((get_area(tile) in test_ship.shuttle.shuttle_areas) && !tile.is_blocked_turf(exclude_mobs = TRUE))
+ progress_added += new /obj/machinery/rnd/production/protolathe(tile)
+ return
+
/datum/unit_test/voidcrew_ship_saves_hull/proc/change_slot(player_ckey, slot)
var/list/rows = GLOB.ship_saves.saves_for(player_ckey)
rows += list(list("slot" = slot, "ship_name" = "Interloper", "ship_class" = null, "saved_round" = 1))
@@ -1077,6 +1243,8 @@
var/saved_name
var/saved_class
var/saved_jobs
+ /// The research fit_source() did.
+ var/list/saved_nodes = list()
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/New()
. = ..()
@@ -1133,7 +1301,12 @@
first_meta["saved_round"] = SHIP_SAVE_TEST_ROUND - 1
for(var/slot in 2 to SHIP_SAVE_TEST_SLOTS)
store.saved["savecaptain|[slot]"] = list("meta" = first_meta.Copy(), "payload" = first["payload"])
- store.saved["savelobby|1"] = list("meta" = first_meta.Copy(), "payload" = first["payload"])
+ // The two that get loaded whole hold funds, as if the captain had paid to carry them.
+ var/list/funded_data = json_decode(first["payload"])
+ funded_data["funds"] = SHIP_SAVE_TEST_FUNDS_PER_PART * 3
+ var/funded_payload = json_encode(funded_data)
+ store.saved["savelobby|1"] = list("meta" = first_meta.Copy(), "payload" = funded_payload)
+ first["payload"] = funded_payload
GLOB.ship_saves.refresh("savecaptain")
GLOB.ship_saves.refresh("savelobby")
@@ -1234,7 +1407,14 @@
/// What a player could try to carry between rounds: an upgraded machine, a tank of plasma beside a tank of air.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/fit_source(obj/docking_port/mobile/voidcrew/port)
- var/list/fittings = list(/obj/machinery/autolathe, /obj/machinery/atmospherics/components/tank/plasma, /obj/machinery/atmospherics/components/tank/air)
+ var/list/fittings = list(
+ /obj/machinery/autolathe,
+ /obj/machinery/atmospherics/components/tank/plasma,
+ /obj/machinery/atmospherics/components/tank/air,
+ /obj/machinery/rnd/production/protolathe,
+ /obj/machinery/ship_combat/laser_turret,
+ /obj/machinery/rnd/server/ship,
+ )
for(var/turf/open/floor/tile in port.return_turfs())
if(!length(fittings))
break
@@ -1249,6 +1429,37 @@
fitting.component_parts -= old_bin
fitting.component_parts += GLOB.stock_part_datums[/datum/stock_part/matter_bin/tier4]
fitting.RefreshParts()
+ if(istype(fitting, /obj/machinery/rnd/production/protolathe))
+ var/datum/stock_part/old_bin = locate(/datum/stock_part/matter_bin) in fitting.component_parts
+ TEST_ASSERT_NOTNULL(old_bin, "A new protolathe has no matter bin to upgrade")
+ fitting.component_parts -= old_bin
+ fitting.component_parts += GLOB.stock_part_datums[/datum/stock_part/matter_bin/tier4]
+ fitting.RefreshParts()
+ if(istype(fitting, /obj/machinery/ship_combat/laser_turret))
+ var/datum/stock_part/old_laser = locate(/datum/stock_part/micro_laser) in fitting.component_parts
+ TEST_ASSERT_NOTNULL(old_laser, "A new laser turret has no micro laser to upgrade")
+ fitting.component_parts -= old_laser
+ fitting.component_parts += GLOB.stock_part_datums[/datum/stock_part/micro_laser/tier4]
+ var/obj/item/stock_parts/power_store/cell/old_cell = locate(/obj/item/stock_parts/power_store/cell) in fitting.component_parts
+ TEST_ASSERT_NOTNULL(old_cell, "A new laser turret has no cell to upgrade")
+ fitting.component_parts -= old_cell
+ qdel(old_cell)
+ fitting.component_parts += new /obj/item/stock_parts/power_store/cell/hyper(fitting)
+ fitting.RefreshParts()
+ if(istype(fitting, /obj/machinery/rnd/server/ship))
+ var/obj/machinery/rnd/server/ship/server = fitting
+ server.adopt_source_disk(new /obj/item/computer_disk/ship_disk(server))
+ var/datum/techweb/web = server.stored_research
+ for(var/node_id in SSresearch.techweb_nodes)
+ if(SSresearch.techweb_nodes_starting[node_id] || SSresearch.techweb_nodes_hidden[node_id])
+ continue
+ web.research_node_id(node_id, TRUE, FALSE, FALSE)
+ saved_nodes += node_id
+ if(length(saved_nodes) >= 5)
+ break
+ var/datum/experiment/physical/arcade_winner/finished = allocate(/datum/experiment/physical/arcade_winner)
+ web.available_experiments |= finished
+ web.complete_experiment(finished)
TEST_ASSERT(!length(fittings), "The test hull had no room for [length(fittings)] fittings")
/// As lose_original() does for the checkpoint tests: flies the source away and deletes it.
@@ -1268,10 +1479,13 @@
/// Everything a retrieved ship must be, however it got here.
/datum/unit_test/voidcrew_ship_saves_hull/retrieval/proc/check_retrieved(obj/structure/overmap/ship/ship, where)
+ var/servers = 0
+ var/protolathes = 0
+ var/turrets = 0
TEST_ASSERT_EQUAL(ship.name, saved_name, "[where]: the ship lost its name")
TEST_ASSERT_EQUAL(ship.source_template.short_name, saved_class, "[where]: the ship lost its class")
TEST_ASSERT_EQUAL(length(ship.job_definitions), saved_jobs, "[where]: the ship lost its roster")
- TEST_ASSERT_EQUAL(ship.ship_account.account_balance, 0, "[where]: the ship came with money")
+ TEST_ASSERT_EQUAL(ship.ship_account.account_balance, SHIP_SAVE_TEST_FUNDS_PER_PART * 3, "[where]: the ship didn't come with exactly the funds its save held")
TEST_ASSERT(length(ship.shuttle.spawn_points), "[where]: nobody can spawn on the ship")
var/lathes = 0
var/plasma_tanks = 0
@@ -1289,7 +1503,37 @@
for(var/obj/machinery/machine in tile)
if(istype(machine, /obj/machinery/autolathe))
lathes++
- TEST_ASSERT_NULL(locate(/datum/stock_part/matter_bin/tier4) in machine.component_parts, "[where]: an upgraded part came back")
+ TEST_ASSERT_NULL(locate(/datum/stock_part/matter_bin/tier4) in machine.component_parts, "[where]: an upgraded part came back on a machine off the price table")
+ if(istype(machine, /obj/machinery/rnd/production/protolathe))
+ protolathes++
+ TEST_ASSERT_NOTNULL(locate(/datum/stock_part/matter_bin/tier4) in machine.component_parts, "[where]: a priced machine lost its upgraded part")
+ var/obj/machinery/rnd/production/protolathe/protolathe = machine
+ TEST_ASSERT_NOTNULL(protolathe.stored_research, "[where]: the lab machine came back unlinked")
+ if(istype(machine, /obj/machinery/ship_combat/laser_turret))
+ turrets++
+ var/obj/machinery/ship_combat/laser_turret/turret = machine
+ TEST_ASSERT_NOTNULL(locate(/datum/stock_part/micro_laser/tier4) in turret.component_parts, "[where]: the turret lost its upgraded laser")
+ TEST_ASSERT(istype(turret.cell, /obj/item/stock_parts/power_store/cell/hyper), "[where]: the turret lost its upgraded cell")
+ var/obj/machinery/ship_combat/laser_turret/fresh_turret = allocate(/obj/machinery/ship_combat/laser_turret, run_loc_floor_bottom_left)
+ TEST_ASSERT_EQUAL(length(turret.component_parts), length(fresh_turret.component_parts), "[where]: the turret parts were added instead of swapped")
+ TEST_ASSERT(turret.cell.charge > 0, "[where]: the turret's cell came back flat")
+ if(istype(machine, /obj/machinery/rnd/server/ship))
+ servers++
+ var/obj/machinery/rnd/server/ship/server = machine
+ TEST_ASSERT_NOTNULL(server.source_code_hdd, "[where]: the server came back with no disk")
+ TEST_ASSERT_EQUAL(server.source_code_hdd.loc, server, "[where]: the disk isn't in the server")
+ var/datum/techweb/web = server.stored_research
+ TEST_ASSERT_NOTNULL(web, "[where]: the server came back with no research")
+ TEST_ASSERT_EQUAL(ship.find_research_web(), web, "[where]: the ship can't find its research")
+ for(var/node_id in saved_nodes)
+ TEST_ASSERT(web.researched_nodes[node_id], "[where]: the research lost [node_id]")
+ var/datum/experiment/done = web.completed_experiments[/datum/experiment/physical/arcade_winner]
+ TEST_ASSERT_NOTNULL(done, "[where]: the research lost a completed experiment")
+ TEST_ASSERT(!(done in web.available_experiments), "[where]: a completed experiment is on offer again")
+ // Already paid, for this crew, whichever disk reports it.
+ var/list/repaid = web.award_experiment_completion(done)
+ TEST_ASSERT(!length(repaid), "[where]: the experiment paid out again")
+ TEST_ASSERT(ship.get_crew_record().research_reward_ledger[/datum/experiment/physical/arcade_winner], "[where]: the crew can be paid for it through a new disk")
if(istype(machine, /obj/machinery/atmospherics/components/tank/plasma))
plasma_tanks++
var/obj/machinery/atmospherics/components/tank/plasma_tank = machine
@@ -1318,6 +1562,9 @@
var/obj/machinery/atmospherics/components/unary/shuttle/heater/heater = machine
TEST_ASSERT(heater.fuel_tank?.air_contents.total_moles() > 0, "[where]: an engine heater came back with no fuel")
TEST_ASSERT(lathes, "[where]: the upgraded autolathe did not come back")
+ TEST_ASSERT(protolathes, "[where]: the upgraded protolathe did not come back")
+ TEST_ASSERT(turrets, "[where]: the upgraded turret did not come back")
+ TEST_ASSERT(servers, "[where]: the research server did not come back")
TEST_ASSERT(plasma_tanks, "[where]: the plasma tank did not come back")
TEST_ASSERT(air_tanks, "[where]: the air tank did not come back")
TEST_ASSERT(stocked_vendors, "[where]: the hull's stocked vendor did not come back")
@@ -1661,6 +1908,9 @@
#undef SHIP_SAVE_TEST_SLOTS
#undef SHIP_SAVE_TEST_PRICE_MIN
#undef SHIP_SAVE_TEST_MASS_PER_PART
+#undef SHIP_SAVE_TEST_CREDITS_PER_PART
+#undef SHIP_SAVE_TEST_NODES_PER_PART
+#undef SHIP_SAVE_TEST_FUNDS_PER_PART
#undef SHIP_SAVE_TEST_PLAN_MAX
#undef SHIP_SAVE_TEST_PASSWORD_PRICE
#undef SHIP_SAVE_TEST_TOO_LONG
diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md
index 81af2bd0997..2d9fc92e1c4 100644
--- a/wiki/content/player-outposts.md
+++ b/wiki/content/player-outposts.md
@@ -169,9 +169,9 @@ The bay's own **shipyard console**, found only in the bay itself, has two tabs:
### Saved ships
-You have three save slots. Dock a ship you command in a ship bay, choose a slot and pay [ship parts](ship-parts.md) to save it there. The hull needs at least one cryopod. Saving to a slot that already holds a ship overwrites that save, and you pay again.
+You have three save slots. Dock a ship you command in a ship bay, choose a slot and pay with [ship parts](ship-parts.md) to save it there. The hull needs at least one cryopod. Saving to a slot that already holds a ship overwrites that save, and you pay again.
-The price depends on what is aboard, and the console lists it before you pay. Weapons and defenses cost combat parts, research and chemistry machines cost science parts, cargo and mining machines cost trade parts, and the hull costs misc parts. Every save costs at least 5 parts in total. You need enough of each class. Whatever you take off the ship before saving is not charged for, and is not in the save.
+The price depends on what is aboard, and the console lists it before you pay. Weapons and defenses cost combat parts, research and chemistry machines cost science parts, cargo and mining machines cost trade parts, and the hull costs misc parts. Every save costs at least 5 parts in total. Misc parts can be paid for in credits instead, at 2,500 credits each from the ship's account. Combat, science and trade parts have to be parts. Whatever you take off the ship before saving is not charged for, and is not in the save.
Get a saved ship back for free, either from **Saved Ships** in the lobby's join menu, which launches it with you aboard, or from the **Saved ships** tab of a shipyard console in any ship bay, where the delivery drones build it. Each save works once: using it uses it up and frees the slot.
@@ -180,13 +180,18 @@ You can load a save in the same round you made it once the ship it came from is
| Comes back | Stays behind |
|---|---|
| Hull and rooms | Everything the crew put aboard |
-| Machines, with standard parts | Upgraded parts |
-| Crew jobs | Credits |
+| Weapons, lab and cargo machines, with the parts you upgraded them with | Upgraded parts in every other machine, which comes back with standard parts |
+| Research from the R&D server aboard, with its disk | Research points not spent yet, and survey data |
+| Crew jobs | Credits, unless you carry them |
| Charged batteries and engine fuel | Statues and other fittings of precious materials |
| A fresh fill in emergency, fire, tool, engineering, medical and clothing lockers, EVA and work suit units, oxygen dispensers, extinguisher cabinets and everyday vendors | Security, armory and command lockers and suit units stay empty, and so does any plain locker |
Walls and floors of precious materials keep their look, but only give iron when taken apart.
+**Research.** A save keeps the nodes and finished experiments of the R&D server aboard, on a new disk in that server. It costs **1 science part per 20 nodes** on top of the machines. Take the disk out before saving if you don't want to pay for it. Finished experiments don't pay out a second time.
+
+**Funds.** The **Carry funds** buttons spend **1 trade part per 5,000 credits**. The credits leave the ship's account when you save and arrive in the new ship's account when the save is loaded. Overwriting a save loses whatever it holds.
+
## Advertising
A **broadcast** costs **500 credits from the outpost bank** and lasts 20 minutes, with a 10-minute purchase cooldown. Buy it through Outpost Management with treasury permission. It announces the outpost to crewed ships and marks it on helm charts. A refused purchase says why in chat.
diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md
index 3e7e2de1300..76176766ae6 100644
--- a/wiki/content/ship-parts.md
+++ b/wiki/content/ship-parts.md
@@ -81,6 +81,6 @@ Open the **Shipyard** from the join menu to see your balance and the prices of h
Unlocks are permanent. Buying a theme or module lets you select it for future launches of that hull without paying again, and losing a ship does not erase your purchases. See [Buying & Upgrading Ships](ship-upgrades.md) for choosing a loadout and [Ship Catalog](ship-catalog.md) for the available hulls.
-[Saving a ship](player-outposts.md) in a ship bay also costs parts, in the classes that match what is aboard: combat parts for weapons, science parts for research and chemistry machines, trade parts for cargo and mining machines, and misc parts for the hull. Every save costs at least 5 parts in total.
+[Saving a ship](player-outposts.md) in a ship bay also costs parts, in the classes that match what is aboard: combat parts for weapons, science parts for research and chemistry machines, trade parts for cargo and mining machines, and misc parts for the hull. Every save costs at least 5 parts in total, and misc parts can be paid for at 2,500 credits each. Those parts also carry the ship's progress: upgraded parts in weapons, lab and cargo machines, research at 1 science part per 20 nodes, and funds at 1 trade part per 5,000 credits.
A ship's join password can be paid for with parts of any class. Setting or changing it costs **3 parts**; clearing it is free.
From 35e67d6a9c79d4902c1848794b05b71f2f16f6bb Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Sat, 7 Nov 2026 20:27:39 -0800
Subject: [PATCH 19/72] Add xenoarchaeology module layout and shared
definitions
Defines for bands, effect tiers, triggers, energy kinds, pricing, research and dig sites;
the artifact and effect types with the shared vars later parts read; the sale value and
research point functions; the planet dig reserve and the specimen crate; and empty files
for the dig, study, use, trade and Polaris effect parts and their tests.
---
tgstation.dme | 51 +++++
voidcrew/_DEFINES/xenoarch.dm | 201 ++++++++++++++++++
voidcrew/modules/unit_tests/_unit_tests.dm | 6 +
.../unit_tests/voidcrew_xenoarch_core.dm | 1 +
.../unit_tests/voidcrew_xenoarch_dig.dm | 1 +
.../voidcrew_xenoarch_effects_polaris.dm | 1 +
.../unit_tests/voidcrew_xenoarch_study.dm | 1 +
.../unit_tests/voidcrew_xenoarch_trade.dm | 1 +
.../unit_tests/voidcrew_xenoarch_use.dm | 1 +
voidcrew/modules/xenoarch/core/artifact.dm | 77 +++++++
.../modules/xenoarch/core/artifact_effect.dm | 25 +++
.../modules/xenoarch/core/artifact_helpers.dm | 114 ++++++++++
.../modules/xenoarch/core/artifact_label.dm | 25 +++
.../modules/xenoarch/core/artifact_pricing.dm | 58 +++++
voidcrew/modules/xenoarch/dig/ambush.dm | 1 +
voidcrew/modules/xenoarch/dig/boulder.dm | 1 +
voidcrew/modules/xenoarch/dig/curios.dm | 1 +
voidcrew/modules/xenoarch/dig/designs.dm | 1 +
voidcrew/modules/xenoarch/dig/dig_reserve.dm | 51 +++++
voidcrew/modules/xenoarch/dig/dig_sites.dm | 1 +
voidcrew/modules/xenoarch/dig/finds.dm | 9 +
voidcrew/modules/xenoarch/dig/site_walls.dm | 1 +
voidcrew/modules/xenoarch/dig/spectrometer.dm | 1 +
voidcrew/modules/xenoarch/dig/stabiliser.dm | 1 +
voidcrew/modules/xenoarch/dig/strange_rock.dm | 1 +
voidcrew/modules/xenoarch/dig/tools.dm | 1 +
.../xenoarch/effects_polaris/animate.dm | 1 +
.../xenoarch/effects_polaris/berserk.dm | 1 +
.../xenoarch/effects_polaris/disarm.dm | 1 +
.../effects_polaris/electric_field.dm | 1 +
.../xenoarch/effects_polaris/extinguisher.dm | 1 +
.../xenoarch/effects_polaris/faraday.dm | 1 +
.../xenoarch/effects_polaris/feysight.dm | 1 +
.../xenoarch/effects_polaris/forcefield.dm | 1 +
.../xenoarch/effects_polaris/fortify.dm | 1 +
.../modules/xenoarch/effects_polaris/gaia.dm | 1 +
.../xenoarch/effects_polaris/gravity_wave.dm | 1 +
.../xenoarch/effects_polaris/life_drain.dm | 1 +
.../xenoarch/effects_polaris/life_steal.dm | 1 +
.../xenoarch/effects_polaris/plasma_vent.dm | 1 +
.../xenoarch/effects_polaris/poltergeist.dm | 1 +
.../xenoarch/effects_polaris/resurrect.dm | 1 +
.../xenoarch/effects_polaris/sleeping_gas.dm | 1 +
.../xenoarch/effects_polaris/spines.dm | 1 +
.../xenoarch/effects_polaris/sprint.dm | 1 +
.../effects_polaris/status_effects.dm | 1 +
.../modules/xenoarch/effects_polaris/sweat.dm | 1 +
voidcrew/modules/xenoarch/study/analyser.dm | 1 +
voidcrew/modules/xenoarch/study/labeler.dm | 1 +
voidcrew/modules/xenoarch/study/research.dm | 1 +
voidcrew/modules/xenoarch/study/scanpad.dm | 1 +
voidcrew/modules/xenoarch/study/techweb.dm | 1 +
voidcrew/modules/xenoarch/trade/buyers.dm | 1 +
voidcrew/modules/xenoarch/trade/checkpoint.dm | 1 +
voidcrew/modules/xenoarch/trade/metrics.dm | 1 +
.../modules/xenoarch/trade/specimen_crate.dm | 23 ++
voidcrew/modules/xenoarch/use/battery.dm | 1 +
voidcrew/modules/xenoarch/use/harvester.dm | 1 +
voidcrew/modules/xenoarch/use/utilizer.dm | 1 +
59 files changed, 688 insertions(+)
create mode 100644 voidcrew/_DEFINES/xenoarch.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm
create mode 100644 voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm
create mode 100644 voidcrew/modules/xenoarch/core/artifact.dm
create mode 100644 voidcrew/modules/xenoarch/core/artifact_effect.dm
create mode 100644 voidcrew/modules/xenoarch/core/artifact_helpers.dm
create mode 100644 voidcrew/modules/xenoarch/core/artifact_label.dm
create mode 100644 voidcrew/modules/xenoarch/core/artifact_pricing.dm
create mode 100644 voidcrew/modules/xenoarch/dig/ambush.dm
create mode 100644 voidcrew/modules/xenoarch/dig/boulder.dm
create mode 100644 voidcrew/modules/xenoarch/dig/curios.dm
create mode 100644 voidcrew/modules/xenoarch/dig/designs.dm
create mode 100644 voidcrew/modules/xenoarch/dig/dig_reserve.dm
create mode 100644 voidcrew/modules/xenoarch/dig/dig_sites.dm
create mode 100644 voidcrew/modules/xenoarch/dig/finds.dm
create mode 100644 voidcrew/modules/xenoarch/dig/site_walls.dm
create mode 100644 voidcrew/modules/xenoarch/dig/spectrometer.dm
create mode 100644 voidcrew/modules/xenoarch/dig/stabiliser.dm
create mode 100644 voidcrew/modules/xenoarch/dig/strange_rock.dm
create mode 100644 voidcrew/modules/xenoarch/dig/tools.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/animate.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/berserk.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/disarm.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/electric_field.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/faraday.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/feysight.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/forcefield.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/fortify.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/gaia.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/life_drain.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/life_steal.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/resurrect.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/spines.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/sprint.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/status_effects.dm
create mode 100644 voidcrew/modules/xenoarch/effects_polaris/sweat.dm
create mode 100644 voidcrew/modules/xenoarch/study/analyser.dm
create mode 100644 voidcrew/modules/xenoarch/study/labeler.dm
create mode 100644 voidcrew/modules/xenoarch/study/research.dm
create mode 100644 voidcrew/modules/xenoarch/study/scanpad.dm
create mode 100644 voidcrew/modules/xenoarch/study/techweb.dm
create mode 100644 voidcrew/modules/xenoarch/trade/buyers.dm
create mode 100644 voidcrew/modules/xenoarch/trade/checkpoint.dm
create mode 100644 voidcrew/modules/xenoarch/trade/metrics.dm
create mode 100644 voidcrew/modules/xenoarch/trade/specimen_crate.dm
create mode 100644 voidcrew/modules/xenoarch/use/battery.dm
create mode 100644 voidcrew/modules/xenoarch/use/harvester.dm
create mode 100644 voidcrew/modules/xenoarch/use/utilizer.dm
diff --git a/tgstation.dme b/tgstation.dme
index 1938407f825..beeb8a121f1 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -6742,6 +6742,7 @@
#include "voidcrew\_DEFINES\subsystems.dm"
#include "voidcrew\_DEFINES\techweb_nodes.dm"
#include "voidcrew\_DEFINES\trade.dm"
+#include "voidcrew\_DEFINES\xenoarch.dm"
#include "voidcrew\_HELPERS\data_huds.dm"
#include "voidcrew\_HELPERS\debug_atmos.dm"
#include "voidcrew\_HELPERS\docking.dm"
@@ -7730,6 +7731,56 @@
#include "voidcrew\modules\voice_barks\voice_screen.dm"
#include "voidcrew\modules\weapons_bench\ammunition.dm"
#include "voidcrew\modules\worldgen_logging\worldgen.dm"
+#include "voidcrew\modules\xenoarch\core\artifact.dm"
+#include "voidcrew\modules\xenoarch\core\artifact_effect.dm"
+#include "voidcrew\modules\xenoarch\core\artifact_helpers.dm"
+#include "voidcrew\modules\xenoarch\core\artifact_label.dm"
+#include "voidcrew\modules\xenoarch\core\artifact_pricing.dm"
+#include "voidcrew\modules\xenoarch\dig\ambush.dm"
+#include "voidcrew\modules\xenoarch\dig\boulder.dm"
+#include "voidcrew\modules\xenoarch\dig\curios.dm"
+#include "voidcrew\modules\xenoarch\dig\designs.dm"
+#include "voidcrew\modules\xenoarch\dig\dig_reserve.dm"
+#include "voidcrew\modules\xenoarch\dig\dig_sites.dm"
+#include "voidcrew\modules\xenoarch\dig\finds.dm"
+#include "voidcrew\modules\xenoarch\dig\site_walls.dm"
+#include "voidcrew\modules\xenoarch\dig\spectrometer.dm"
+#include "voidcrew\modules\xenoarch\dig\stabiliser.dm"
+#include "voidcrew\modules\xenoarch\dig\strange_rock.dm"
+#include "voidcrew\modules\xenoarch\dig\tools.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\animate.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\berserk.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\disarm.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\electric_field.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\extinguisher.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\faraday.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\feysight.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\forcefield.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\fortify.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\gaia.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\gravity_wave.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\life_drain.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\life_steal.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\plasma_vent.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\poltergeist.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\resurrect.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\sleeping_gas.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\spines.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\sprint.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\status_effects.dm"
+#include "voidcrew\modules\xenoarch\effects_polaris\sweat.dm"
+#include "voidcrew\modules\xenoarch\study\analyser.dm"
+#include "voidcrew\modules\xenoarch\study\labeler.dm"
+#include "voidcrew\modules\xenoarch\study\research.dm"
+#include "voidcrew\modules\xenoarch\study\scanpad.dm"
+#include "voidcrew\modules\xenoarch\study\techweb.dm"
+#include "voidcrew\modules\xenoarch\trade\buyers.dm"
+#include "voidcrew\modules\xenoarch\trade\checkpoint.dm"
+#include "voidcrew\modules\xenoarch\trade\metrics.dm"
+#include "voidcrew\modules\xenoarch\trade\specimen_crate.dm"
+#include "voidcrew\modules\xenoarch\use\battery.dm"
+#include "voidcrew\modules\xenoarch\use\harvester.dm"
+#include "voidcrew\modules\xenoarch\use\utilizer.dm"
#include "voidcrew\objects\items\blueprints.dm"
#include "voidcrew\objects\items\cards_ids.dm"
#include "voidcrew\objects\items\trash.dm"
diff --git a/voidcrew/_DEFINES/xenoarch.dm b/voidcrew/_DEFINES/xenoarch.dm
new file mode 100644
index 00000000000..0981e3337d9
--- /dev/null
+++ b/voidcrew/_DEFINES/xenoarch.dm
@@ -0,0 +1,201 @@
+// Xenoarchaeology: shared defines for every part of voidcrew/modules/xenoarch/.
+// Each section names the part that owns its numbers. Parts may add part-local defines at
+// the top of their own files; anything two parts read belongs here.
+//
+// Unit tests compile before this file and cannot see these defines (see the test files for
+// the literal values they mirror).
+
+// ===== Bands (shared) =====
+// A find's band is the ZONE_* it was dug in (voidcrew/_DEFINES/overmap_zones.dm), stamped on
+// the find. Price and research read the stamp, never where the find is now. A null stamp
+// means "not dug": buyers refuse it.
+
+/// Positional lists below are indexed by ZONE_GREEN (1), ZONE_YELLOW (2), ZONE_RED (3).
+#define XENOARCH_BAND_INDEX(band) clamp(band || ZONE_GREEN, ZONE_GREEN, ZONE_RED)
+
+// ===== Effect tiers (core) =====
+// Numeric so they index the positional lists below. Never use them as assoc keys.
+#define ARTIFACT_TIER_COMMON 1
+#define ARTIFACT_TIER_UNCOMMON 2
+#define ARTIFACT_TIER_RARE 3
+#define ARTIFACT_TIER_EXTREME 4
+
+/// Main-effect tier odds for a large artifact, per band: common, uncommon, rare, extreme.
+#define ARTIFACT_TIER_ODDS_GREEN list(62, 28, 9, 1)
+#define ARTIFACT_TIER_ODDS_YELLOW list(45, 33, 18, 4)
+#define ARTIFACT_TIER_ODDS_RED list(30, 34, 27, 9)
+
+/// Chance a large artifact also carries a minor second effect.
+#define ARTIFACT_SECOND_EFFECT_CHANCE 50
+
+// ===== Effect strength (core) =====
+#define ARTIFACT_STRENGTH_SMALL "small"
+#define ARTIFACT_STRENGTH_MEDIUM "medium"
+#define ARTIFACT_STRENGTH_LARGE "large"
+/// Strength weights per band (small, medium, large): green leans small, red leans large.
+#define ARTIFACT_STRENGTH_ODDS_GREEN list(60, 30, 10)
+#define ARTIFACT_STRENGTH_ODDS_YELLOW list(35, 45, 20)
+#define ARTIFACT_STRENGTH_ODDS_RED list(15, 45, 40)
+
+// ===== Release modes (core) =====
+// What the analyser reports as "contact", "field" or "bursts".
+#define ARTIFACT_RELEASE_TOUCH "touch"
+#define ARTIFACT_RELEASE_AURA "aura"
+#define ARTIFACT_RELEASE_PULSE "pulse"
+#define ARTIFACT_ALL_RELEASE_METHODS list(ARTIFACT_RELEASE_TOUCH, ARTIFACT_RELEASE_AURA, ARTIFACT_RELEASE_PULSE)
+
+// ===== Charge (core) =====
+// Per second. Auras spend three times what the artifact refills, so a bolted aura runs about
+// a third of the time. A pulse fires when the store is full and empties it.
+#define ARTIFACT_RECHARGE_PER_SECOND 0.5
+#define ARTIFACT_AURA_COST_PER_SECOND 1.5
+#define ARTIFACT_TOUCH_COST 3
+
+// ===== Triggers (core) =====
+// Bitflags so an effect can hold one and a check can test several. Oxygen and nitrogen
+// triggers from Nova are deliberately gone: ship air would keep those artifacts always on.
+#define ARTIFACT_TRIGGER_TOUCH (1<<0)
+#define ARTIFACT_TRIGGER_WATER (1<<1)
+#define ARTIFACT_TRIGGER_ACID (1<<2)
+#define ARTIFACT_TRIGGER_VOLATILE (1<<3)
+#define ARTIFACT_TRIGGER_TOXIN (1<<4)
+#define ARTIFACT_TRIGGER_IMPACT (1<<5)
+#define ARTIFACT_TRIGGER_ENERGY (1<<6)
+#define ARTIFACT_TRIGGER_HEAT (1<<7)
+#define ARTIFACT_TRIGGER_COLD (1<<8)
+#define ARTIFACT_TRIGGER_PLASMA (1<<9)
+#define ARTIFACT_TRIGGER_CO2 (1<<10)
+#define ARTIFACT_TRIGGER_PROXIMITY (1<<11)
+
+/// Triggers read from the air on the artifact's tile every process.
+#define ARTIFACT_TRIGGERS_AIR (ARTIFACT_TRIGGER_HEAT | ARTIFACT_TRIGGER_COLD | ARTIFACT_TRIGGER_PLASMA | ARTIFACT_TRIGGER_CO2)
+/// Triggers held for as long as a condition lasts, rather than woken by a stimulus.
+#define ARTIFACT_TRIGGERS_LEVEL (ARTIFACT_TRIGGERS_AIR | ARTIFACT_TRIGGER_PROXIMITY)
+
+/// Every trigger an effect may roll. Weights are in artifact_trigger_weight().
+#define ARTIFACT_ALL_TRIGGERS list(\
+ ARTIFACT_TRIGGER_TOUCH,\
+ ARTIFACT_TRIGGER_WATER,\
+ ARTIFACT_TRIGGER_ACID,\
+ ARTIFACT_TRIGGER_VOLATILE,\
+ ARTIFACT_TRIGGER_TOXIN,\
+ ARTIFACT_TRIGGER_IMPACT,\
+ ARTIFACT_TRIGGER_ENERGY,\
+ ARTIFACT_TRIGGER_HEAT,\
+ ARTIFACT_TRIGGER_COLD,\
+ ARTIFACT_TRIGGER_PLASMA,\
+ ARTIFACT_TRIGGER_CO2,\
+ ARTIFACT_TRIGGER_PROXIMITY,\
+)
+
+// Trigger thresholds (Nova's values).
+#define ARTIFACT_HEAT_AIR_KELVIN 375
+#define ARTIFACT_HEAT_TOOL_KELVIN 700
+#define ARTIFACT_COLD_AIR_KELVIN 225
+#define ARTIFACT_GAS_TRIGGER_MOLES 10
+#define ARTIFACT_PROXIMITY_RANGE 3
+#define ARTIFACT_IMPACT_THROWFORCE 10
+
+// Trigger families, as the analyser reports them.
+#define ARTIFACT_FAMILY_CONTACT "contact"
+#define ARTIFACT_FAMILY_ENERGY "energy"
+#define ARTIFACT_FAMILY_AIR "air"
+
+// ===== Waking (core) =====
+/// How long a contact stimulus keeps an effect awake. Each artifact rolls once in this range;
+/// another stimulus while awake starts the count again.
+#define ARTIFACT_WAKE_MIN (45 SECONDS)
+#define ARTIFACT_WAKE_MAX (90 SECONDS)
+/// How long a level trigger keeps an effect awake after its condition ends.
+#define ARTIFACT_LEVEL_LINGER (6 SECONDS)
+/// Hauling, bumping and repeated pats count as one touch per this long.
+#define ARTIFACT_TOUCH_COOLDOWN (3 SECONDS)
+
+// ===== Energy kinds (core) =====
+// Nova's seven type_name values. Several effects share each kind, so the analyser narrows
+// an artifact down without naming its effect. Keys index GLOB.artifact_energy_kinds.
+#define ARTIFACT_ENERGY_CONCENTRATED "concentrated"
+#define ARTIFACT_ENERGY_PSIONIC "psionic"
+#define ARTIFACT_ENERGY_ELECTROMAGNETIC "electromagnetic"
+#define ARTIFACT_ENERGY_PARTICLE "particle"
+#define ARTIFACT_ENERGY_ORGANIC "organic"
+#define ARTIFACT_ENERGY_BLUESPACE "bluespace"
+#define ARTIFACT_ENERGY_SYNTHESIS "synthesis"
+
+// ===== Labels (core vocabulary; the labeler is P3) =====
+/// The second-effect field when the labeller thinks there is none. A correct "None" counts.
+#define ARTIFACT_LABEL_NONE "None"
+
+// ===== Scan and harvest lock (core API; analyser P3, harvester P4) =====
+/// A lock nobody released frees itself after this long.
+#define ARTIFACT_LOCK_TIMEOUT (2 MINUTES)
+
+// ===== Harvesting (P4 owns the machines) =====
+#define ARTIFACT_MAX_HARVESTS 3
+
+// ===== Pricing (core function; buyers are P5) =====
+/// Base price of a large artifact by stamped band (green, yellow, red).
+#define ARTIFACT_PRICE_BASE list(250, 450, 700)
+/// Main-effect tier multiplier (common, uncommon, rare, extreme).
+#define ARTIFACT_PRICE_TIER_MULT list(1, 1.5, 2.5, 4)
+/// Label accuracy shares. A label pays its share at or above the floor, else the unlabelled rate.
+#define ARTIFACT_LABEL_SHARE_EFFECT 0.5
+#define ARTIFACT_LABEL_SHARE_TRIGGER 0.3
+#define ARTIFACT_LABEL_SHARE_SECOND 0.2
+#define ARTIFACT_LABEL_ACCURACY_FLOOR 0.5
+#define ARTIFACT_UNLABELLED_SHARE 0.2
+#define ARTIFACT_DAMAGED_MULT 0.5
+#define ARTIFACT_HARVEST_MULT 0.8
+/// Receipts round to this many credits.
+#define ARTIFACT_PRICE_ROUNDING 5
+
+// ===== Research (core function; payout is P3) =====
+/// First full analysis of a large artifact, by stamped band (green, yellow, red).
+#define ARTIFACT_RESEARCH_POINTS list(100, 150, 250)
+/// Multiplier for a rare or extreme main effect.
+#define ARTIFACT_RESEARCH_RARE_MULT 1.5
+
+// ===== Dig sites (P2) =====
+#define XENOARCH_SITES_PER_PLANET 3
+/// Large and small finds per site, by band (green, yellow, red).
+#define XENOARCH_LARGE_PER_SITE list(1, 1, 2)
+#define XENOARCH_SMALL_PER_SITE list(3, 3, 2)
+/// Small-find tier odds per band: tier 1, 2, 3.
+#define XENOARCH_SMALL_TIER_ODDS_GREEN list(60, 32, 8)
+#define XENOARCH_SMALL_TIER_ODDS_YELLOW list(50, 37, 13)
+#define XENOARCH_SMALL_TIER_ODDS_RED list(40, 40, 20)
+/// Chance that uncovering a large find wakes local fauna, by band.
+#define XENOARCH_AMBUSH_CHANCE list(0, 25, 45)
+/// Minimum tiles between two dig sites.
+#define XENOARCH_SITE_SPACING 20
+
+// ===== Signals =====
+// All sent on the artifact (/obj/machinery/artifact) unless noted.
+
+/// An effect woke: (datum/artifact_effect/effect)
+#define COMSIG_ARTIFACT_EFFECT_ACTIVATED "artifact_effect_activated"
+/// An effect went dormant: (datum/artifact_effect/effect)
+#define COMSIG_ARTIFACT_EFFECT_DEACTIVATED "artifact_effect_deactivated"
+/// A stimulus reached the artifact, whether or not anything woke: (trigger, mob/living/user, woke)
+#define COMSIG_ARTIFACT_STIMULATED "artifact_stimulated"
+/// A label was stuck on or scraped off: (datum/artifact_label/label_or_null, mob/user)
+#define COMSIG_ARTIFACT_LABEL_CHANGED "artifact_label_changed"
+/// The artifact was stamped as dug: (band)
+#define COMSIG_ARTIFACT_STAMPED "artifact_stamped"
+/// One harvest was taken from it (P4 sends): (datum/artifact_effect/effect, mob/user)
+#define COMSIG_ARTIFACT_HARVESTED "artifact_harvested"
+/// It was sold (P5 sends, on the artifact before it is deleted): (credits, vouchers, mob/seller)
+#define COMSIG_ARTIFACT_SOLD "artifact_sold"
+
+// ===== Metrics (record_metric events) =====
+// Category in brackets. Event names must never equal a record_metric() parameter name.
+/// [METRIC_RESEARCH] a find came out of the ground. subject = find type, zone = band.
+#define XENOARCH_METRIC_DUG "artifact_dug"
+/// [METRIC_RESEARCH] a find crumbled or came out unstabilised.
+#define XENOARCH_METRIC_LOST "artifact_lost"
+/// [METRIC_RESEARCH] first full analysis paid. points = research points.
+#define XENOARCH_METRIC_ANALYSED "artifact_analysed"
+/// [METRIC_RESEARCH] an effect was drained into a battery.
+#define XENOARCH_METRIC_HARVESTED "artifact_harvested"
+/// [METRIC_TRADE] sold at an outpost. credits, vouchers; details: accuracy, tier, band, harvests, damaged.
+#define XENOARCH_METRIC_SOLD "artifact_sold"
diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm
index cb517499a34..2a4cc01d253 100644
--- a/voidcrew/modules/unit_tests/_unit_tests.dm
+++ b/voidcrew/modules/unit_tests/_unit_tests.dm
@@ -246,6 +246,12 @@
#include "voidcrew_weapon_ladder.dm"
#include "voidcrew_weather_sites.dm"
#include "voidcrew_windoor_survey.dm"
+#include "voidcrew_xenoarch_core.dm"
+#include "voidcrew_xenoarch_dig.dm"
+#include "voidcrew_xenoarch_effects_polaris.dm"
+#include "voidcrew_xenoarch_study.dm"
+#include "voidcrew_xenoarch_trade.dm"
+#include "voidcrew_xenoarch_use.dm"
#include "voidcrew_zone_identity.dm"
#include "voidcrew_zone_logging.dm"
#include "voidcrew_zone_missions.dm"
diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm
new file mode 100644
index 00000000000..321a3bff218
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm
@@ -0,0 +1 @@
+// Xenoarchaeology tests: core.
diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm
new file mode 100644
index 00000000000..a2e008fae10
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm
@@ -0,0 +1 @@
+// Xenoarchaeology tests: dig.
diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm
new file mode 100644
index 00000000000..0042d09ba8f
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm
@@ -0,0 +1 @@
+// Xenoarchaeology tests: effects_polaris.
diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm
new file mode 100644
index 00000000000..c4fdeca83f3
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm
@@ -0,0 +1 @@
+// Xenoarchaeology tests: study.
diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm
new file mode 100644
index 00000000000..46dc0d06d1f
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm
@@ -0,0 +1 @@
+// Xenoarchaeology tests: trade.
diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm
new file mode 100644
index 00000000000..33c4631511f
--- /dev/null
+++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm
@@ -0,0 +1 @@
+// Xenoarchaeology tests: use.
diff --git a/voidcrew/modules/xenoarch/core/artifact.dm b/voidcrew/modules/xenoarch/core/artifact.dm
new file mode 100644
index 00000000000..c609d116946
--- /dev/null
+++ b/voidcrew/modules/xenoarch/core/artifact.dm
@@ -0,0 +1,77 @@
+/**
+ * # Large alien artifact
+ *
+ * A dense machine dug out of a planet's old dig sites. It carries a main effect and sometimes
+ * a minor second one, each with its own trigger.
+ */
+/obj/machinery/artifact
+ name = "alien artifact"
+ desc = "A large object of no make you recognise."
+ density = TRUE
+ anchored = FALSE
+ use_power = NO_POWER_USE
+ max_integrity = 1000
+ resistance_flags = LAVA_PROOF | FIRE_PROOF | ACID_PROOF
+
+ /// ZONE_* it was dug in, or null if it never came out of the ground. Price and research read this.
+ var/origin_band
+ /// Catalogue number, such as "Kappa-4412"
+ var/serial
+ /// The label a crew stuck on it, if any
+ var/datum/artifact_label/label
+ /// Harvests a harvester may still drain from it
+ var/harvests_left = ARTIFACT_MAX_HARVESTS
+ /// Its first full analysis has paid research points. Stays with the artifact whichever crew holds it.
+ var/research_paid = FALSE
+ /// Came out of the rock unstabilised. Sells for less.
+ var/damaged = FALSE
+ /// The effect it is named and priced for
+ var/datum/artifact_effect/main_effect
+ /// A minor second effect, or null
+ var/datum/artifact_effect/second_effect
+
+/// Whether it came out of a dig site. Buyers refuse anything else.
+/obj/machinery/artifact/proc/is_dug()
+ return !isnull(origin_band)
+
+/// How many harvests have been drained from it.
+/obj/machinery/artifact/proc/harvests_taken()
+ return clamp(ARTIFACT_MAX_HARVESTS - harvests_left, 0, ARTIFACT_MAX_HARVESTS)
+
+/// 0 to 1: how right its label is. 0 with no label.
+/obj/machinery/artifact/proc/label_accuracy()
+ if(!label || !main_effect)
+ return 0
+ . = 0
+ if(label.effect_name == main_effect.label_name)
+ . += ARTIFACT_LABEL_SHARE_EFFECT
+ if(label.trigger_name == artifact_trigger_name(main_effect.trigger))
+ . += ARTIFACT_LABEL_SHARE_TRIGGER
+ var/second_truth = second_effect ? second_effect.label_name : ARTIFACT_LABEL_NONE
+ if(label.second_name == second_truth)
+ . += ARTIFACT_LABEL_SHARE_SECOND
+
+/// Sticks a new label on, replacing any old one.
+/obj/machinery/artifact/proc/apply_label(datum/artifact_label/new_label, mob/user)
+ if(label && label != new_label)
+ QDEL_NULL(label)
+ label = new_label
+ SEND_SIGNAL(src, COMSIG_ARTIFACT_LABEL_CHANGED, new_label, user)
+ update_appearance()
+
+/// Scrapes the label off.
+/obj/machinery/artifact/proc/remove_label(mob/user)
+ if(!label)
+ return FALSE
+ QDEL_NULL(label)
+ SEND_SIGNAL(src, COMSIG_ARTIFACT_LABEL_CHANGED, null, user)
+ update_appearance()
+ return TRUE
+
+/// Marks it as dug in `band`. Only the first stamp counts.
+/obj/machinery/artifact/proc/stamp_origin(band)
+ if(!isnull(origin_band))
+ return FALSE
+ origin_band = XENOARCH_BAND_INDEX(band)
+ SEND_SIGNAL(src, COMSIG_ARTIFACT_STAMPED, origin_band)
+ return TRUE
diff --git a/voidcrew/modules/xenoarch/core/artifact_effect.dm b/voidcrew/modules/xenoarch/core/artifact_effect.dm
new file mode 100644
index 00000000000..d838ca53fa6
--- /dev/null
+++ b/voidcrew/modules/xenoarch/core/artifact_effect.dm
@@ -0,0 +1,25 @@
+/**
+ * # Artifact effect
+ *
+ * What an artifact does. A rollable effect is any subtype that sets label_name and tier;
+ * parents that only share code leave label_name null and never roll.
+ */
+/datum/artifact_effect
+ /// The plain words a labeler offers for it, such as "Heals". Null on abstract parents.
+ var/label_name
+ /// Name for logs and admins
+ var/log_name = "unknown"
+ /// ARTIFACT_TIER_*
+ var/tier = ARTIFACT_TIER_COMMON
+ /// A harvester can drain it into a battery
+ var/harvestable = TRUE
+ /// Can roll as an artifact's second effect (common and uncommon effects only)
+ var/minor = FALSE
+ /// The trigger that wakes it (one ARTIFACT_TRIGGER_* flag)
+ var/trigger = ARTIFACT_TRIGGER_TOUCH
+ /// ARTIFACT_RELEASE_*
+ var/release_method = ARTIFACT_RELEASE_TOUCH
+ /// ARTIFACT_ENERGY_*: what the analyser reads off it
+ var/energy_kind = ARTIFACT_ENERGY_CONCENTRATED
+ /// Whatever holds it: an artifact, or a battery once harvested
+ var/atom/holder
diff --git a/voidcrew/modules/xenoarch/core/artifact_helpers.dm b/voidcrew/modules/xenoarch/core/artifact_helpers.dm
new file mode 100644
index 00000000000..3a1308b1d81
--- /dev/null
+++ b/voidcrew/modules/xenoarch/core/artifact_helpers.dm
@@ -0,0 +1,114 @@
+/**
+ * # Xenoarchaeology helpers
+ *
+ * Small shared procs: bands, serials, the trigger vocabulary and weighted picks.
+ */
+
+/// The ZONE_* a turf belongs to, for stamping a find. Falls back to green, the lowest price,
+/// so a failed lookup can never inflate a payout.
+/proc/xenoarch_band_for_turf(turf/location)
+ location = get_turf(location)
+ if(!location)
+ return ZONE_GREEN
+ var/zone_type = SSovermap_zones.get_zone_type_anywhere(location)
+ if(zone_type == ZONE_YELLOW || zone_type == ZONE_RED)
+ return zone_type
+ return ZONE_GREEN
+
+/// "green", "yellow" or "red", for metrics and logs.
+/proc/xenoarch_band_name(band)
+ switch(band)
+ if(ZONE_YELLOW)
+ return "yellow"
+ if(ZONE_RED)
+ return "red"
+ return "green"
+
+/// Every serial handed out this round, so no two finds share one.
+GLOBAL_LIST_EMPTY(artifact_serials_used)
+
+/// A catalogue number such as "Kappa-4412". Unique for the round.
+/proc/xenoarch_new_serial()
+ var/static/list/letters = list(
+ "Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta", "Theta",
+ "Iota", "Kappa", "Lambda", "Mu", "Nu", "Xi", "Omicron", "Pi",
+ "Rho", "Sigma", "Tau", "Upsilon", "Phi", "Chi", "Psi", "Omega",
+ )
+ for(var/attempt in 1 to 50)
+ var/serial = "[pick(letters)]-[rand(1000, 9999)]"
+ if(!GLOB.artifact_serials_used[serial])
+ GLOB.artifact_serials_used[serial] = TRUE
+ return serial
+ // 24 letters x 9000 numbers; fifty misses means the round has dug an absurd amount.
+ var/fallback = "Omega-[length(GLOB.artifact_serials_used) + 10000]"
+ GLOB.artifact_serials_used[fallback] = TRUE
+ return fallback
+
+/// The plain word for a trigger, as the labeler lists it and a label records it.
+/proc/artifact_trigger_name(trigger)
+ switch(trigger)
+ if(ARTIFACT_TRIGGER_TOUCH)
+ return "Touch"
+ if(ARTIFACT_TRIGGER_WATER)
+ return "Water"
+ if(ARTIFACT_TRIGGER_ACID)
+ return "Acid"
+ if(ARTIFACT_TRIGGER_VOLATILE)
+ return "Volatile chemicals"
+ if(ARTIFACT_TRIGGER_TOXIN)
+ return "Toxins"
+ if(ARTIFACT_TRIGGER_IMPACT)
+ return "Impact"
+ if(ARTIFACT_TRIGGER_ENERGY)
+ return "Electricity"
+ if(ARTIFACT_TRIGGER_HEAT)
+ return "Heat"
+ if(ARTIFACT_TRIGGER_COLD)
+ return "Cold"
+ if(ARTIFACT_TRIGGER_PLASMA)
+ return "Plasma"
+ if(ARTIFACT_TRIGGER_CO2)
+ return "Carbon dioxide"
+ if(ARTIFACT_TRIGGER_PROXIMITY)
+ return "Proximity"
+ return "Unknown"
+
+/// Every trigger word, in the labeler's order.
+/proc/artifact_trigger_label_names()
+ var/static/list/names
+ if(!names)
+ names = list()
+ for(var/trigger in ARTIFACT_ALL_TRIGGERS)
+ names += artifact_trigger_name(trigger)
+ return names.Copy()
+
+/// Which family the analyser reports a trigger under.
+/proc/artifact_trigger_family(trigger)
+ if(trigger & (ARTIFACT_TRIGGER_TOUCH | ARTIFACT_TRIGGER_WATER | ARTIFACT_TRIGGER_ACID | ARTIFACT_TRIGGER_VOLATILE | ARTIFACT_TRIGGER_TOXIN))
+ return ARTIFACT_FAMILY_CONTACT
+ if(trigger & (ARTIFACT_TRIGGER_IMPACT | ARTIFACT_TRIGGER_ENERGY | ARTIFACT_TRIGGER_HEAT | ARTIFACT_TRIGGER_COLD))
+ return ARTIFACT_FAMILY_ENERGY
+ return ARTIFACT_FAMILY_AIR
+
+/// How often an effect rolls this trigger, relative to the others.
+/proc/artifact_trigger_weight(trigger)
+ switch(trigger)
+ if(ARTIFACT_TRIGGER_PROXIMITY)
+ return 4
+ if(ARTIFACT_TRIGGER_PLASMA, ARTIFACT_TRIGGER_CO2)
+ return 8
+ return 10
+
+/// Picks an index from a positional list of weights. Returns 0 if every weight is 0.
+/proc/xenoarch_pick_weighted_index(list/weights)
+ var/total = 0
+ for(var/weight in weights)
+ total += max(weight, 0)
+ if(total <= 0)
+ return 0
+ var/roll = rand() * total
+ for(var/index in 1 to length(weights))
+ roll -= max(weights[index], 0)
+ if(roll < 0)
+ return index
+ return length(weights)
diff --git a/voidcrew/modules/xenoarch/core/artifact_label.dm b/voidcrew/modules/xenoarch/core/artifact_label.dm
new file mode 100644
index 00000000000..1eb9429109b
--- /dev/null
+++ b/voidcrew/modules/xenoarch/core/artifact_label.dm
@@ -0,0 +1,25 @@
+/**
+ * # Artifact label
+ *
+ * The sticker a crew prints and sticks on a large find: what they think it does, what they
+ * think wakes it, and its second effect or "None". Words come from the labeler's plain-word
+ * lists (artifact_effect_label_names(), artifact_trigger_label_names()). Nothing in the game
+ * says whether a label is right; the sale price is the only feedback.
+ */
+/datum/artifact_label
+ /// What it does, as an effect's label_name
+ var/effect_name
+ /// What wakes it, as artifact_trigger_name() gives it
+ var/trigger_name
+ /// The second effect's label_name, or ARTIFACT_LABEL_NONE
+ var/second_name = ARTIFACT_LABEL_NONE
+
+/datum/artifact_label/New(effect_name, trigger_name, second_name = ARTIFACT_LABEL_NONE)
+ . = ..()
+ src.effect_name = effect_name
+ src.trigger_name = trigger_name
+ src.second_name = second_name || ARTIFACT_LABEL_NONE
+
+/// The label as it reads when examined.
+/datum/artifact_label/proc/examine_text()
+ return "A printed label reads: Effect: [effect_name || "?"]. Trigger: [trigger_name || "?"]. Second: [second_name || ARTIFACT_LABEL_NONE]."
diff --git a/voidcrew/modules/xenoarch/core/artifact_pricing.dm b/voidcrew/modules/xenoarch/core/artifact_pricing.dm
new file mode 100644
index 00000000000..f1fece3ec39
--- /dev/null
+++ b/voidcrew/modules/xenoarch/core/artifact_pricing.dm
@@ -0,0 +1,58 @@
+/**
+ * # Artifact pricing and research value
+ *
+ * One place that says what a large find is worth, so buyers, receipts, metrics and tests
+ * all agree. Price = base by stamped band x main-effect tier x label share x condition.
+ * Buyers only take finds stamped as dug; anything else is worth nothing to them.
+ */
+
+/// Credits a buyer pays for this artifact as it stands. 0 if it was never dug.
+/proc/artifact_sale_value(obj/machinery/artifact/artifact)
+ if(QDELETED(artifact) || !artifact.is_dug())
+ return 0
+ var/datum/artifact_effect/main = artifact.main_effect
+ if(!main)
+ return 0
+ return xenoarch_price_for(artifact.origin_band, main.tier, artifact.label_accuracy(), artifact.damaged, artifact.harvests_taken())
+
+/**
+ * The pure price function behind artifact_sale_value().
+ *
+ * * band - stamped ZONE_*
+ * * tier - ARTIFACT_TIER_* of the main effect
+ * * accuracy - 0 to 1, from label_accuracy(); 0 for no label
+ * * damaged - came out of the rock damaged
+ * * harvests_taken - how many harvests were drained from it
+ */
+/proc/xenoarch_price_for(band, tier, accuracy, damaged = FALSE, harvests_taken = 0)
+ var/list/base = ARTIFACT_PRICE_BASE
+ var/list/tier_mult = ARTIFACT_PRICE_TIER_MULT
+ var/value = base[XENOARCH_BAND_INDEX(band)] * tier_mult[clamp(tier, ARTIFACT_TIER_COMMON, ARTIFACT_TIER_EXTREME)]
+ value *= xenoarch_label_share(accuracy)
+ if(damaged)
+ value *= ARTIFACT_DAMAGED_MULT
+ if(harvests_taken > 0)
+ value *= ARTIFACT_HARVEST_MULT ** harvests_taken
+ return max(round(value, ARTIFACT_PRICE_ROUNDING), 0)
+
+/// What share of full price a label of this accuracy earns.
+/proc/xenoarch_label_share(accuracy)
+ // A perfect label sums 0.5 + 0.3 + 0.2 in floating point; never let that read as 0.99999.
+ accuracy = round(accuracy, 0.01)
+ if(accuracy >= ARTIFACT_LABEL_ACCURACY_FLOOR)
+ return min(accuracy, 1)
+ return ARTIFACT_UNLABELLED_SHARE
+
+/// Research points for the first full analysis of a large find.
+/proc/xenoarch_research_points(band, tier)
+ var/list/points = ARTIFACT_RESEARCH_POINTS
+ . = points[XENOARCH_BAND_INDEX(band)]
+ if(tier >= ARTIFACT_TIER_RARE)
+ . *= ARTIFACT_RESEARCH_RARE_MULT
+ return round(.)
+
+/// Research points this artifact pays on its first full analysis, or 0 once paid or if it was never dug.
+/proc/artifact_research_value(obj/machinery/artifact/artifact)
+ if(QDELETED(artifact) || artifact.research_paid || !artifact.main_effect)
+ return 0
+ return xenoarch_research_points(artifact.origin_band, artifact.main_effect.tier)
diff --git a/voidcrew/modules/xenoarch/dig/ambush.dm b/voidcrew/modules/xenoarch/dig/ambush.dm
new file mode 100644
index 00000000000..0dcb6ab9b0e
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/ambush.dm
@@ -0,0 +1 @@
+// Waking local fauna when a large find comes out of a yellow or red dig site.
diff --git a/voidcrew/modules/xenoarch/dig/boulder.dm b/voidcrew/modules/xenoarch/dig/boulder.dm
new file mode 100644
index 00000000000..12b733953fb
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/boulder.dm
@@ -0,0 +1 @@
+// The boulder a large find sits in: depth, stabilising, chipping down and brushing out.
diff --git a/voidcrew/modules/xenoarch/dig/curios.dm b/voidcrew/modules/xenoarch/dig/curios.dm
new file mode 100644
index 00000000000..71be66fe2f5
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/curios.dm
@@ -0,0 +1 @@
+// Small finds: fossils, pottery, statuettes and other curios that come out of strange rocks.
diff --git a/voidcrew/modules/xenoarch/dig/designs.dm b/voidcrew/modules/xenoarch/dig/designs.dm
new file mode 100644
index 00000000000..8c33114a8bc
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/designs.dm
@@ -0,0 +1 @@
+// Printer designs for the excavation kit and dig tools.
diff --git a/voidcrew/modules/xenoarch/dig/dig_reserve.dm b/voidcrew/modules/xenoarch/dig/dig_reserve.dm
new file mode 100644
index 00000000000..7d284f8333e
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/dig_reserve.dm
@@ -0,0 +1,51 @@
+/**
+ * # Planet dig reserve
+ *
+ * What a planet's dig sites still hold for the rest of the round. It lives on the persistent
+ * overmap planet, never on its disposable surface, because surfaces rebuild five minutes after
+ * the last visitor leaves: seeding per build would refill every site on every visit.
+ *
+ * The first build fills one record per site; rebuilds place only what is left, and mining a
+ * site wall takes from its record. Same pattern as /datum/planet_ore_reserve.
+ */
+/datum/planet_dig_reserve
+ /// ZONE_* of the planet when the reserve was made; finds are stamped with it
+ var/band = ZONE_GREEN
+ /// One record per dig site, in the order they were placed
+ var/list/datum/dig_site_record/sites = list()
+ /// Set once the first build has placed every site
+ var/seeded = FALSE
+
+/datum/planet_dig_reserve/New(band)
+ . = ..()
+ src.band = XENOARCH_BAND_INDEX(band)
+
+/datum/planet_dig_reserve/Destroy(force)
+ QDEL_LIST(sites)
+ return ..()
+
+/// What one dig site still holds.
+/datum/dig_site_record
+ /// Large finds (boulders) left in the site
+ var/large_left = 0
+ /// Small finds (strange rocks) left in the site
+ var/small_left = 0
+ /// Which kind of site it is (garden, animal, house, technical, temple, war)
+ var/site_kind
+ /// Where the site was placed on the first build, so rebuilds put it back in the same spot
+ var/x
+ var/y
+
+/obj/structure/overmap/planet
+ /// What this planet's dig sites still hold; made on first use
+ var/datum/planet_dig_reserve/dig_reserve
+
+/obj/structure/overmap/planet/Destroy()
+ QDEL_NULL(dig_reserve)
+ return ..()
+
+/// This planet's dig reserve, made on first use with the planet's band.
+/obj/structure/overmap/planet/proc/get_dig_reserve()
+ if(!dig_reserve)
+ dig_reserve = new(ore_zone())
+ return dig_reserve
diff --git a/voidcrew/modules/xenoarch/dig/dig_sites.dm b/voidcrew/modules/xenoarch/dig/dig_sites.dm
new file mode 100644
index 00000000000..bfa4033bfe5
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/dig_sites.dm
@@ -0,0 +1 @@
+// Dig sites: the planet generator pass that places site walls, stakes and spoil heaps.
diff --git a/voidcrew/modules/xenoarch/dig/finds.dm b/voidcrew/modules/xenoarch/dig/finds.dm
new file mode 100644
index 00000000000..bd3223cbfcd
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/finds.dm
@@ -0,0 +1,9 @@
+// Large finds other than artifacts: bluespace formations, charged crystals, sealed cases and
+// exosuit wrecks.
+
+/// A crystal that holds a finite charge once wired in. Never saved with a ship (it would refill).
+/obj/machinery/power/charged_crystal
+ name = "charged crystal"
+ desc = "A heavy crystal humming with stored charge."
+ density = TRUE
+ anchored = FALSE
diff --git a/voidcrew/modules/xenoarch/dig/site_walls.dm b/voidcrew/modules/xenoarch/dig/site_walls.dm
new file mode 100644
index 00000000000..be9b4bd111a
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/site_walls.dm
@@ -0,0 +1 @@
+// Site walls: rock flagged as part of a dig site, holding a boulder or a strange rock.
diff --git a/voidcrew/modules/xenoarch/dig/spectrometer.dm b/voidcrew/modules/xenoarch/dig/spectrometer.dm
new file mode 100644
index 00000000000..3b387f5c53d
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/spectrometer.dm
@@ -0,0 +1 @@
+// The spectrometer that reads a core sample and prints the stabilising field.
diff --git a/voidcrew/modules/xenoarch/dig/stabiliser.dm b/voidcrew/modules/xenoarch/dig/stabiliser.dm
new file mode 100644
index 00000000000..f3fda34d942
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/stabiliser.dm
@@ -0,0 +1 @@
+// The field stabiliser set to one of eight fields before a boulder is cut down.
diff --git a/voidcrew/modules/xenoarch/dig/strange_rock.dm b/voidcrew/modules/xenoarch/dig/strange_rock.dm
new file mode 100644
index 00000000000..77870dd65d7
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/strange_rock.dm
@@ -0,0 +1 @@
+// Strange rocks: small finds chipped out by hand.
diff --git a/voidcrew/modules/xenoarch/dig/tools.dm b/voidcrew/modules/xenoarch/dig/tools.dm
new file mode 100644
index 00000000000..c4e4839e004
--- /dev/null
+++ b/voidcrew/modules/xenoarch/dig/tools.dm
@@ -0,0 +1 @@
+// Excavation tools: hammer, brush, depth scanner, core sampler and sample bag.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/animate.dm b/voidcrew/modules/xenoarch/effects_polaris/animate.dm
new file mode 100644
index 00000000000..9dc7522445f
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/animate.dm
@@ -0,0 +1 @@
+// Artifact effect: animate.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/berserk.dm b/voidcrew/modules/xenoarch/effects_polaris/berserk.dm
new file mode 100644
index 00000000000..5e21a5f6cd7
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/berserk.dm
@@ -0,0 +1 @@
+// Artifact effect: berserk.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm
new file mode 100644
index 00000000000..60e09910339
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm
@@ -0,0 +1 @@
+// Artifact effect: disarm.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm
new file mode 100644
index 00000000000..870a293c5f0
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm
@@ -0,0 +1 @@
+// Artifact effect: electric field.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm b/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm
new file mode 100644
index 00000000000..f7405b2a680
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm
@@ -0,0 +1 @@
+// Artifact effect: extinguisher.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/faraday.dm b/voidcrew/modules/xenoarch/effects_polaris/faraday.dm
new file mode 100644
index 00000000000..653c55103f6
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/faraday.dm
@@ -0,0 +1 @@
+// Artifact effect: faraday.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/feysight.dm b/voidcrew/modules/xenoarch/effects_polaris/feysight.dm
new file mode 100644
index 00000000000..902c4ab4625
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/feysight.dm
@@ -0,0 +1 @@
+// Artifact effect: feysight.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm b/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm
new file mode 100644
index 00000000000..4ee85013446
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm
@@ -0,0 +1 @@
+// Artifact effect: forcefield.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/fortify.dm b/voidcrew/modules/xenoarch/effects_polaris/fortify.dm
new file mode 100644
index 00000000000..fb611a3afe2
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/fortify.dm
@@ -0,0 +1 @@
+// Artifact effect: fortify.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/gaia.dm b/voidcrew/modules/xenoarch/effects_polaris/gaia.dm
new file mode 100644
index 00000000000..d3a5bd5022e
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/gaia.dm
@@ -0,0 +1 @@
+// Artifact effect: gaia.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm b/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm
new file mode 100644
index 00000000000..5cf9fe346fc
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm
@@ -0,0 +1 @@
+// Artifact effect: gravity wave.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm
new file mode 100644
index 00000000000..6fc56609d67
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm
@@ -0,0 +1 @@
+// Artifact effect: life drain.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm b/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm
new file mode 100644
index 00000000000..9af86bcbc54
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm
@@ -0,0 +1 @@
+// Artifact effect: life steal.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm b/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm
new file mode 100644
index 00000000000..6a82f0b756b
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm
@@ -0,0 +1 @@
+// Artifact effect: plasma vent.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm
new file mode 100644
index 00000000000..cb67d56ee74
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm
@@ -0,0 +1 @@
+// Artifact effect: poltergeist.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm
new file mode 100644
index 00000000000..d61062271d4
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm
@@ -0,0 +1 @@
+// Artifact effect: resurrect.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm b/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm
new file mode 100644
index 00000000000..7278bea4aa6
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm
@@ -0,0 +1 @@
+// Artifact effect: sleeping gas.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/spines.dm b/voidcrew/modules/xenoarch/effects_polaris/spines.dm
new file mode 100644
index 00000000000..d391810f000
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/spines.dm
@@ -0,0 +1 @@
+// Artifact effect: spines.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/sprint.dm b/voidcrew/modules/xenoarch/effects_polaris/sprint.dm
new file mode 100644
index 00000000000..541f6ed659d
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/sprint.dm
@@ -0,0 +1 @@
+// Artifact effect: sprint.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm
new file mode 100644
index 00000000000..d40b809507a
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm
@@ -0,0 +1 @@
+// Status effects applied by artifact effects.
diff --git a/voidcrew/modules/xenoarch/effects_polaris/sweat.dm b/voidcrew/modules/xenoarch/effects_polaris/sweat.dm
new file mode 100644
index 00000000000..d3ee47a123f
--- /dev/null
+++ b/voidcrew/modules/xenoarch/effects_polaris/sweat.dm
@@ -0,0 +1 @@
+// Artifact effect: sweat.
diff --git a/voidcrew/modules/xenoarch/study/analyser.dm b/voidcrew/modules/xenoarch/study/analyser.dm
new file mode 100644
index 00000000000..45c86c8c309
--- /dev/null
+++ b/voidcrew/modules/xenoarch/study/analyser.dm
@@ -0,0 +1 @@
+// The artifact analyser: scans a find on its pad and prints a report.
diff --git a/voidcrew/modules/xenoarch/study/labeler.dm b/voidcrew/modules/xenoarch/study/labeler.dm
new file mode 100644
index 00000000000..5202cbd12bb
--- /dev/null
+++ b/voidcrew/modules/xenoarch/study/labeler.dm
@@ -0,0 +1 @@
+// The artifact labeler: prints the sticker a crew puts on a find.
diff --git a/voidcrew/modules/xenoarch/study/research.dm b/voidcrew/modules/xenoarch/study/research.dm
new file mode 100644
index 00000000000..83ca4c85777
--- /dev/null
+++ b/voidcrew/modules/xenoarch/study/research.dm
@@ -0,0 +1 @@
+// Research paid for analysing finds, and the curio point table.
diff --git a/voidcrew/modules/xenoarch/study/scanpad.dm b/voidcrew/modules/xenoarch/study/scanpad.dm
new file mode 100644
index 00000000000..63a85615170
--- /dev/null
+++ b/voidcrew/modules/xenoarch/study/scanpad.dm
@@ -0,0 +1 @@
+// The scan pad a find stands on for the analyser and harvester.
diff --git a/voidcrew/modules/xenoarch/study/techweb.dm b/voidcrew/modules/xenoarch/study/techweb.dm
new file mode 100644
index 00000000000..787847fb146
--- /dev/null
+++ b/voidcrew/modules/xenoarch/study/techweb.dm
@@ -0,0 +1 @@
+// Xenoarchaeology research nodes and their designs.
diff --git a/voidcrew/modules/xenoarch/trade/buyers.dm b/voidcrew/modules/xenoarch/trade/buyers.dm
new file mode 100644
index 00000000000..8577a192a25
--- /dev/null
+++ b/voidcrew/modules/xenoarch/trade/buyers.dm
@@ -0,0 +1 @@
+// Outpost traders buying large finds and curios.
diff --git a/voidcrew/modules/xenoarch/trade/checkpoint.dm b/voidcrew/modules/xenoarch/trade/checkpoint.dm
new file mode 100644
index 00000000000..49b987779d0
--- /dev/null
+++ b/voidcrew/modules/xenoarch/trade/checkpoint.dm
@@ -0,0 +1 @@
+// Outpost checkpoints turning back loose finds.
diff --git a/voidcrew/modules/xenoarch/trade/metrics.dm b/voidcrew/modules/xenoarch/trade/metrics.dm
new file mode 100644
index 00000000000..72bea68c7ee
--- /dev/null
+++ b/voidcrew/modules/xenoarch/trade/metrics.dm
@@ -0,0 +1 @@
+// Round metrics for digging, analysing, harvesting and selling finds.
diff --git a/voidcrew/modules/xenoarch/trade/specimen_crate.dm b/voidcrew/modules/xenoarch/trade/specimen_crate.dm
new file mode 100644
index 00000000000..5242834cb0d
--- /dev/null
+++ b/voidcrew/modules/xenoarch/trade/specimen_crate.dm
@@ -0,0 +1,23 @@
+/**
+ * # Specimen crate
+ *
+ * A heavy crate built to hold one large find. Effects stop while it is shut inside (an
+ * artifact off a turf never stirs), so a crew can haul a dangerous find without it going off.
+ */
+/obj/structure/closet/crate/specimen
+ name = "specimen crate"
+ desc = "A padded, lead-lined crate big enough for one large find."
+ allow_dense = TRUE
+ allow_objects = TRUE
+ storage_capacity = 10
+
+/obj/structure/closet/crate/specimen/insertion_allowed(atom/movable/thing)
+ // The only dense thing it takes is one artifact. No people, no other machines.
+ if(istype(thing, /obj/machinery/artifact))
+ var/obj/machinery/artifact/find = thing
+ if(find.anchored || (locate(/obj/machinery/artifact) in contents))
+ return FALSE
+ return TRUE
+ if(ismob(thing) || (isobj(thing) && thing.density))
+ return FALSE
+ return ..()
diff --git a/voidcrew/modules/xenoarch/use/battery.dm b/voidcrew/modules/xenoarch/use/battery.dm
new file mode 100644
index 00000000000..5885763dfa5
--- /dev/null
+++ b/voidcrew/modules/xenoarch/use/battery.dm
@@ -0,0 +1 @@
+// The exotic battery an effect is drained into.
diff --git a/voidcrew/modules/xenoarch/use/harvester.dm b/voidcrew/modules/xenoarch/use/harvester.dm
new file mode 100644
index 00000000000..0709c090d39
--- /dev/null
+++ b/voidcrew/modules/xenoarch/use/harvester.dm
@@ -0,0 +1 @@
+// The harvester: drains one active effect from a find into a battery.
diff --git a/voidcrew/modules/xenoarch/use/utilizer.dm b/voidcrew/modules/xenoarch/use/utilizer.dm
new file mode 100644
index 00000000000..9962c143f12
--- /dev/null
+++ b/voidcrew/modules/xenoarch/use/utilizer.dm
@@ -0,0 +1 @@
+// The handheld utilizer that runs a harvested effect from its battery.
From 646af70ff312075090f6b281c3d864c83ee0b87d Mon Sep 17 00:00:00 2001
From: Jackriip <97582790+Jackriip@users.noreply.github.com>
Date: Sat, 7 Nov 2026 20:58:23 -0800
Subject: [PATCH 20/72] Add the artifact sprite sheet
Fifteen artifact shapes with idle and active states and a label sticker, assembled from
NovaSector (artifacts.dmi states 7-13), Polaris (xenoarchaeology.dmi ano10, 70, 80, 120, 130,
140, 150) and Aurora (crystal_madness, with new active frames). All CC BY-SA 3.0. No Goon-derived
states.
---
voidcrew/modules/xenoarch/icons/artifacts.dmi | Bin 0 -> 33966 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 voidcrew/modules/xenoarch/icons/artifacts.dmi
diff --git a/voidcrew/modules/xenoarch/icons/artifacts.dmi b/voidcrew/modules/xenoarch/icons/artifacts.dmi
new file mode 100644
index 0000000000000000000000000000000000000000..eb78e3d4591ab9a39a52b224d344d44750bb29a4
GIT binary patch
literal 33966
zcmaI72UJtd_b!~!L69z;fQW!dRY5=qNL8dL(xsz-bm=V#MLL3_fKpWu1*G>DdJ*Z;
zqy$3m2`vePrBn+^Z~Fg(!FcmeSb2LKS5Rq$c-Wu!D)blI_3ev3pwg1;L_
z6PLtSj%{(BzgaH@^d3HvXg&W@+Vx0}Y*`?yZJ#JC(z`5+n}hPb>EAnJni3Gd5qR*{
zaa)g9S@dy%b-w7Ehk2SmPBjb4$+9Iq7+6D@wWEGLEVDswTt%~=&Y7%YOPm>Q*|!L^
zd5y0X$4S4STlw(nck`dZ5n2eJm%MoXA
z!4nieq`u|W`s=^vJ8VtKkw{fNZM5O&;eNf+z-0b=mIQKTuTlQK_{Y-54F4}0Snd1b
zbU!!08%Wy;@{Rqa(T;Z>5X16c0N3r6aoJ?;%d;^Qioxyq5mn$C&12
z7h5y?2hBBmPd|@a)olg4Do2t_hmxg+nG}N93
zX6;&0XRvAhE&VtzEXj3^j)m=MV#cEC6=l}=_%Bh1k3?Q5vmMbpDIOo08V)Nx?z)xt
zMKnY8QQ^%9P9}B=wg>Mz-`SZ@-zFYT{+72in!PptxiL7P&^o6eM6qRl!fGEgVJh}M
zH+Ptxly(;JO?$(-HN+O^!hUgH;xzZi$J;QIpYigZbIvuPC2r%NAqO^IL?Nf!3b}n&cs@;uB!>{Hw|xPAbRhi
zU^bYmbkO$d{?wmuT)cF>W*6AMeb)BF6U4DJ;+OJXc3Otunl)*BlRaNQ~&{YTKO)E=ad
zUdfJIUH~0~GPqa$Jm+`vu1M&CmazI~GStQcydXYz-oF#-2yp`|6-Mz;@+SB^May3d
z-Zhd25w)oeAHYvTT!^>oAGcN9)n*6Z3obGxMs
zeW=W%;g$X5W~dTgB*Hrhos`VANYMb
z+0*Foxlm~9dqCeoA0TLbDBGOW^K9!ek~MimZa*|FMRP8W@Ld#m`>n=z-PAClaXfez4?;lBB$}4r
zD7~+Xle>5WaAzT|OP=RD2hb|l+;=DYS6h`FoLj93Nu9)R&FX(+gcaoft`Zyk)_IIy
z!)RE)L0_d<5%90!y!kgMR#wl9ENCCy`K|Gy=13gxKx%>00Y;mzC<|4*L$Afxl!Z!Y
ziD&`X@or78MuFD9f2Z(xbpRsjT-;gaW&hCj1O(7VO
zM?pxPCO#q(zMG5GpdR!XEWjU<13X?%K9C4~hfc*XlILylkK{n-6NpO^A>Usc$U+VX
zv(`b&Nk!3-*Z~e7kO<`EQfgvuGZag+jT=Q8zDuqaK#k4yyD@WtPHv~S=x
zD;sh*l18{Go#twZQjlMcewP4q2e>1IR@4wVi8wCOCt+vI7-?6*ju7-gU^r
z^SdS=u&F6VqOo1`@wq3el1#vM!RG&=ST$XM>pY3h(Yn^XWZ1(b^OH^J^Sv
zds?ewzgH)ZZ7956s7yOJ*PjdRz&ERd&8DZ_RivQkNRzNUIM&OQLqF=f-GQRcBrr9f
z9{b>e?Uwi@?B_V2oi>8EqWB}I{3(0f8?7U&t#FY
zxEur@c-2;L2)=%f`=z
zaUJ8=&g60M=L{oYODe$1`RTEPghln@vO#X}){iVy3<$D#maKeY22sz25){q|XZz-A
zYQRhxhv-U;?=SI@h)+Y5+AA>vdX4q)D}-$x&@W0;QZacRt*dnpIa%+FibQSSR!_LgRS(Tt+l
zSUvW8C@C%n@`b>NFvJ(l|1oKGVNy^)Nqz2xlj$_;gr)##r9gy2uM@e>9~Z;lSOAka
zWg?t9{CEGN&EC^=yeWw
z{Qp=@mOHPRXQt=qEjh%dE*k)9M+v_2TU7s}J8t=d@K+L;_YSDZ;47!9O~IznWltTy
zl2Hyg`+h6p;s2N#)+#vN7NyXLLlo$8@IzU{&Di*sIsR+5b2=(akUg()2YioXF1e($
zn6#t`So!#N^|OO<$NzL@QhdV3^c*nOz7GNXPa7N<5NANni}Vy)pTqpSjg<0&J2?U5
z4q@Tc{M;nd3o@3fM*51)^(4dN>G
zX8mbY(UjgYp*~n9Mb)`|T!!7+>`nT$)2XY$izN{W51->xLFVEwjt#>oR^P;(p
z--0~=UCHoCU|0r7bm5-3%XPy+8$8cifBct8K@rz=sUWA&Q04WArN1~`P($Q%1kSmm
z_E9jQa<(j?%t`sA#Gi0*sGnRGgCO3PfGRF2pYH%^XRE4w%yt$1ZVt~h+qKB5UH$~7
zp@$QT`qJDj&0|XaW{QM0=Vqg0k`N9#@Q+rb+c^%`@14yU_~Hwuvi)yE()o_{99Jd`
zOckl&ney&EJqY4`9ZKjVKJ{kVax~b*U~vREnDJ0U&eKm~vET44OIM<9KUYrB4naV)
zCh`EAL_ey8PjGvC9$Dv{#;nRz+fcpXz_1(Sw!J#FM7YYnPYR2D4n8Kck>c|eC?7dSw@E@p{NJsfJLDP>{-~?n_Go)=+w0L=sM_Yktp#q)FQ0}x
zAajgiw(KqKHJGz%qrgE7PQs=UB;{3!2ZgWwVCSs%QB-VGCLc8
z|14;4-?mvcTzTaaNc7zGb+6~^Ir#-WtkwGbW@0xML(kV&No1E07T|sQEGY+M9h&cd
zd7#KtepBHXjLjdbtqk^C#=lYC^KdR-#Uh}SAkFe=w113AP*gTi<{wkXr=)p`KKpsb`)CAloLTljqdWZ_3o_j
zD;hkroZ?7$-11n0MU_a8#M2%|+gu}v>8*%Ozz2ZnttKFSmkJ}saDPu*8LiISg0iFy
zRa8CNsoq@z-CHqu8+`0z?ys*OYDrqk
zYxDKP{CzVgcVKg;p_Y$b#9S@wF3*}g`9+Cg3@SX>wONG@CGU=J3Yf?%DcOMl%>JG5
z#UtQO8T9U3zIjQ-%jc6VA?CSP2B(o7S5H=KN*^uuR(z$-YX3A*oL)YB8OkI&Z}O|f
zG0NIx$f3j``LEk@{*y^`_1`(3nBHb@^XgRw4&?JhhG(+US9|`5y#TqtuF3=@_&pPc
zBu(Ej9hx0(XnXL}KVoHAf2vDQL<4dI*!DHx30a$5AOWfJ?2;d#TMidQ`6Byo$u;rc
z$`(pFW%YcdA4tZ#t8Mo{y+l3fm3ff~ZTUA`LekJU1{YUOVOVaQ3dhkj6lrfh
zl!XhAR8ikoE?&m)UW1>ow7qd>ml1^jXPgDrpNAY5r-i2%deI?^PHAvHxx7$8fgx>>4x)Af`?4EG>Z
zlZ~CdVow`nxjFr!;#<0EXEX!ufU%daGrg?kj<95(Z&qj`d&C-M>>7(|Ecxu5TuTOvVG2QMeC3~_nT_Kj-D|5B=R2F>D7lw%{Vu7Hh5E@@zjy;3+JFDV_mJ=qB<-O}cVBfi%Yl0v#{}CS$MmRAf#=2){&tKYg;=ABLKi%PUs~I&wVA25pogCn$pZ-D*r#Qg}h?yuG
z&=2*#dRD$ZDu&y=9rq0HQcZmV+?{Rm2!0^oy;n^zhGaFr4_WZvJ$tb7dYITfg&lU3rY1?2L2MrB1gcE
zzYFG)2_Cgr0$a;5NwGX%tam)8Y({;3ooHtM5FXUMiQs=_jN6C;4Jy;t^tNylll-dj
zGa_86It(F@+nDP661RaL-ds|~6utVVDEA5Wt5?B|_S%=#H$O{gpkw|;+x
z)$Q=S6?=efD+fL0-N41;g+O7!6R9a+pK2Jee9fq5va7a#9YjkoC^SI^{JmD?AL)F~
zIhlPs?0xrBHYX(*7E*n5N6zV$nCq0p?^19XJI^t}g?W@FKZ2bj&PVpEpuLF;QOESe
zRL*W|-EFsQb?1v^W(Dn~;pAAYwk_`JYo$lR0FGVEF2FruH_y@Z;7hRVW2v`_5$1e_
z5#aT2@o$WUJN8+A2)&;Zd&myOqUji$G_Y3oqu!79q)k|Jz#qS->+rRgyfJFeee_3n
z<&Eu?-kDVnT>yi967byz01e~=;IbPxA3@NZ7PeSJv6%nh+I?{KUiD^yrEgLkaccb1
zG`WGVQ|@ovhnDlM>;C6IhZ14*v4`<;&^KnZt@V6J=)@1`b5m>i=wt5pgV8o&oIFIl
z{bUm~fkMc3-9n53HlEYuUlCT^vaJO
zNNb~{^=5}hQX1J_W^;;_udA-y$$1j(F~Gl6czIs_s_C=8sa6lJ>goP{<8bO|z}SKc
zBKCm^!Iz{c+n8X`laBegV}M+#@ec}t7i1zSnYD53R$qFnAoJX{?nY{3+ZX=GNBsHo
zU|IlE=BxYl%w#O-EA3Lmmz0*r4kZ-jb?+xU&B1>w#QGIByFhuasXF6NB=4j!n@yeD
zl!RftgwX-7B#M1>@?FGo$%WdcMcy`fC?%Pa4IuYdEYeatgjl5+Q!*PK|7Oi${Pb~G
zVy^Rg3odvp-R^=e8N-OwcJ?-C=LV8(&m9{sr19@=AhM6B5CKPMk)FD+vkAA!^NnBb
z+Xh?S^k**?l0Qr~`R{e5-AukbL~6{i*UyE3|t!
zXz>Rah0<^IdB+tFynOeJmtgx)NY-^!nYj?dat&Spw3T55It1aDYu}a}wvSe*`R~p#
z6LnC`h%;{IyZXVj4!mDs6
z=uASl7XJ2|Lq<=t$Cdr9Yy1Ux6V}YGhA4jc)$bAMH=@V+Jny`X=NWqXHK?&6T>xiP
zOU7bTjztfgrQgJV!~ri{D5F*|Cxt9iKVYdYvW7?AytM3D)f=hq3dj?*I)1*50Zy~E
znovtX1%pCXL>6wc{S0yDv7i&Arx3vVoMmetJ^}ENGn&pto2b#y2CedfSZylgC4M@9
zG&p}IWi99$;vX;okUn2+GMGLmBZ@G{GTrow7w$^e+|e+fMdQWJtI36s-{cRe|3l^S
zMvmUC;K!BP(`P3KwDzWp?0fJa{C7;NW`IA%ZQ#Z-yn{SqWId;=x7@
zT$2{AcfV*^92`{CtRUjrp9vX?4=VNnTMlDBTM^fCW@mbyH<^D`=Rtq_%rqp18(3D3
z92KKqkBtxs>G4pYUf|`6{pm9tkBRj2R;?Aoh49Vmrr=#d$l$k~RGPk+YRU(6JcmMK
zSC2t$5Q~Pi!JthS|8p?E18%&qvL}YW6Y4+|XVjdHHoDG4omqM5-_F4Ovy~2lBdIF9
zeajb$!kVz2k9v?);^`8r&`m1)OYdxsUdkK@Q_w4i3p)Rif|VbpXVVi6$zU}1H@=qv
zAgM6>sNYZ7+l=_UEMBK{iE($+xiww9ct1Z25Kd2H+`*JT4
zO)rOmj!K>$p=aaZ7aZ=#)AN=ig!gA2PyYT}Xe}2TyDDd+-$@mx(FMAYAHFJU+vmvC
zWNdJ!R?-Xx5jr?k#w*)5d5e0`Q#gK9->PZ(oNPn>Apj7Sq!{^m9(+nS;wkbqO24N4
z0eO*mwt@1;`2}4j`~&fDAX_-2l9(cR3+een#-i>tdnebeuApK~zyUTaf3OkmO`
zUMn*zr755@3~WMnZ9raKkBko)?ar;FfF-9R^N!RKQlJB~UMgw&%95mUC$T)Ezs3yj
zKKt>-WXPyenzgdBGJ}I+5Cl&)8@B1{D{Pf}YwBykNBvd^?IRofjbq3W6)e?=9bcGf
z<+GGnr|4ecI#@LS!*+RakrYvJ@C$kUFUg)uU-3Z9!F(sZDMsUZ3)gXnp8ySnF32!+
z_803ncWY&+NLGIy(%KhYcpI?66q$^8`Ar(VoIa~c?zcU;y#6(yV4g>wG>Z>nOo535
zaFPB*w`*OO>;*ZzL9d^$gj&pFpHHLnTY?EU$_kOz_%#D_>ctxEYl{x*!d!e(jnfE8c5vrgk+!yo=dE}>rcy9iGa)}ZDVQS)4
zufLX;CM7SbiuAHMu1CCvLk|XoE`l=jvL=8Q6wzrbDESEfZHX^qL-P*Ud-S;Ljh&U!
zKA#W>8cgiV5FX%3)aaGs=GAYFUVpe)bV7E)-sHjI=C9SSoctiZ2sk|7W>xjx;b9ia
z;p&Mo%sbilrx=ldixP@(F1GTC)KT8ysGmz`9s4rFh~M0QAPExBEZbCqyfGQuL!q93
zszABm;FXqat-S@P3=hev{V>$-nRv-@B3X|6e%MO6-b@_ZHkaV)r*u(|B7OgGFkJBM;mYWDp!
zpO}XF<1N8G_EGDeCapTfeN`GtJHE3oLeR7WA1}#4pnx@xt{2&iFY8^}TO*;$KkQKg
z+Z6R5D~^9yw~dRj=B+PYzXx3L_;o=v&hLBn|Bhz@G!s9&b62%&57YSFEqbici%Gvj
z%zu%w8qiVIS>ABQ0cSA33;8o?Wb^58F?sDQAcw#t^xIz(N#W2=nPN;M5O-B$?+1^5
z<)2(S;2dAJZGo8X$p%f~o3-vW&-PT>@tRn*Mu$Erl&Ti4xekT!_c+rD%yN{LJb_
zc7h#5okA0Ir06#vMRwY?!oqdGq;?pJuE%(OO6I*Awy49s59+m7R>XMja96n6>)U#T
zv-U={SO{!$*doZdNHeT;EBHOx0(Sk4WyC4rJHKs5RqnZCD|CG`R}?l%JMf^n&w)w@
zP{E*IZ1%2AvDPm4?rA|FL*uubumXOZAmMPmroK}Gyrm`mT&@=<>#_l3d@oT=ijt?P(EyS3iljmwI3S#{g*(3OlqJAv0sg0%bq_X*RP~
zh2me(piI^aM~$2d#ABPu-PL-oo{jj1k__&JHCfehRVZ+9k;flVn?N
zs~>9P`!_{AFt^To^}$3L9RG&=$z~w2EG(d19IJaW>q&qo?Sa{#{SZ=Y0}717E-@lw
z3esJTDIp>-Y?)+6_u3=L=jO$j?)~1unj3x;nW$q*DeJ$7*(7Hw^eRo6;&Iu|h1&(i
z^ygS_e%`SjVAw$wQ)#>jCOnQ9S7w3-CcbPwn4pw8YWPmD@d^`%cnt?Pq2TG~OWDO4
z2CzOG1hHjyDahB&?Dv7&V97wtul}U
ztucW#6EV=6cI$kpUw6;SZr~R^W?Q*J&f@e+@_eF}l+5aai)%wN*q4dH!9dG1R|PnA
ze&l5c3)G7deky&^pJme^=oP}~O%Cwh3$r^H@5*N4J~+~NEQD@3Xp||js*~QHfom3U
z2;_)_lvEY6wK_hh(ZVm6I1h98Ob-s)JhM3&yp5MDXAj;x&FYH37=j69F(1H|H&b8UV@
zZi@Ll2OtFnXk?>4YNSQ+=(?q|0s;>z!`)>6o|M%
zM*YTrdSZ)RJSUUxXdErI%t(-$kEAoqEqOY-#U<)ybYCQ@yiH5w^8g)FYfF6BiB)T^Jq<#e0*X8_~q@RF!|^VAzooI0Td$9;rn
z>~QYZ`P83v>Iev=k=l6JQ@dK=6{`9H09ectYXu~cH2Sd@4~B3*N`Awvv>7iT1X!Lu
zJ#q$JnmMcs`57gzP5oA9eC!4gP^tRETD8bEC=BS!XwnUy00C$lvl&yOV~htG8I|U#
zb%Z4+@GCPme~(&5ep%H7qEi>(#x#9p^mk9O!}n>FV~0K2$qtMFi|&GJHz34UG<}{x
zvaau#h#UwVP`LNX_?K#uAHSZz4zM5U!|FukSp#6n0-^)~71dE1fnD_FE-d^qVPT#}
zniF`BXjPY;RzBqpqzo~=YG$P`P@9#uX7(P>
zmpV|~11{4q@r^X-Y|rlBQ*32W_i2-cy)ZcF3
z&ho#UCghu1n1_e5Blqa*ufHjD=sxOwM%Jab9~*#YwJDjRXj2iP6@Z%k8;p~cBBI-u
zrGu}#_G>R@v9n6X3Y@6@mcz^q$w?->>7)Qg^)Z+0r`9iGNp8zBS!|r4m&993xhDm{
z!-UP)Q5BbiE6*aeMD!-&9%bceXVtu+lI=Plt+K5)tl;qXH`W3Cil0zmjj-i8`Go9T`lfW^9=|>%62VCVtM}tYi1QZ(xgFTcyZDY<~0ayn|}H&A=@T
zfEamSpSknj3kPc%&^n7J`>UX025-{sPY(eu0*b{xyZX8c0=5t|zu}Fqm_^5jx(@@q
zWT-ip^OBcu
zhqWx(XFhiS4-D76)ce+7f4Cq-7;SLpjb;6f>#+a+R~0GCiw=w|p49&?#Ypk%Zsu%d
zzb<}B_HX!fU=ClX3n;z;ZrA&(pinpFoG07$7gC6NRr-%UIF;1
z@?W*@+7nJ`wThBFUl#Oy`JidpRm0b
zzuN^YXs*S};EivcGmv&dU3q6hmauHBZrD}YkDTVX>ikskCGmz$XMYkG!FC)xp(Jxz
z^@XA;6&DLxhZzgDPdbg_&T1D6^41zIbLJvESRHupyhnvsC`Wxt=g!-3BZ8we{^eM1
zS2STmo}Q`HX`-3oUrZP~b#a4O@1X1XHn~^s!)ESQPS=va3--(AvBI@KRw@S!r19id
z1<(i}Os6d&zdcv-_jZ<-bhN*fF^YcM=kcD6!08u3PGWZ(^nb%t4=o-ge7`NWr2vlQ
zPXv}WUhew`_*RuE;}2lW(=jCD$hvKSD*o&bv`T3*i5%ro;|uF3&vI0j31nqh{$+!T
zcMp94V(dBDAVsR=@b3dWKcRMQWLrM7LE>_OwlE%W!Ub{K
zlp31g=4-9B-?`#S9(v1Rv_JkG=VGA|cV13prWg4&42wMeWE|T6I~B<<@PDJ(E|=4U
zg=<6MFt?d2e2k!;hzK>#$^I
z5dC%by!Ox1b{%cPqYnf(?js$UGO7W+V_xNG*RIky1+{ic{i14Fe0u@-%^j-xQ$+z$
z%6s@>;LhH_u>!#S(BP#>M|P{-^xSy@BKjsT_~rHwslgzW_A=m})lc3$X$yMWF)}m#
zPcrAnHAu+
zf6KBx9UX?;NJie-_*4m$#V?E#F$hbQO}X=aKwM9FA_GMXJ<}j9Ku9QJ3&;fgF0ERC
z=gqRTgWV`i6241ojn(8N6!H~<%P6ihuccDR53c1DGaJp7G?s^YvFPPCpxY8S`oQ7(x`kwR;?CxpMYa3l$cV0qt~_49
zRJd1TwSWgAGDY_e!MXU`Pc=eLH5&RdZxbXBxHjZ$Wn1$;li!aN0B|&MffD3j?zzz3
z`R(gg>B^i=!?M1>F$wQ@>C&C+x44`I?WjGJz@=Ix)Rzo>ynwP663-#1c$dXhi~ibz
zzn(FOy_%v_pvDDmUQd`kUjZqd<9k;^@(|PO@?g~80FLGexFbA?+%Sxu6znXGj*Lcy
zlF0az6V^<#CJ(#GD(IyP2G{Kfo!hV%9!sx5b(K#b&@}th)PEnI
zKGTW>2~sR2V{dWMkiGewD9LYtClTJ)3HWUj&*x#4g}uql!>N?6C+|DbC3kT{9}dEj
zu)XG^{(gxSnYpfn@aEh-Al`je-oc44lon2X58me~obtbLs4NH5cv9l!u_Y2d|Ip@+
zQC930x3NOq&rR4OQcl$&sWByWmxv)VpoNBb97Qbt_#Dy1r?1@`-F>sn0X$hS{?@Jk
z_pSTY$u5Ab((Pf_?kO}M3^8y#re}S1k2t$QP_MwlACDu-Pj^==Jg~+py5A0!;3ZfR_HKpe%JgjT
z&mD*yQPdvrrn>jWP@^LOLafsYA`|`xiQNv)4`wxCtaJ6$a|!9L%sLsHoEFzvcb*RK
zA6Z=0`EEoj=Y2hd^!iqkQSd;&RJ`xTgo}KUptYqbXEX?Kxi`g#
zwrMHP@>%9zOjL!Tla@H?C~;y0xN&(1=$_ywx%0ao63c7oRUW#7u?9z%MW~X%p7eJMUpNoOmYuB9M}q6m^-mg~((IGXQJu4tUF+#Bo=Q^>dvaeYp(9iG
z)Fi{EThgq|K4kO(0VVW#zob(>q$FI1&HFsPnoYhUKc=9n*p*H%koZR(
zy4eo=62inMkc~IFoH4#F9NO?lGK2JpYAZRiz`!WGlKWu)vgdx6<8Di=`si%zHSXSq
zFq)vDO;eSMF&a;Ht_dBcGItzW{)2=iz+8g&;5P_QwIV69>-&KKNAg&QdCojvyw_C?
za-GWnf#^K^qR22mVupmeu^}SVZ8s?LKjluo9Zo@Xt=G{3fA|85z5ppAq+t|D`YqhJ
zf~|l1u*PsXRKF#hQ7%eYkttU9uL>!ofKH|c@yPNLG|(5oTKKZzN*dg4&^^nB4Qhp7
zWFOB{JZ!q7Dy;(3Cl}=g#!7dYCK9(7_vc%Hk<4v}owUpt++gyginccQBhtpzvGC@Dk%U
z`rS6b=hO#{u5o&!^dt#`wvaZtm%{gT|4PhAR?
z1si(nKD)~HC^0j@mKf#ElgLvVE~m(}k;_g3kv7yIC9+8EinLHbk$DB951{{=?kq-6ID6Teu<6LgTh|$9NNRmhvP_VlCjV^
zI&6`&&yA7HvR0FUnukYwEB4zbuIx9MXPr>r!q0n
z_tG(c9;Kx<;iK;oqw99rb`}t!VTicg^JEzciy$(OHd3Nd_>RlUvhVWPo*zx~3(7jy~((|N5A
zLuddK|0rN7pz*;r0}-&hnOhAMV*t|uy>BdN+(#}u3dnSVLQj{UWofg<$Icoh$wY)9
zTaeERk)GH{T_|^UYR>|-v#8yvdm)!l(gOo^u5r)C)}=DVO^`lD=COBsDMpN67nC!v
zmPikscmQ1nIF^z-^1^;_EdvU6@2{;b8+()5Dt8`9T2v8t0Uuw&{0<6RZpnW%?QV#M
zzs!ew17yd6EN3)%VQr|5tYw$ROA|Y&-G+*%`2U9nf7)$hHBz_VB^8x>_+p6H
zKU1He-vWhr=;Eqf%NOp24%M?)CD)sXK+C>rgL^rb@rf1QJ`nDL55)Yr=6x;)Wqa^?
zSd|FzZkpQ0Y=C{o^zB@N0V;s7CHn{7*&1XLB
z1s^Qv3&{|-F4%SMv+^!EW4t~ZGKCPrI(!->^`W<-+0UU2f{kPvmCHsFw5*j%YL*y5BM5gm3rm@iv>3gG4L1xpB=M@fAQ(!vYH
zU>G|i-94}$P0>{Oy{Rkrn^Her&;k$wL+~79F+(8rYzRIh${d&B7TAgG5Wzcu(
z`zj{*&+F@G!<5_=wv!4Z_@qE06rPK}$d}!5AxG@r0yCbOp{L@m079iG1y+Qd2%bE*
zU+5kjMcFbPR3M?&>bS@bk`*6Uzs0ljo=Iwt>J{h@Z=P@DG&nOf{MFpVn^M+jvkqOY
zjn^*{3;QYh9InKObjt+}t=OsK*N0ubsV}H^{C5)de*JD6rM}zYFGf5{@9?b$Zr*-#
z&ACEXzOTukci&VD)_mrRhkPLWzl!N(a#B_U5r81fCieUWWlBKq8${U?)v}C#Gyl}@
zrjd0x^F_KWGbzOcjdqHaco
zTt5F@JPpC-YT+#C%IVL|P)1^^HO&7%xi%>wfoRAYtvqU~H3mykqB}Q3|Eu)hxsB({
zrxnP;C$N`7w(S4r_=MWjRj+lJb%baM|M-v3#MLSpFY?pjpGIj&qVWPas~E_dX2Lli
z;`Ha3cTo}`4tjq>gc81Gm@us_x~CZg-&x*HvV98?u^yU$m$QjqHXWB!@AGo&epm|AfK%%D#|}3K8^i#_!2Ah7n@$Urzv@=tIYnZrvR!{?;wFHTi$4
zZ#)FHP+Z8xKY?xFlvWK)>1bxN1JxJ6#nn&>=-0KJp7bAY^a3R0*WdFW-&@Zv-wlN@
zTI1%U!WPe4Fe`cwh95ts(_ue(&`Wx9%8i}WH#VAL0>;xKd?b^qMdb#H5Ykbo;&R4j
zc#{p&8L&u-GN0}&f7$eZv_`}=|F^hO0l?G&s1`U|>)B%>b<;_L1gFlxX(;zctOA7M
zOD`%EQgzKIx5CgN2MJQE2d16k0)~3SdtMm3I$9Zc<)z-*ZVvIEV?j
z_!u