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 ( +
+ {!!drawing && ( + + {PLAN_LAYERS.map( + (layer) => + !!drawing.paths[layer.char] && ( + + ), + )} + + )} +
+ ); +}; + +const SMALL_CAPS: CSSProperties = { + fontSize: '10px', + letterSpacing: '0.12em', + textTransform: 'uppercase', +}; + +const Stat = (props: { label: string; value: string }) => ( +
+
{props.label}
+
+ {props.value} +
+
+); + +const FitLine = (props: { + color?: string; + name: string; + count?: number | null; +}) => ( +
+ + {props.name} + {!!props.count && props.count > 1 && ( + ×{props.count} + )} +
+); + +const machineCount = (bill: BillClass[], id: PartClass) => + (bill.find((line) => line.class === id)?.items || []).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[] }) => { + const { bill } = props; + const weapons = bill.find((line) => line.class === 'combat')?.items || []; + const lab = machineCount(bill, 'science'); + const cargo = machineCount(bill, 'trade'); + return ( + <> + {weapons.length === 0 && Unarmed} + {weapons.map((item) => ( + + ))} + {lab > 0 && ( + + )} + {cargo > 0 && ( + + )} + + ); +}; + +const FRAME: CSSProperties = { + border: '1px solid rgba(134,197,228,0.28)', + borderRadius: '3px', + overflow: 'hidden', + flex: 1, + display: 'flex', + flexDirection: 'column', + background: 'linear-gradient(180deg, #16222d 0%, #111a23 100%)', +}; + +const SLOT_LABEL: CSSProperties = { + ...SMALL_CAPS, + padding: '5px 8px', + color: '#86c5e4', + borderBottom: '1px solid rgba(134,197,228,0.18)', +}; + +/** + * One tall ship card. Buttons go in `footer`, under the card. An empty slot can show + * `ghostPlan`, the ship that would go in it. + */ +export const SlotCard = (props: { + slot: number; + save?: SavedShip; + ghostPlan?: string | null; + planHeight?: number; + footer?: ReactNode; +}) => { + const { slot, save, ghostPlan, planHeight = 96, footer } = props; + return ( +
+ {save ? ( +
+
Slot {slot}
+ +
+
+ {save.name} +
+ {!!save.class && ( +
+ {save.class} +
+ )} + {save.mass !== undefined && ( +
+ + +
+ )} + {!!save.bill && } +
+
+ ) : ( +
+
Slot {slot}
+
+ +
+
+ Empty +
+
+ )} + {!!footer &&
{footer}
} +
+ ); +}; diff --git a/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx b/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx index f41ce7e8394..a9c98454812 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx @@ -18,6 +18,15 @@ import { ShipPreview, type UpgradeSlot, } from './ShipUpgradeSelector'; +import { + type BillClass, + HullPlan, + PART_CLASSES, + type PartClass, + type SavedShip, + type ShipSurvey, + SlotCard, +} from './SavedShipCards'; type ShopTheme = { id: string; @@ -71,23 +80,6 @@ type Data = { working: BooleanLike; error: string | null; notice: string | null; - save_cost: number; - update_cost: number; - has_checkpoint: BooleanLike; - bays: { - ref: string; - name: string; - number: number; - balance: number; - denial: string | null; - }[]; - blueprints: { - ref: string; - name: string; - width: number; - height: number; - denial: string | null; - }[]; rebuilds: { ref: string; name: string; @@ -98,34 +90,28 @@ type Data = { shop: Shop | null; saved_ships: { online: BooleanLike; - parts: number | null; + parts: Record | null; slots: number; - saves: { - slot: number; - name: string; - class: string | null; - denial: string | null; - }[]; - ships: { - ref: string; - name: string; - price: number; - denial: string | null; - }[]; + saves: SavedShip[]; + ships: DockedShip[]; }; }; -const credits = (amount: number) => `${amount.toLocaleString()} cr`; +type DockedShip = ShipSurvey & { + ref: string; + name: string; + class: string | null; + denial: string | null; +}; -const shipParts = (amount: number) => - `${amount} ${amount === 1 ? 'part' : 'parts'}`; +const credits = (amount: number) => `${amount.toLocaleString()} cr`; export const ShipCheckpoint = () => { const { data } = useBackend(); - const [tab, setTab] = useState('checkpoints'); + const [tab, setTab] = useState('saved'); const rebuilds = data.rebuilds || []; return ( - + {!!data.error && {data.error}} {!!data.notice && {data.notice}} @@ -148,13 +134,6 @@ export const ShipCheckpoint = () => { )} - setTab('checkpoints')} - > - Checkpoints - { {tab === 'shop' && } {tab === 'saved' && } - {tab === 'checkpoints' && } ); }; -const CheckpointsTab = () => { - const { data, act } = useBackend(); - const updating = !!data.has_checkpoint; - const cost = updating ? data.update_cost : data.save_cost; - return ( - <> -
- {data.blueprints.length === 0 && None saved.} - {data.blueprints.map((checkpoint) => ( - - - {checkpoint.name} - - - - - - ))} -
-
- {data.bays.length === 0 && ( - Dock a ship you command to save it. - )} - {data.bays.map((bay) => { - const short = bay.balance < cost; - return ( - - - Bay {bay.number}: {bay.name} - - - - - - ); - })} -
- + + {item.name} + {!!item.count && item.count > 1 && ( + ×{item.count} + )} + + {item.parts} + + ))} + + + ); + })} + +); + +/** The ship in the bay and what it would cost to save. */ +const DockedPanel = (props: { + ship: DockedShip; + parts: Record | null; +}) => { + const { ship, parts } = props; + return ( +
+
+
+ +
+
+
+ {ship.name} +
+ {!!ship.class && {ship.class}} + + {(ship.mass || 0).toLocaleString()} t · crew {ship.crew || 0} + +
+
+
+ {ship.total || 0} +
+ parts to save +
+
+
+ +
+
); }; +/** Why this captain can't afford the save, or null. */ +const shortOfParts = ( + bill: BillClass[], + parts: Record | null, +) => { + 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 SavedShipsTab = () => { const { data, act } = useBackend(); const { online, parts, slots, saves, ships } = data.saved_ships; + const [shipRef, setShipRef] = useState(null); if (!online) { return (
@@ -252,64 +316,101 @@ const SavedShipsTab = () => {
); } + const ship = ships.find((entry) => entry.ref === shipRef) || ships[0]; + const saveDenial = !ship + ? 'Dock a ship you command.' + : ship.denial || shortOfParts(ship.bill || [], parts); return ( <>
- {parts === null ? '' : `${shipParts(parts)} · `} - {saves.length}/{slots} - + ships.length > 1 ? ( + + {ships.map((entry) => ( + + + + ))} + + ) : undefined } > - {saves.length === 0 && None saved.} - {saves.map((save) => ( - - - {save.name} - {!!save.class && {save.class}} - - - - - - ))} -
-
- {ships.length === 0 && ( + {ship ? ( + + ) : ( Dock a ship you command. )} - {ships.map((ship) => { - const short = - parts !== null && parts < ship.price - ? `You have ${shipParts(parts)}.` - : null; - return ( - - - {ship.name} - - - - - - ); - })} +
+
+
+ {Array.from({ length: slots }, (_, index) => { + const slot = index + 1; + const save = saves.find((entry) => entry.slot === slot); + return ( + + {!!save && ( + + )} + +
+ } + /> + ); + })} +
); diff --git a/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx b/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx index 4b351699896..44243ccf426 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/ShipJoinMenu.tsx @@ -11,6 +11,7 @@ import type { BooleanLike } from 'tgui-core/react'; import { useBackend } from '../../tgui/backend'; import { Window } from '../../tgui/layouts'; +import { type SavedShip, SlotCard } from './SavedShipCards'; type ActiveShip = { ref: string; @@ -45,13 +46,6 @@ type ShipJoinMenuData = { 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; @@ -179,33 +173,35 @@ const SavedShipsSection = (props: { saves: SavedShip[] }) => { } > - +
{props.saves.map((save) => ( - - - - {save.name} - {!!save.class && ( - - {save.class} - - )} - - - - - - + act('launch_saved_ship', { slot: save.slot })} + > + Launch + + } + /> ))} - +
); }; From aaf5cfe0b24f5792a88331f74d0e124fa2dec33c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:38:37 -0800 Subject: [PATCH 09/72] Remove round-only ship checkpoints Ship saves are now the only way to keep a ship. The console no longer stores checkpoints for credits or rebuilds them. Every capture strips salvage and keeps nothing per machine, so a rebuilt machine comes back at default tier with charged batteries and engine fuel. Construction builds straight from a snapshot it owns, and retiring the replaced hull moves to retire_replaced_hull() for the lobby to use. The Outpost Manipulator keeps rebuild docked, the free order, rush and the job list. --- .../interfaces/OutpostManipulator.tsx | 75 +------ voidcrew/_DEFINES/player_outposts.dm | 5 +- .../overmap/code/modules/overmap/ship.dm | 4 +- .../modules/player_outposts/outpost_admin.dm | 2 +- .../player_outposts/outpost_admin_bays.dm | 7 +- .../outpost_admin_checkpoints.dm | 202 +++-------------- .../outpost_checkpoint_construction.dm | 75 +++---- .../outpost_checkpoint_machinery.dm | 78 +------ .../outpost_checkpoint_rebuild.dm | 65 +----- .../outpost_checkpoint_snapshot.dm | 50 ++--- .../player_outposts/outpost_checkpoints.dm | 176 ++------------- .../player_outposts/outpost_ship_orders.dm | 5 +- .../modules/player_outposts/player_outpost.dm | 3 - .../modules/ship_saves/ship_save_capture.dm | 16 +- .../modules/ship_saves/ship_save_console.dm | 1 - .../ship_saves/ship_save_construction.dm | 11 +- .../unit_tests/voidcrew_checkpoint_loading.dm | 19 +- .../unit_tests/voidcrew_checkpoints.dm | 210 ++++++++---------- .../voidcrew_checkpoints_every_ship.dm | 10 +- .../unit_tests/voidcrew_outpost_admin_bays.dm | 6 - .../voidcrew_outpost_admin_checkpoints.dm | 72 +++--- .../unit_tests/voidcrew_ship_orders.dm | 2 +- .../modules/unit_tests/voidcrew_ship_saves.dm | 23 +- wiki/content/player-outposts.md | 3 +- 24 files changed, 285 insertions(+), 835 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/OutpostManipulator.tsx b/tgui/packages/voidcrew_tgui/interfaces/OutpostManipulator.tsx index 617e67f2193..19b671e93cf 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/OutpostManipulator.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/OutpostManipulator.tsx @@ -46,7 +46,6 @@ type ShipBayData = { capacity: number; install_denial: string | null; remove_denial: string | null; - saved_checkpoints: number; silo: string | null; silos: { ref: string; name: string }[]; slots: { @@ -67,14 +66,6 @@ type ShipBayData = { type CheckpointAdminData = { enabled: BooleanLike; docked: string | null; - checkpoints: { - ref: string; - name: string; - owner: string; - size: string; - original: string; - rebuild_denial: string | null; - }[]; rebuilds: { ref: string; name: string; @@ -900,8 +891,6 @@ const ShipBays = ({ > {data.installed ? 'One permanent bay installed' : 'Not installed'} - {' / '} - {data.saved_checkpoints} saved checkpoints The bay stays loaded between visits. @@ -999,24 +988,17 @@ const CheckpointTools = ({ busy: boolean; act: DetailsProps['act']; }) => ( -
+
Free. No fees, captain rules or materials. -
); 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: {
)} {!!save.bill && } + {!!save.research && ( + + )} + {!!save.funds && ( + + )}
) : ( diff --git a/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx b/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx index a9c98454812..adf6c41aded 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/ShipCheckpoint.tsx @@ -91,6 +91,8 @@ type Data = { saved_ships: { online: BooleanLike; parts: Record | null; + part_credits: number; + funds_per_part: number; slots: number; saves: SavedShip[]; ships: DockedShip[]; @@ -102,6 +104,7 @@ type DockedShip = ShipSurvey & { name: string; class: string | null; denial: string | null; + balance: number; }; const credits = (amount: number) => `${amount.toLocaleString()} cr`; @@ -111,7 +114,7 @@ export const ShipCheckpoint = () => { const [tab, setTab] = useState('saved'); const rebuilds = data.rebuilds || []; return ( - + {!!data.error && {data.error}} {!!data.notice && {data.notice}} @@ -160,6 +163,7 @@ export const ShipCheckpoint = () => { const PriceBill = (props: { bill: BillClass[]; parts: Record | null; + miscCredits: number; }) => (
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 (
)} + {id === 'misc' && props.miscCredits > 0 && ( +
+ or {credits(props.miscCredits)} +
+ )}
{(entry?.items || []).map((item) => (
| null; + bill: BillClass[]; + total: number; + partCredits: number; + fundsParts: number; + fundsPerPart: number; + maxFundsParts: number; + setFundsParts: (amount: number) => void; }) => { - const { ship, parts } = props; + const { ship, parts, bill, total, partCredits, fundsParts } = props; + const miscParts = bill.find((entry) => entry.class === 'misc')?.parts || 0; return (
- {ship.total || 0} + {total}
parts to save
- + +
+ Ship's account + {credits(ship.balance)} +
+ Carry funds +
); }; -/** 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;2dAJZGo@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@CsSNJfRa< zI_bzQWm^osJ>oy#v6E;$KP}<|d9G$l+AEgDgMo#hFFCO*o4*bJKIVz@bQ|3cWsARC za)YepJ(cK@(f+dDdBkGj$S_Eytn(sc@U^UpY>9MDjW{G*5@E|ehTzY>J_O^>U0)#m ze*UAUg9n$I=2EsBe9qNNx_l`LZu$rh_6+k9z+WSs$(TcmL+`tb{vEUI?(NR;}Ccy@awQJo9y}X7fU+*Z^Y*Ne z6NRK@)?Iry&`8TQr+CW--sBUSZOS5N`|!Tn;eBOMT+N|6e!$g=i4$C&hlZ)*IQZp< z0tgi3s(fE{uDOT>WLy;*aW#}Z6$Qyy~0G4;yC=Q_pA`y z+{#}Gv^StRvf?hU+Awc_Nmq{^b_yU5p~QqKt=vKj3> zsAD>%hiQm`tI5yi9eRF$?BuOucbndROVa_i#zE`9D9qkjA4#n@I#`2ju zwpW~p?k;`?h33|&Oy2@i#rT|^3*whQfFe5!P6?GitnN`TPuJgUbQq|0Ub0=zAH$xM z&AgQRJYf8mv~}wG$w-oQ$bi%1=+jHGG~6|A*4a>w zS?$nr>1$(M)K;x$@6`4cjdYXp_~T$S!6Bm#^E>TUGph3}fCr*Fy;f|5k&(iq2;O}1 zc$(Rr9rLv~&;mRLHQc-_v4H7f+(+nQ3-`3wU;mv-GU>STM{i-e{q-Yg1P7u?`@8|r1f$AX9#vCB76LWx@UlW zMikZ2ZAFo!7RC|7Uq?z4*KSlN_pB6t&f1i>0?**o4ht`l_vig)95y&2jCIG&M~7z} zgk*!vE|ZfwE)Dj&&)FvikHCeqcBqn8wLqdhrw-L~NFd}3CI$(=5o;$BhI&Uub6cP^T$>7kG!tS_&Jdx2#q+<_X?mrM$9^B!#?Kml!_T zXfgkdz{E4(BN{^a z0iJ_1j)7(!6kjIZT{Yz+c>gL>#FMAQXz#XZChte}c|W5lfBG1={Q;326rPRxM-1a& zZ7YJ;U!z<*2Rs4F_|+{`rTY}q!KcN|B-aMmBUUI|z4obp$qF#ghFiD$A{3X!rYU3`n$h!TNj7=%i zH3JkFG*^6ZmSA{s7reiNRKN={{%cpnt^YCZT=V;dQIN@$(SPOl;nluc@=s+Fo zV7PV8+VzDHz`)hyV#;x{A6+pdp#r>rS!hH^HaTp{+1vsW3*VA9?6e-gx`Ygv4j9xO zJ5a|P+YcFhy)R9sy_fN;)fFx;0q(L97Fdxcn}@@r!vcPZxsZ04=%>_DT*RVm4R0+W zq(5C8+@mt)?g3mJ?sF9Vf8$mEuP=SyT^$F2#Tk)&-R3vYWj5`jew(_Y9aoh@pO})I zOOTuo0q^w*3nGbDrq<2rhcT`bmeOZU+cgK6d$uE`eG3wy&Xa~MYniPJ_ZmLvLr5Mx z#&C>radjdr$g^f(cKFCs^*vjQV_Bdd@4mqCSZ>+J&*L<_qasj3QDs?j=;>fGZa*_7 z>&FDmk&4|8{s7scZGGPUlyHXgp?1DQrp(A$EmIkwJK)NpeMSHWnDuc2$;p$PXK^LY zYXLC70Wf!735B%3IijzTeY8SKGU&Ls&ouzVIwnSlBr27H(4fH6&*zh&rxWzSY%b`B5nk$CjAecQU@O&aMyPH&)4jJ% zm6_mc*}Y;I)iKE460O;c`PKXRQ%`Py+KGZcZhh#o9xK$Tm<5_$&H@dB=N16O(dn2o zbGrXs%rs01znmC!hVOemwO0ONyAr$qe7kaHEV-`gR8O(eM{53etIZQ}$j#cRYlPbD z;ZMQaq>y>T&fjuZf}4~bNa2ul;x5JNpgipf;KGyI7Ns$?M*YM!-2uhJ0^F#$xHR8gdcDpo*{qI3`e5wOuA3B5^CKtQBgK#CM;(n1L! zMFCMkS|Icq5|AV$B;UdJ_ulV*_q+Z>&T*5oyJvT1p4pjasEUE>vP_Fh#w`63i!0?~ zVPAJ_w)UiPTfe1IFsecl2N;rsZa}YpdLJk7>ms#51xFabHVcl-x*lhBorT|lX_}s& zo0*y~S-8b8+a~sZFDX2yDD}QGufzf-TqxWdbu-+i-A?}Boj6> zs=T`~(97n|8{$-euD4v5t{k!3(GXg`+%AZQp>EssUZcBix!RWqeg`^PG6_T^z$%D0 z**gF*g0TAEZ_8-=OxC#oF_uekTcMcCQYA-jDJ8e|W)2Z*f) zzb8Fyjr=L$zULQAC{7ii0Cx~-gj*G=N@^D1d(eiiMw;vA0+F6*Y~GjUUXm1_J{HPPAYNX-B>-cWmg$QavBa5551I5yt?5 zxpKCi*=bA;rj-%9O?7DRkY_?*()oc>yQ0M_*bD$#=lFdce@nSp4c83bkpKfx#`fYWRVMovFIcsaVq+v({geIbAu<1XxV65vc@c`kbU z@^b1U0)uqZBW@MggAIw@g%hS&afhCb&xZ1-s@Bs(wHq&M1arIWSHJM@ckgn)Dv*O& z;p5uW2pMfqP3Epbo-4VblS=@=xcqZ^Oc6jSdS%P`0i-sd&*uqsg8+0xC^N(e^`?TM zC%V8>KTpMirev;1xmoEgqWttHE*Vk~#_}7Fc~KW|!c_!==~~|SN+%XmOzO;_|AcfB zGB=c`$s_&}h&~nltma7vw=bkOwPBT|H-)zc9y<_-?+$c-^eBqB^h3#gqh0=P=&$h) zKlXSdODDdJSuKoaz4sP(VK~H?{4A$}*(^ILXFA2mD_)~Eq zN~mM02eDd03MJlszY3V7aSM*Xu~KxXeae%W>8Ot6*2Y+(n@iF8XEk1PEDwSAx~J^G zuNAwyp#HIFJuiM%DMoLObSJ@SL((pEd61EsA=bG~-J82S_Fhm#`}RHG$v;CxO)EaR zFBF{+bhs0;kSI=7pP@-`B!V$$Y|WzxAQ!B*^cWiM8*+yZGjVEJWMjN)W$Wt)5s8HMrel4jk!NjH;n#^4_!hyo)!?LT8(93# zu|Q3AJ=I1b>gxAg{a8XvjarRB#Ho*Xa~2-t1?fvYW>T=yB8u#>js_n)`2@rS&V+Hl zZ#c=FI@1#HyPX$Gnn~e!n~2_O(kmnW{pi%wq_>#!-H(;4^#(o4gS@}RCN7$(326Y3gAW60xX+yA;YSA=q^j+xf`0IAO-fYt*Hjy^e))(Qyi(}marb#}q;R_QC~qVASa+5B zp)pnzV^b#xuMg@gsqYf@_<><6wrvW3$2Sc1g#9K5b`^Zv4b(XyquL@o*@+DoMY^3m zE@{ma?eq3`yFISu81TTc>hW>jDvED6lyz!PGo;P~);JH2_Awp!x;um&uO88Vrt3xg zQvwY}o`(JQ9|+zI^>JDHwFGvQg49bb1-YS7WC85Kx>JB zIV_)(#zJLaO0!6IY7@cFdCp}h??T`5V;sjy#;41{@ql1i*g~_ph6cuiUD4+wkUkHF zz(~^A2~c#ziKn_hAA|pa3@i0`8+L}T4+pXS7TLDK@t>CnnzGl=axT6?Z@g8mQBL!Q zos)eQ0RVo`eXJsv&vi-X7X%KR{AA4yui>fipJ`C5VJ90Y#QPh$#&!{EKGrpr8#%Zk zZt*!6P!Hr^xbQDJQ2%1zLH3{AB>+-5i9z}fQ|PCs9F@ZjNSxYUz@s{5wS ztfXgCRgZ@L`M4rf=Rf4v1!Z<@^NWYZ=-#QH0t?Y*yx*L2_-RCKh$K`+p`Wh_B$29- zEw>vPJK<~dMFToCTW<*GmoX(VTHGOQJv_?B;H02_ z33@9unhf0U8~;?hQC%$d%*W}9UBD;2#rgCUpRW+uzvo}se0s-v|3sObZenzk))5>y zO&w%$%Iu)w-wM^?xld`Ij(~rEt6q}a2) z3x}mQ0P=x2A{f2D813_9Jp98~Ry~TDFBQTOXvGthmu`df+ zXW!a@`0kNR(pG$iJ0}fOQgS*P{wl6^;&?Kpyss^nhw3Fba8FcH(5;D>{FRoM58MzM zaPgL0kG%r99zrT=s^)B`U?(?(D2sl(SWAfwzXJ(CrhfZ#w-jUMFE;EC;(<#SZ|@NW z#&4d%oIkcx>(su=e&LDf(}Q2=fy5>d_=Qzg6O1(ddlL zIp(>=*OxX2U*ug~r-$e&yi&jGV|U(V^4u1pF2W%GFcN*;O zS3|)9bt78ozDG^0?$J$DU6t*ixv|NqcnxJRL%cik&; z($4tSY-cq}ngn7yGy^<4*&83G&bt&Nk~atc6FDsHzlrxsbkP*5er3iQ-(PfwrpAaFeOLY!RZyC zZ%PF7hj8TOB(g{JG~O;xNn%rKnH#qO0Hq%8E)OZ#YH`JhVq(iu;Km`4sTzyMYDV3# zJhX_Gg!_+jw60A{Uv2S-r(Q=7$HQG8b!9Upb6fJY53_dr3(#pL8< zY*m%K((+)#{G>=X@@j;xIL>V+l@CFTavD|CDe~e0y1$9MbEEp`E4_ zuWZLUwdZfIc<9cz%ll&Hg;0}(n#cQ7%Uhc*-*q;}j-l`4+M||{8%kNbJKU~^p|?eH zkwu7b$L91KcXo4*V`=;4%(%b*V=F=q*b3Q)^Jn?yTkD5#l`SvD_oayF)lJ#yzAU1`whdjpI?8g-Id5k{`?a6D(WbM`I@f93 z(*w#yC-wDZSf2b@Dj`I2xWxhNa7zkyS|JK%+1ENInHPCy!q98~q>meYR`+BBk zgf0#IuDHQu|4e)!j=UMp{FNK$H?^7()IxK3{&;`>;Lo&hhs?=>pB;fX?8xGDPz$^+ zD8%iLoG+)dz`?<^$T1B1bAKVwyz;rl{(9olQS-gQN7x)gbEBK$SpGptF0HgGH(6h^ zfRC1tFx;KK-QV8VPa$$;7rWspn2g2tr{}x47bkH zDZJisF%p2+QN+`TZ=`o(qEb@TS&YLG>1PT{UOzbp4d^FST^iZrxxw{;>FOKiKekx@ z7Cf-DwDuP+o>a=m9*?Ena~gwg{vez5to$!1(Rz!ND2Q|!w-%> zW)P$}Mx=2hC{_EA9jz@adKbR^7~JQsOj&621*~lHeR`;Y?rCbrvO=isqemiBe6q;| zyiU0TG~kxCZzLggCzTztXo%siK)0rEp`8XU7+=M-g(+=6RMb*wartiz9!xex31XGp zb0vFlj_p|iP4RgdV;4KgCcxnhj~nyjjd2$T%}XAYcN!k^J7Lrt##CS(J5?6>RF}ph zbm=*J^)}@W`1h;en9WK6RgbRsZS0oEA7;JGs+kGh@99x*gjF))j8|h&3_XJlW~JG9fz>Lu@JHhqGXJlD5D(#j);;xlf=RiW@8_Ev zUNIkWv&cLGLNxBQ?!=zohZEe#O{k+-)%{&1(I$W;$~G>hyND4d)fq#4K}cTCmg7;Q z`PkU=1%IZAZBCJpPjG&dO{WIgboj9`q}Ec8`WSx3D5a1zCDB>h3}G`fk{==o}} zw$^|RV;0Gclg5I->RZiK*}N7dVTOlms_q^3_8@QXG9QH$@`c4igK3^zWhdgbN)EQv zIpGXaYXw^<@{H}Pi5zBrzClAVJqjdVbs9zMd6-NO>nj4^tZu`HCVw`21x$@j}C)SpW`3zZ~R)_k#K9_T)FlXAiqO5AR1J>&_}t zcec|Lpa^!C#`KQDE&?@23D52nPkVVtw!PW(Ec0bXPY-xDTouP+E?yDBCw%LI=I@Uu zVv#Aedw_51sMl4V-dx}XX0ZYz^CY2o*ZKX-d>wfCOQOuOUK@ZYKxcrY&AMDJ@#Q9Rg`CaGxP50#8W)h<1`6R*eznp`hmyn&=Zefk*P0N zZ7cE0>bg9%)R)(nAHjo2r>Q>O>qe}CA5#Np@_x&o+bS|DU3w}4g0p?lR;V7@ma!7P z3ECRQM~h-yD2b#cyxCs-svY<;v-al$Xt`B>Pr&x{U=QtU&qB~N*iknDUa}d|9*X|4 zX#OCE-dW3EPnGgh@-Y1bh|doXyF#hm$k?4AOm+=uvnIrw|K3I)Lxw3d*1(l0*5h@e zA)jjp5kaD4s~+PD_tUvZ<+|pGm2lINbHPL=Tp%801)P{h!?v~e5`#o(=zXlqmV^eU zyHedj#0qW2H!7IOf`iMkmC_qP%}#2_&Gyn(!dq-hE-QRXwTA9c{MKRT0b_=0CPinZ zx4HuDn-=w3fFeCvHco!{N*Y~f$)}Ze`z_9cQM3=I=cFNuKNtin&3IruSwAFyD*atr z`BYi?2$kgnaIuZM`%AUQdNBt*(b|ie z7%jj3=3q1Z#>Q{n+kbBBBp@GM`~&jyjf3%Mg`D?#F-UE6Ii@A2mGv_-ztQUiOx+Or zmLd4{&INVf+5S6XBQou8x8jY-Ox?&UUI#XsFEk%K4>J7!d>P4vus;&iV$YVPtg40M zj>NX7b;U)3`-?^sRQ%CqAj1L=Po4_#66I)wFiL*wy!KGCU4PfoMCrt&i$^7^yqr=} z610^jWDQ3dLdD$XPemh88VELDRDov5=m^);{j5)I?(RS)K7lroctv|}?ED3knt>kX zWCx=<#!Rfu(=$%Yf8e%@*U_drL?bDJrw%ku%WI;}{nIEDW417&_WKxVb?ju#CpV2OL`ui zfGP_9&+Bjd@8Z!vf28s_sl78wAMilhcW@0Qro2NcxgMJGMv}1j51T_N9@YL!WD$<> zPbqn8MZ38_T<&V^by|khSQoU&I@uh2J zpn&^~5US@u!kN7Bc7ll3+2c0o^k}enAulxb-nbZyfn5JU$?f(Z;tM%M9uS5OAGA)s zjh$urlq(GzNcob$9`5oYVT+f(`Cg~tgT?vAYU6h?+BR-i1UuxO>D7|%we;pfLfn-p z`fuR|Y#mqYcn?5EI72C!1h7$Rtnog(e9f;RIPxm21jCIbzdjRe9~LMe^vR>$zN6MK z0h`zAcOoQbC+i!-M4vo#-cnIwFH$^bzS2e-7DUUsAo>N6yVS$Ye6!{1lL-KC@reTs zm~W%}gebDDvn0%@PnnY~Ibkj@gr*LDs3AnVK~9|5A?Ui*p9YwZy;eQCEKQ!d4l9Pl znvvpDT!I3wGh%;`X?BAfO+-K0$-$2WMA>o$fH~RB5X#>xMhd)eZ!|q@z~m`sciKoc z>ud)^@NAUv;-LXWW^c4?hT25jax8EEoMn5vJQ3}~DigmVowm~E?17n3dH0-E-c9h- z#XB=6pE}naewOKS<-ZPc-Fho>Z-lZ)f`8JfAw0P9_H`yZ#q-Zt{TN;5GsFa=0HWsM zcg_rL+Hm8U{d|R&#L@MYFNTxWs`Re>=AhoLE9cDd6sW?)Xa$V_@`>gVn^7!1>!hA61nxV|5`{;KQ zm86}CUAqnB)%bd3{p0fmk9iHQyK)X!X#%Usi#2XWg&Kag>(`~k-xEK__+1a(;C2Hg zVbS6cx9ZV1;~cmW@sk0yo@~YO#uEcyVEc24>U^$;+=>S|W+YZay%gNI^C_^sY;H*1S?k7cNr*c-4l02GRq#j%Wu6ot`W}<S4z@>K|?SEe*ml}_Jy!^p^ReI(jk6H}}UC~M5Q;$47Zmkbww$-KHVce2Ys^L6* zE6NplCO{mL!7wir_uszSfae<=NnM|ZaNcJ=8#QicQ&F#d(ZX7#CeS)S%yIs+j;5U7 z4<~tlr|$wWnyfJ1GSb#BrU|cWRn2YTRsiq#ODF$OIB&otzT6k9)MaW16WcmGrphAQ zN3VJm++O;5r>ZX&h-Qz_&y4+-ZTBx*%YDXX&QmAFgzOmhx4%$@ytu6XHwZsT_zB(nEg0J)bk-%8XUT3V#(hKbm!wlHi`mGA zWC`g2t&E?;{Vp+5QkgH&%{6+vc+AsQX7+~|p6|Cx-puNZ+T97A>GN(<-!|z7VYOEM!r?=#0-2e$hwknb z0oKRMb2GPEh$X7@zCB=NDNvTm<(V3j@}cVOh4Xz9V@zv;OfiM|NWhMtf#~q=BENuYeA?CZ->h6Zn}Onv5AI|uF6`%6hBr?-NrzJX!Bq*^s(vh5 zPHd-Sp{CkDMA6}zPbCui{46TfQEK56I7~t`E2CD z7M(Q{wr~Bjl<%piKU4e-S2@H)pkG$1*s)H>;iI5+IlA}P;$Gbc?c1pxUT6AQ8Sw&V z$p++d)dSu`*23Z|dkwC|g+2VW&C-~kTWox;VM%FRu$S?dL6iBxbNmuLZe9`3Im*me>7`)qwI#oJ?~KCHYdJe0deF9BtQ3O2ON zaocoP4@;DfL=|8>tSIlEnHfJ4%P~9NVapsJ=jJN-bjT0a$lkX0g@XTdTL-QxIX_NL z)i~XbgpZO&4-ZT5RJX|2B^z4tBEikN-(7B=rH6Fg&y^0t?M6m7AqA_!eIJ;k#l~lv zT#PA=tpI7)$*gbYO&37H!NI(OeNDzKH*)Wdn8?69_i8%%+1v2pJpvAi#)UKQZxCD@ zhKO?l=fEkra@LcfFs_?K)E+C)f+7qi;wY3H<0{KN#1s&7@LLIn`~5-(nSH5DydAC@ z8ANcsGoqra3x}Yg5U8lp%lrby@nK4;cOT#+Jy)-@+d;8)Bp+=ytQ!N4Z&a#UJy=Hx6?gP@=A;%@< z{Nr8f&7a?g)4uQa738$SEBKLM)3k?MB2RrVCC%K=oumkn@2>gu{djazgx(5Ktu-%g zSlG&FZf@T3BHVf6*BcXV*fXbUj5KctOIZ?nVDJ}-VolF~>mXe`TF-r8m*tQeMWvQs zrKW1o2U}ClS4vDOUHr%}@yWyNT`GbPSy~$JgKl>q-8exVSW^no$jPSWP$^asU>C43 zt&sS^x7Uv<$*nsPaFL~NJ}A@EzPyeP+Cy3jf~Iui+SM404ZuKTNrmrB^I3KEtgo*% z(*1<^;Q*uRStt~p^^NBrwuI}{v3F3in@UjKwmy@Rfff&8*?;{^B|`^n?^xkNn;MH0 z?AE5ik3a#BKCOuU!f(z`?Kdvf`^2Q2Brv2I3+vSk`r-Z|>GHrAm>&LC;-p@x4E-(w zLc}KQXc{pdBUL}{8MgmHHwvQEhOlYXh1;|%?lZ^ou1>76+8G)dRjt!kH(@N)|KUOa zDo&J8f290i3ny)voJR*JmCKHntPLzW2W zV|i@P0^(>1!eN3i-FzE(D!?lBw|Yg1CH%h0dUQo}##Kh^Eoyq+n@FV9hUxTfAJ{;< zE@jJ?sN>IQ*j43(%&+$v>m79ew-Yu#dnLZs0g=QpKQTQbi>uiUof?52v}|qLX;}K1 z8%<1H5}bGcGA#@*A`ACe3adFeSS65FJ=Fkn>#eRj-xPneR%OM|^T$43 z1-W7@Z)i`htVjOKj}ZC2;1%PzI7DOKU#R=v4hUsXe+XPW#mSYYSh}S^P20&J%Df`! z2xt7)w?+zHvGz%U#Ae$59j#5~o(D3j2tvs|{#_C7MQXkr(B$()ok7p%Vbp&;tqk+b zxdfF$!mKb|O-5X7=h4h_zpbX#v%jjs^2Vgrhk9RK>g|yhHQhABgqPfBE}H}qd#b8f zqK-7{d7~`>*L9O76KQyT4&V2qBr-jPe*@y0xaj(8q{WPj$_A=(Tgq@OkGQQ8psRb_ zub}avtgoPNfSn2E=Nhs70s9fA^&vAgMY9Ex4n2 z$1{wkKo6r`qv3eGAFj07Q)*Dy>o8gd7fU-i0&724SXaeP-|F3gxZE3qAlm-udU{tl zaj)n`Iq~~&F~q&Y&DEaq)k%F8WvI;i_^Hocg5|@uVF_EB)uDud2Bi|ycUw|Ys^C`JB#Px zkvsU`*;izn-bw*a0U{peZZkP&Z!>2H5d8P6aDNc`BxWMwEhcz|p7|1BdX=}1V z|B;IKSRtLzVgPCV?~s93VC_K>zyKN3+;8K^MzW^&%x-}yQb7v- z>fHjl;)#UF&~BY|&jYs65-2aMj+vK)d%ZVwm-9r7Xip>Q_lcfX8hbXOK!fCjdi7~| zc|2~;l9hEs@6Dj%?jfmvp@a`%D>1@HKMAPQFtOP)`rATt(r>wBUJ`&~MsQ!C$5nX)##TGrQ?EW=ap!KdgY?5iXggPKhTutj2YHx@y%6dKKLfbu@eHL7A%P9 z60X}krvw=Mf1sim>`wgPnC88R!B_;$4Vp5>3qFrUst%E> zK9dR>$f*AaD^Scg)7-yCvICfHZLFKD!*vB=OAv`K*UWO7fva=cTV|hta{b4`b+7xb;Fi^0UDp znDwJf*JDmykBREbm$+EM7dsQ&ehA+^{8a86M`o>1C*P!FsA884Skv zfCL0C*?M`Yq;`s<5ch0cc^`CsM~7F&lz1og=u9&JVj~)Ofo06glTTG;)xM%XuqS(G zT4PGo(vsRQa`lmqY|c)XS@?209P>Sgb_J2n_R*G&Bcti$%T6!Wf%V2;z#Ck<_GsN) zCW*w<++aIZ^#&u-7QU6J324{;b@pjz6O|A#>3jF@bIA#XKRTIW61yY%U(+&}ChyR8 zt4p(U-au9_+Vh!2x-@y5TQ6E0#DD&4qR~LMQ=xa#*N%tWutc$A)O+e5ul04ZmV#Qv z`20nzhi~-K3%bL)TH)T=1gB4U2GEOKUHT@W>GYt~GQ`GhhxEx)cJ=zE z0V(XTp#7boFAOFAqYF0uN@$Lke`SCy32iD1VZ}m6M)f}6<6K8HV-@PsBhW7@ye%v+>P9*>iy1C z8&L1-5HN12QD152MLzyb-;U7#6Su~C59oYa(fN#sbJy6;d9R51J`Zb;9scjB0mC2t*21P0v!k=aX5^aWD@K#WSJ2#q<7 z6XrfMI-fVr;i~vDm^2bJ;l7ez?hxIO|HSn5IaXLyDINQFI<{uUS8J@3bU70hzn3gU z)tX*gxkb0JDr-M{c1h?hh*{;7x_(bIr{nj6Us0Fg&!K@6)g7~NbFRx)G7-!Q2%y+W zLJo)JC1Q#7shf{)_3FKF?~^QfKn9$b@uA_QgjI!#|4KCq6 z@LFL`O4%YP(`z}d^SKA(6YuCQ$8Io>b9~V^BT&Ivgr>3iVkBtoxU;GrJ9(m;|wiMRMLjM7@3vOa5U52|G;wq%rLh?mmKbCD_Mb2fLWhMY_*JA=?O=Q8J^yoC!y66pJwoo_mx z5^|;_Jh52kbH-m>V-Rrb(8P42#C4&F(C^Ph(x@o2LVq#l_*rN~eooeaS}VMquV9sD z6(dwoaI(1YDkA3$!j)tZ)|EDjBVI3eo%ux^@vx{cD<>os?mi{6xOwU)ewlhBfWMhyCsZQr|dYII=Rr*ST z9vVDSx+2wt1{eJ#AOC!~D9vI-p`z3}+laD}8Fa=B*QYhR9~Sp1&8DXC+#R8hf0xNn zDoI~0;|v?^%)AEjz2@Q~O@Eo*B+YGy5y_~WPREcfw|M54KJhvAE~0X_@nqyqH^?q? zXOXkCo5*{Y`?|pV9o?SpB|c$XLT}+24hT?Z!JVbUPmMFa0FONR_|ta=X;RhXGp46u zFxcgxxmp&PkA9*+!X1id=Dk^}ljS~Wi7cCQarFB2BH=~Em+Ao?n226*?lgN8=Uv~y zmqTV>q;yAxiNNG@IM?2rD`q`7qy{o~V1>F&naoo<=^wu(=9nlbnor zzNVE&f?RVdc%#w5J39!{Sm#%A-~$lK0`Z#esZLm0Q~}ema*J$J9B;QX+jQu@XDBVl z0(0#i0&(|i+PT}Hb98anxI7JPzZ+8nt>{M_7GI1$n5;{)XB6N7`(`Vma6&Qq%Kmj@Qgbi|;&UbIp`$#<%K}e#ed3O{PkaCQF zsIm-RG_(+w}>$?w9Ro{X5RJCYyvUIyVy0h50z;ckVqtb^k0xyZ-~xb!3G%Ymx2EJ zPdQZuegYAUKq3+Y!}h=#{Zh|IzU7@Q{bs(Q*sA=!u<4@mQJNDRR~S?3Dw=@R7t^fPPk!=y zb>aY9Ze7E&@-4Ykl|F!2<@;iA`*DB4mK8!Y^JQ&eNK~;rjN#>Hfb@jvD-aeeeM%tu zc(EKdZZ2_y2Mr-M8c*-GlrbJ}(H-*)YHl&C0u-0I4H3XQl^GXdZW|gaD2=E&_4pHz z3wJL&dvUXPjw9%nXIO|-|97;bp40g0uyA~e&Lf<$Om8YrniD1E7(kan)dLJOeOp);On8^-n7?{3}tc2B2~Yr0K~YMTU`mW@$Z*&A1j{W;wIV z#SC}DzL1aiE&TF|hQRQzo0^R;#_u&6m`Bw-r{g=x^YV(+$aNDQu93pt%-tL=93dT@ zNf#uoPI!E$1os8$IicMC)eoT*f8|4eQ@lMs`lRMD9=Z)p z!!%%*`#)n29TpceJY6Ah|B6)m~_!R0o9ELy4mZN7GS&C3F?zE%>Hto$?Vl zCuA`b(PM$=7WWy_pPfxDkcj^tCliG5Osl54?=H7z7@#a-&IUccJT%M~PigPrEPZ2e zTuS+l(8}V{CH*Km2^cP?AF`(0A@POo1sZ639z$C3Wm;Wx&|Y8y~2hZj??Uj+LUxX`V*U8UI%=ejO&zLSF^7DB^6s zV*fX-s2lmdJ5HX)1K&bJUnnkue5c(~%y7;)H#2LtZId%R$q9ee;--)RS3L1B!7DNp zXUW(PXx;24|JZq>RHs(3nE01MMq3!ImSNR~aqd)+#7jL)(mQk|oU+WV#jD46G4f=T zG*JqX^KA5)L;hdoIOH|9{P^cPZq*KJtR0s=inAJT5pUm>&eY*Y_d<-PoDY=pVaZb4 zUNM5Vz>|}50nyB48+(%er5FfmOlY_e7>Uh5Vb5YA=ovfLPjV zyw41zk^w~1p=v36AJU8G) z_UDz}TP)OSOS+eh{gDfepga13S`T<#d8hq4K<|<|4|1qMH0#Pj)CwRu%AUR}=Z&;V zkhm?c2E7W_kA*~?jkqIjCCO^l)L?L(%nm>KW`1MpW&x9PF(sl`0Zx` z+KNpttH&(%-q`z?MTwwaEMQOaj^@%z9ALsp!_RaNfYx}xaxq^hYzkvxXTw3P3h(4` zKkEkkv@U%qA)wWNnyv>exv?lKP+S`6&o&xz{r^V5(~GD3qaB{cQ+#1b$^w;x{vg;b z7>i7YRfw{YSR34bNB#o?Yw=}H-eS)GX$r`ILc=}=WssJxKFp)&r!dRCRK831%j%$z zp!6$(l*Z!G`*N@+*;P39l@o@0s$3F2F?g*oze7J5t{)20Pccynnb&`d`~uD(GHFQK z@LbC3GIwQ5TR-iJM)=K3^V*=UiWh?lYQ3{w4q)%b_IfOQvY*wH;GEAy1qS z76{R{{W~(HY-J_5r#gKAC(cX}DZOl~)^bOIRHGZ1{zH}W(U#L1%+GjbvCT`EY~^1x@wCHWy7K{7(F5{UOYzlmVQba0Vcj4O8;qRM(#14*3s?^-D>jYh>#t%i25u`?HZ`d`G$ z@z(JH1(I=oqpccRH88OY3Js3c?i{&ouED_fSPw{tFj_nTNQk+m&j7ji^zQv&sH*nJ z>08f9Rt5r^@vmCJk0fk#r}qrYUD_6c03EdZ$@}BS3Si{} zlj?Z^Wc`(b8mej*DdjA`mr@LzsxixUm*tcIHCS>Ztn2^Nj`1$&drzo;0=zaYP7!Bz z96u^W)z>*H#|+TnffyjxjC?Y?Zfk)X+%*yzdO)cLbfpt2(V0F0`V!ViWji26!H(vS zE|4*b)!c04mN5*eNvF^FLHa>iTAWi}p)+Ht^uw~WRHwX-NZ3Ck(C-j0EIiln?YUxQ z;gh$+WyF7nu6m+$V$zTkkf3Ze^-03aqf#$iy}>G+ZwD_k{%3v|jxp=y!!~zXwy#af zUe4W2QkA_jmla`iqqz&AJ4uRUQ z!I1_@X~akU%OC5KZc+S{Xn9GH;4XtCVFk*Vb>-3}#8rBOq{3%G)HtC78WrH@rdI`7 zEVC3oWZOCgls)sO7P)CBAjDvJ#Jg{1lf=Kb2C`QOyGQeWEAQ%DP_NV8Fw5~N&;62+ z>(RtN?~_l|0K(k_O>}CRpX|BtD*V^P3is+!b4F(n#uz4V&aVg70=-)-Ig_RvfVkRk zc0(_G6J-6az^R)IRO%@$bEC708Nvo)$Di)B5d5s*|5*!gX6Jhka(H%mxsWqX-Q{siU^MK>hBuoW?foYnet(CZG7SPc}}O#R3Pc#cQ33Bm6&i#yj@Zr9i-{z7Mi^x?ID@Fzmw8 zT@bwH2YC1N1-a;?0KQ)ZYFNbl@a7&cPS$Ehh~#Yo%mL@v?eyt8Is?dovvnrIlRtz| UMLJfNLBPK&#->Ie4V)wY57XTnQ2+n{ literal 0 HcmV?d00001 From 03e816044172d99a81e3f4156602cc35951b153a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:58:33 -0800 Subject: [PATCH 21/72] Add the artifact core and the Nova artifact effects Artifacts roll a main effect by the band they were dug in and sometimes a minor second effect, each with its own trigger. A stimulus wakes a matching effect for a while; air and proximity triggers hold it awake while they last. Auras spend charge faster than it refills, pulses fire when the store is full, and touch effects act on whoever touches it. Ported from NovaSector with fixes: planet air is only ever read, oxygen and nitrogen triggers are gone, nothing generates free power, helpful effects never burst when smashed, healing shares one cap per person, harmful effects log like weapons, and the scan lock frees itself. Artifacts do nothing when crated or inside a trader outpost, can be bolted down, and are never saved with a ship. Each energy kind has its own glow, particles, light, hum and wake sound. Unit tests cover pricing, the effect catalogue, triggers, the lock, checkpoint exclusion, the healing cap, cleanup and planet air. --- tgstation.dme | 20 + .../outpost_checkpoint_snapshot.dm | 4 + .../unit_tests/voidcrew_xenoarch_core.dm | 206 +++++++++ voidcrew/modules/xenoarch/core/artifact.dm | 427 +++++++++++++++++- .../xenoarch/core/artifact_appearance.dm | 389 ++++++++++++++++ .../modules/xenoarch/core/artifact_effect.dm | 412 ++++++++++++++++- .../xenoarch/core/artifact_energy_kinds.dm | 304 +++++++++++++ .../modules/xenoarch/core/artifact_helpers.dm | 126 ++++++ voidcrew/modules/xenoarch/effects/blood.dm | 63 +++ .../modules/xenoarch/effects/bodyhorror.dm | 68 +++ voidcrew/modules/xenoarch/effects/cell.dm | 104 +++++ voidcrew/modules/xenoarch/effects/disgust.dm | 33 ++ voidcrew/modules/xenoarch/effects/emp.dm | 19 + voidcrew/modules/xenoarch/effects/feelings.dm | 105 +++++ voidcrew/modules/xenoarch/effects/gas.dm | 58 +++ voidcrew/modules/xenoarch/effects/gravity.dm | 58 +++ .../modules/xenoarch/effects/hallucination.dm | 35 ++ voidcrew/modules/xenoarch/effects/health.dm | 94 ++++ voidcrew/modules/xenoarch/effects/light.dm | 41 ++ voidcrew/modules/xenoarch/effects/noise.dm | 34 ++ .../modules/xenoarch/effects/radiation.dm | 42 ++ voidcrew/modules/xenoarch/effects/sleepy.dm | 35 ++ voidcrew/modules/xenoarch/effects/stun.dm | 38 ++ voidcrew/modules/xenoarch/effects/teleport.dm | 55 +++ .../modules/xenoarch/effects/temperature.dm | 84 ++++ voidcrew/modules/xenoarch/effects/tesla.dm | 26 ++ 26 files changed, 2872 insertions(+), 8 deletions(-) create mode 100644 voidcrew/modules/xenoarch/core/artifact_appearance.dm create mode 100644 voidcrew/modules/xenoarch/core/artifact_energy_kinds.dm create mode 100644 voidcrew/modules/xenoarch/effects/blood.dm create mode 100644 voidcrew/modules/xenoarch/effects/bodyhorror.dm create mode 100644 voidcrew/modules/xenoarch/effects/cell.dm create mode 100644 voidcrew/modules/xenoarch/effects/disgust.dm create mode 100644 voidcrew/modules/xenoarch/effects/emp.dm create mode 100644 voidcrew/modules/xenoarch/effects/feelings.dm create mode 100644 voidcrew/modules/xenoarch/effects/gas.dm create mode 100644 voidcrew/modules/xenoarch/effects/gravity.dm create mode 100644 voidcrew/modules/xenoarch/effects/hallucination.dm create mode 100644 voidcrew/modules/xenoarch/effects/health.dm create mode 100644 voidcrew/modules/xenoarch/effects/light.dm create mode 100644 voidcrew/modules/xenoarch/effects/noise.dm create mode 100644 voidcrew/modules/xenoarch/effects/radiation.dm create mode 100644 voidcrew/modules/xenoarch/effects/sleepy.dm create mode 100644 voidcrew/modules/xenoarch/effects/stun.dm create mode 100644 voidcrew/modules/xenoarch/effects/teleport.dm create mode 100644 voidcrew/modules/xenoarch/effects/temperature.dm create mode 100644 voidcrew/modules/xenoarch/effects/tesla.dm diff --git a/tgstation.dme b/tgstation.dme index beeb8a121f1..ea3c71f9d1d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7732,7 +7732,9 @@ #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_appearance.dm" #include "voidcrew\modules\xenoarch\core\artifact_effect.dm" +#include "voidcrew\modules\xenoarch\core\artifact_energy_kinds.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" @@ -7748,6 +7750,24 @@ #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\blood.dm" +#include "voidcrew\modules\xenoarch\effects\bodyhorror.dm" +#include "voidcrew\modules\xenoarch\effects\cell.dm" +#include "voidcrew\modules\xenoarch\effects\disgust.dm" +#include "voidcrew\modules\xenoarch\effects\emp.dm" +#include "voidcrew\modules\xenoarch\effects\feelings.dm" +#include "voidcrew\modules\xenoarch\effects\gas.dm" +#include "voidcrew\modules\xenoarch\effects\gravity.dm" +#include "voidcrew\modules\xenoarch\effects\hallucination.dm" +#include "voidcrew\modules\xenoarch\effects\health.dm" +#include "voidcrew\modules\xenoarch\effects\light.dm" +#include "voidcrew\modules\xenoarch\effects\noise.dm" +#include "voidcrew\modules\xenoarch\effects\radiation.dm" +#include "voidcrew\modules\xenoarch\effects\sleepy.dm" +#include "voidcrew\modules\xenoarch\effects\stun.dm" +#include "voidcrew\modules\xenoarch\effects\teleport.dm" +#include "voidcrew\modules\xenoarch\effects\temperature.dm" +#include "voidcrew\modules\xenoarch\effects\tesla.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" diff --git a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm index 71dc3b0dffb..58f417f997b 100644 --- a/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm +++ b/voidcrew/modules/player_outposts/outpost_checkpoint_snapshot.dm @@ -48,6 +48,10 @@ GLOBAL_LIST_INIT(outpost_checkpoint_excluded, typecacheof(list( /obj/structure/headpike, // Outpost property that works a ship bay's side rooms, never a ship's fitting. /obj/structure/outpost_yard_droid, + // Dug finds live for the round only: rebuilding one would reroll its effects and refill its + // harvests (voidcrew/modules/xenoarch/). + /obj/machinery/artifact, + /obj/machinery/power/charged_crystal, ))) /** diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm index 321a3bff218..08abd9a3cbc 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm @@ -1 +1,207 @@ // Xenoarchaeology tests: core. +// +// Test files compile before voidcrew/_DEFINES, so the values below are literals. They mirror +// voidcrew/_DEFINES/xenoarch.dm: +// bands (ZONE_*): green 1, yellow 2, red 3 +// tiers: common 1, uncommon 2, rare 3, extreme 4 +// triggers: touch 1, water 2, acid 4, volatile 8, toxin 16, impact 32, energy 64, +// heat 128, cold 256, plasma 512, co2 1024, proximity 2048 +// release methods: "touch", "aura", "pulse" + +/// Sale price: the design's worked examples, the label floor, condition and rounding. +/datum/unit_test/voidcrew_xenoarch_price/Run() + TEST_ASSERT_EQUAL(xenoarch_price_for(1, 1, 1), 250, "green common, perfect label") + TEST_ASSERT_EQUAL(xenoarch_price_for(2, 3, 1), 1125, "yellow rare, perfect label") + TEST_ASSERT_EQUAL(xenoarch_price_for(3, 3, 1), 1750, "red rare, perfect label") + TEST_ASSERT_EQUAL(xenoarch_price_for(3, 4, 1), 2800, "red extreme, perfect label") + TEST_ASSERT_EQUAL(xenoarch_price_for(3, 1, 0), 140, "red common, unlabelled") + TEST_ASSERT_EQUAL(xenoarch_price_for(3, 1, 0.3), 140, "a label under half right pays the unlabelled rate") + TEST_ASSERT_EQUAL(xenoarch_price_for(3, 1, 0.5), 350, "a half-right label pays half") + TEST_ASSERT_EQUAL(xenoarch_price_for(3, 1, 0.5 + 0.3 + 0.2), 700, "a perfect label summed in floating point pays in full") + TEST_ASSERT_EQUAL(xenoarch_price_for(2, 1, 1, TRUE), 225, "damaged halves the price") + TEST_ASSERT_EQUAL(xenoarch_price_for(1, 1, 1, FALSE, 3), 130, "three harvests take 0.8 cubed, rounded to 5") + TEST_ASSERT_EQUAL(xenoarch_price_for(null, 1, 1), 250, "an unknown band prices as green") + TEST_ASSERT_EQUAL(xenoarch_research_points(1, 1), 100, "green research") + TEST_ASSERT_EQUAL(xenoarch_research_points(3, 3), 375, "red rare research") + + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/heal, null, 2) + TEST_ASSERT_EQUAL(artifact_sale_value(find), 0, "a find never dug sold for credits") + find.stamp_origin(2) + TEST_ASSERT(!find.stamp_origin(3), "a second stamp replaced the first") + TEST_ASSERT_EQUAL(artifact_sale_value(find), 135, "an unlabelled yellow uncommon find") + var/trigger_word = artifact_trigger_name(find.main_effect.trigger) + find.apply_label(new /datum/artifact_label("Heals", trigger_word, "None")) + TEST_ASSERT_EQUAL(find.label_accuracy(), 1, "a perfect label did not read as perfect") + TEST_ASSERT_EQUAL(artifact_sale_value(find), 675, "a perfectly labelled yellow uncommon find") + find.apply_label(new /datum/artifact_label("Heals", trigger_word, "Light")) + TEST_ASSERT_EQUAL(artifact_sale_value(find), 540, "a wrong second effect costs a fifth") + find.damaged = TRUE + find.harvests_left = 2 + TEST_ASSERT_EQUAL(artifact_sale_value(find), 215, "damaged and harvested once") + find.research_paid = TRUE + TEST_ASSERT_EQUAL(artifact_research_value(find), 0, "research paid twice for one artifact") + +/// Every rollable effect is complete, every tier can roll, and the free-power fix holds. +/datum/unit_test/voidcrew_xenoarch_catalogue/Run() + var/list/tiers_seen = list() + for(var/datum/artifact_effect/effect_type as anything in artifact_effect_types()) + var/datum/artifact_effect/effect = new effect_type(null, 1) + TEST_ASSERT(effect.tier >= 1 && effect.tier <= 4, "[effect_type] has tier [effect.tier]") + TEST_ASSERT(!effect.minor || effect.tier <= 2, "[effect_type] is minor above uncommon") + TEST_ASSERT(length(effect.allowed_release_methods), "[effect_type] can't release at all") + TEST_ASSERT(effect.release_method in list("touch", "aura", "pulse"), "[effect_type] rolled release [effect.release_method]") + TEST_ASSERT(GLOB.artifact_energy_kinds[effect.energy_kind], "[effect_type] rolled unknown energy kind [effect.energy_kind]") + TEST_ASSERT(effect.trigger in list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048), "[effect_type] rolled trigger [effect.trigger]") + TEST_ASSERT(effect.label_name in artifact_effect_label_names(), "[effect_type]'s label is missing from the labeler words") + TEST_ASSERT(!(effect.helpful && effect.harmful), "[effect_type] is both helpful and harmful") + tiers_seen |= effect.tier + qdel(effect) + for(var/tier in 1 to 4) + TEST_ASSERT(tier in tiers_seen, "no effect can roll at tier [tier]") + TEST_ASSERT(xenoarch_pick_effect_type(tier, 1), "picking a tier [tier] effect found nothing") + TEST_ASSERT(xenoarch_pick_effect_type(null, 3, minor_only = TRUE), "no minor second effect can roll") + var/datum/artifact_effect/tesla/tesla = new(null, 3) + TEST_ASSERT(!(tesla.zap_flags & ZAP_GENERATES_POWER), "the tesla effect powers tesla coils") + qdel(tesla) + + // Deeper bands roll stronger effects (2000 rolls each; the means are about 1.5 and 2.15). + var/green_total = 0 + var/red_total = 0 + for(var/i in 1 to 2000) + green_total += xenoarch_roll_tier(1) + red_total += xenoarch_roll_tier(3) + TEST_ASSERT(red_total > green_total * 1.2, "red tier rolls ([red_total]) are not clearly above green ([green_total])") + +/// Triggers: only the matching stimulus wakes an effect, wakes lapse, and a crated find never stirs. +/datum/unit_test/voidcrew_xenoarch_triggers/Run() + TEST_ASSERT_EQUAL(artifact_reagent_trigger(/datum/reagent/water), 2, "water is not a water trigger") + TEST_ASSERT_EQUAL(artifact_reagent_trigger(/datum/reagent/hydrogen), 2, "hydrogen is not a water trigger") + TEST_ASSERT_EQUAL(artifact_reagent_trigger(/datum/reagent/toxin/acid), 4, "acid is not an acid trigger") + TEST_ASSERT_EQUAL(artifact_reagent_trigger(/datum/reagent/toxin/plasma), 8, "liquid plasma is not volatile") + TEST_ASSERT_EQUAL(artifact_reagent_trigger(/datum/reagent/toxin), 16, "toxin is not a toxin trigger") + TEST_ASSERT_EQUAL(artifact_reagent_trigger(/datum/reagent/consumable/sugar), 0, "sugar set something off") + TEST_ASSERT_EQUAL(artifact_reagent_triggers(list(/datum/reagent/water, /datum/reagent/toxin/acid)), 6, "a mixture should count as both") + + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/temperature/heat, /datum/artifact_effect/light, 1) + find.main_effect.trigger = 2 + find.second_effect.trigger = 32 + TEST_ASSERT(!find.stimulate(1), "touch matched a water trigger") + TEST_ASSERT(!find.is_active(), "a wrong stimulus woke it") + TEST_ASSERT(find.stimulate(2), "water did not match a water trigger") + TEST_ASSERT(find.main_effect.activated, "water did not wake the main effect") + TEST_ASSERT(!find.second_effect.activated, "water woke the impact-triggered second effect") + TEST_ASSERT(find.shown_awake, "the artifact does not look awake") + TEST_ASSERT_EQUAL(find.icon_state, "[find.shape]_active", "the artifact did not switch to its active sprite") + find.main_effect.awake_until = world.time - 1 + find.process(2) + TEST_ASSERT(!find.main_effect.activated, "an expired wake did not lapse") + TEST_ASSERT_EQUAL(find.icon_state, find.shape, "the artifact kept its active sprite asleep") + + // The glove rule. + var/mob/living/carbon/human/consistent/tester = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT(artifact_touch_counts(tester), "a bare hand did not count as touch") + var/obj/item/clothing/gloves/latex/latex = allocate(/obj/item/clothing/gloves/latex) + tester.equip_to_slot_if_possible(latex, ITEM_SLOT_GLOVES) + TEST_ASSERT(!artifact_touch_counts(tester), "latex gloves counted as touch") + qdel(latex) + var/obj/item/clothing/gloves/color/yellow/insulated = allocate(/obj/item/clothing/gloves/color/yellow) + tester.equip_to_slot_if_possible(insulated, ITEM_SLOT_GLOVES) + TEST_ASSERT(artifact_touch_counts(tester), "ordinary gloves did not count as touch") + find.main_effect.trigger = 1 + find.touched_by(tester) + TEST_ASSERT(find.main_effect.activated, "a gloved hand did not wake a touch trigger") + + // Proximity is held while someone stands near, and only then. + find.main_effect.deactivate(quiet = TRUE) + find.main_effect.trigger = 2048 + tester.moveToNullspace() + find.process(2) + TEST_ASSERT(!find.main_effect.activated, "proximity woke with nobody near") + tester.forceMove(get_step(find, EAST)) + find.process(2) + TEST_ASSERT(find.main_effect.activated, "proximity did not wake with someone beside it") + + // Crated, it goes still and ignores everything. + var/obj/structure/closet/crate/specimen/crate = allocate(/obj/structure/closet/crate/specimen, get_step(find, NORTH)) + find.forceMove(crate) + TEST_ASSERT(!find.is_active(), "a crated find stayed awake") + TEST_ASSERT(!find.stimulate(2048), "a crated find took a stimulus") + find.process(2) + TEST_ASSERT(!find.is_active(), "a crated find woke from proximity") + +/// The analyser and harvester lock frees itself on release, on its holder's deletion and on timeout. +/datum/unit_test/voidcrew_xenoarch_lock/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + var/datum/analyser = allocate(/datum) + var/datum/harvester = allocate(/datum) + TEST_ASSERT(find.try_lock(analyser), "a free artifact refused a lock") + TEST_ASSERT(find.is_being_used(), "a locked artifact reads as free") + TEST_ASSERT(!find.try_lock(harvester), "a second machine took a held lock") + TEST_ASSERT(!find.release_lock(harvester), "a machine released someone else's lock") + TEST_ASSERT(find.release_lock(analyser), "a halted scan could not release its lock") + TEST_ASSERT(!find.is_being_used(), "releasing left the artifact locked") + TEST_ASSERT(find.try_lock(harvester), "a released artifact refused a new lock") + qdel(harvester) + TEST_ASSERT(!find.is_being_used(), "a deleted machine kept its lock") + TEST_ASSERT(find.try_lock(analyser, 0), "could not take a lock") + TEST_ASSERT(!find.is_being_used(), "a lock outlived its timeout") + +/// Finds never ride along in a ship checkpoint or save. +/datum/unit_test/voidcrew_xenoarch_checkpoints/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + TEST_ASSERT(!outpost_checkpoint_saves(find), "a checkpoint would save a loose artifact") + find.set_anchored(TRUE) + TEST_ASSERT(!outpost_checkpoint_saves(find), "a checkpoint would save a bolted artifact") + TEST_ASSERT_NULL(find.checkpoint_type(), "an artifact has a checkpoint rebuild type") + var/obj/machinery/power/charged_crystal/crystal = allocate(/obj/machinery/power/charged_crystal) + TEST_ASSERT(!outpost_checkpoint_saves(crystal), "a checkpoint would save a charged crystal") + +/// Artifact healing shares one cap per person, whatever does the healing. +/datum/unit_test/voidcrew_xenoarch_heal_cap/Run() + var/mob/living/carbon/human/consistent/patient = allocate(/mob/living/carbon/human/consistent) + patient.take_overall_damage(60, 60) + var/healed = 0 + for(var/i in 1 to 6) + healed += artifact_capped_heal(patient, 12) + TEST_ASSERT(healed <= 30.5, "artifact healing went past the cap ([healed])") + TEST_ASSERT(healed >= 29.5, "artifact healing stopped short of the cap ([healed])") + TEST_ASSERT_EQUAL(artifact_capped_heal(patient, 50), 0, "a fresh call slipped past the cap") + +/// Deleting an artifact deletes its effects and leaves them no holder. +/datum/unit_test/voidcrew_xenoarch_cleanup/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/heal, /datum/artifact_effect/light, 1) + var/datum/artifact_effect/main = find.main_effect + var/datum/artifact_effect/second = find.second_effect + main.wake(1 MINUTES) + second.wake(1 MINUTES) + qdel(find) + TEST_ASSERT(QDELETED(main), "the main effect outlived its artifact") + TEST_ASSERT(QDELETED(second), "the second effect outlived its artifact") + TEST_ASSERT_NULL(main.holder, "the main effect kept its artifact") + +/** + * An artifact idling, or running heat and gas, on a planet never gives a planet turf private air. + * Uses the shared planet air room from voidcrew_planet_shared_air.dm. + */ +/datum/unit_test/voidcrew_planet_shared_air/xenoarch_idle_artifact/Run() + if(!planetary_atmos_shared_mix_enabled()) + return + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, center) + find.replace_effects(/datum/artifact_effect/temperature/heat, /datum/artifact_effect/gas, 3) + // Air triggers read the air every process. + find.main_effect.trigger = 128 + find.second_effect.trigger = 1024 + TEST_ASSERT(room_is_pristine(), "placing an artifact gave the planet private air") + for(var/i in 1 to 3) + find.process(2) + TEST_ASSERT(room_is_pristine(), "an idle artifact gave the planet private air") + find.main_effect.release_method = "aura" + find.second_effect.release_method = "aura" + find.main_effect.wake(1 MINUTES) + find.second_effect.wake(1 MINUTES) + for(var/i in 1 to 3) + find.process(2) + TEST_ASSERT(room_is_pristine(), "heat or gas running on a planet gave it private air") diff --git a/voidcrew/modules/xenoarch/core/artifact.dm b/voidcrew/modules/xenoarch/core/artifact.dm index c609d116946..02b38852dc4 100644 --- a/voidcrew/modules/xenoarch/core/artifact.dm +++ b/voidcrew/modules/xenoarch/core/artifact.dm @@ -1,15 +1,34 @@ /** * # 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. + * Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/artifacts/artifact_machine.dm, + * reworked for Voidcrew: + * - Effects roll by the band the find was dug in (tier odds and strength), and the find is + * stamped with that band, a catalogue serial, and later a crew's label. + * - A stimulus wakes a matching effect for a while (ARTIFACT_WAKE_MIN to _MAX); level triggers + * (heat, cold, plasma or CO2 in the air, someone nearby) hold it awake while they last. + * Oxygen and nitrogen triggers are gone. Air is read with return_air_readonly(), so an idle + * artifact never gives a planet turf its own private air. + * - It does nothing off a turf (crated, carried in a mech) or inside a trader outpost. + * - Anyone can bolt it down with a wrench, except inside a trader outpost. + * - Scan and harvest machines lock it through try_lock()/release_lock(); a lock frees itself + * when its holder is deleted or after ARTIFACT_LOCK_TIMEOUT (Nova's halt never unlocked it). + * - Smashing it lets harmful effects off in one last burst, never helpful ones, and an admin + * delete or planet unload does nothing at all. + * + * Looks, sounds and examine text are in artifact_appearance.dm. */ /obj/machinery/artifact name = "alien artifact" desc = "A large object of no make you recognise." + icon = 'voidcrew/modules/xenoarch/icons/artifacts.dmi' + icon_state = "pylon" density = TRUE anchored = FALSE use_power = NO_POWER_USE + // No UI and no power: a hand on it is a touch, nothing else. + interaction_flags_atom = NONE + interaction_flags_machine = INTERACT_MACHINE_OFFLINE max_integrity = 1000 resistance_flags = LAVA_PROOF | FIRE_PROOF | ACID_PROOF @@ -29,6 +48,77 @@ var/datum/artifact_effect/main_effect /// A minor second effect, or null var/datum/artifact_effect/second_effect + /// Rolls its own effects on Initialize. Finds that are not true artifacts turn this off. + var/rolls_effects = TRUE + + /// How long one contact stimulus keeps an effect awake; rolled once per artifact + var/wake_duration = ARTIFACT_WAKE_MIN + /// Whoever last woke, struck, splashed or hauled it. Logged for any harm it does. + var/datum/weakref/last_disturbed_by + /// Last time a touch counted, so hauling and bumping count once per ARTIFACT_TOUCH_COOLDOWN + var/last_touch_time = 0 + /// Effects are held asleep: it is off a turf or inside a trader outpost + var/suppressed = FALSE + + /// The scan or harvest machine using it, if any + var/datum/weakref/locked_by + /// world.time the lock lapses if never released + var/lock_expires = 0 + +/obj/machinery/artifact/Initialize(mapload, band) + . = ..() + serial = xenoarch_new_serial() + wake_duration = rand(ARTIFACT_WAKE_MIN, ARTIFACT_WAKE_MAX) + setup_shape() + if(rolls_effects) + roll_effects(band) + if(!isnull(band)) + stamp_origin(band) + suppressed = is_suppressed() + update_activity_visuals() + +/obj/machinery/artifact/Destroy() + locked_by = null + last_disturbed_by = null + clear_activity_visuals() + QDEL_NULL(main_effect) + QDEL_NULL(second_effect) + QDEL_NULL(label) + return ..() + +/// Both effects, skipping a missing second. +/obj/machinery/artifact/proc/effects() + . = list() + if(main_effect) + . += main_effect + if(second_effect) + . += second_effect + +/// Any effect awake. +/obj/machinery/artifact/proc/is_active() + return main_effect?.activated || second_effect?.activated + +// ===== Rolling ===== + +/// Rolls a main effect from the band's tier odds and, half the time, a minor second effect. +/obj/machinery/artifact/proc/roll_effects(band) + var/main_type = xenoarch_pick_effect_type(xenoarch_roll_tier(band), band) + var/second_type + if(main_type && prob(ARTIFACT_SECOND_EFFECT_CHANCE)) + second_type = xenoarch_pick_effect_type(null, band, minor_only = TRUE, exclude = main_type) + replace_effects(main_type, second_type, band) + +/// Swaps both effects for new ones of the given types (second may be null). For rolling, tests and admins. +/obj/machinery/artifact/proc/replace_effects(main_type, second_type, band) + QDEL_NULL(main_effect) + QDEL_NULL(second_effect) + if(main_type) + main_effect = new main_type(src, band) + if(second_type) + second_effect = new second_type(src, band) + update_activity_visuals(force = TRUE) + +// ===== Seams read by other parts ===== /// Whether it came out of a dig site. Buyers refuse anything else. /obj/machinery/artifact/proc/is_dug() @@ -75,3 +165,336 @@ origin_band = XENOARCH_BAND_INDEX(band) SEND_SIGNAL(src, COMSIG_ARTIFACT_STAMPED, origin_band) return TRUE + +// ===== Scan and harvest lock ===== + +/** + * Claims it for a scan or harvest. Fails while another live machine holds it. The lock frees + * itself when its holder is deleted or after `duration`, so a crashed or halted scan can never + * lock an artifact for good. + */ +/obj/machinery/artifact/proc/try_lock(datum/locker, duration = ARTIFACT_LOCK_TIMEOUT) + if(!locker) + return FALSE + if(is_being_used() && locked_by.resolve() != locker) + return FALSE + locked_by = WEAKREF(locker) + lock_expires = world.time + duration + return TRUE + +/// Frees the lock. With a `locker`, only if that is who holds it. +/obj/machinery/artifact/proc/release_lock(datum/locker) + if(!locked_by) + return FALSE + var/datum/holder = locked_by.resolve() + if(locker && holder && holder != locker) + return FALSE + locked_by = null + lock_expires = 0 + return TRUE + +/// Whether a live machine holds the lock (Nova's being_used). +/obj/machinery/artifact/proc/is_being_used() + if(!locked_by) + return FALSE + var/datum/holder = locked_by.resolve() + if(QDELETED(holder) || world.time >= lock_expires) + locked_by = null + lock_expires = 0 + return FALSE + return TRUE + +// ===== Stimuli ===== + +/// Remembers who set it off, for the combat log. +/obj/machinery/artifact/proc/note_disturbance(mob/user) + if(ismob(user)) + last_disturbed_by = WEAKREF(user) + +/** + * A stimulus reached it. Wakes every effect whose trigger matches. + * Returns TRUE if anything matched. + */ +/obj/machinery/artifact/proc/stimulate(trigger, mob/user) + if(suppressed || QDELETED(src)) + return FALSE + note_disturbance(user) + var/woke = FALSE + for(var/datum/artifact_effect/effect as anything in effects()) + if(effect.trigger == trigger) + effect.wake(wake_duration) + woke = TRUE + SEND_SIGNAL(src, COMSIG_ARTIFACT_STIMULATED, trigger, user, woke) + return woke + +/// Wakes every effect a bitfield of triggers matches. +/obj/machinery/artifact/proc/stimulate_all(triggers, mob/user) + . = FALSE + for(var/trigger in ARTIFACT_ALL_TRIGGERS) + if(triggers & trigger) + . = stimulate(trigger, user) || . + +/** + * Someone laid a hand on it, or brushed it (bumped, hauled). A bare or non-latex hand wakes + * touch effects; any touch lets an awake contact effect act on them. + */ +/obj/machinery/artifact/proc/touched_by(mob/living/user, accidental = FALSE) + if(!isliving(user)) + return + if(accidental && world.time < last_touch_time + ARTIFACT_TOUCH_COOLDOWN) + return + last_touch_time = world.time + if(!accidental) + to_chat(user, span_notice("You run your hand over [src].")) + if(artifact_touch_counts(user)) + stimulate(ARTIFACT_TRIGGER_TOUCH, user) + else + note_disturbance(user) + if(suppressed) + return + for(var/datum/artifact_effect/effect as anything in effects()) + if(accidental && prob(50)) + continue + effect.touch(user) + +/obj/machinery/artifact/attack_hand(mob/living/user, list/modifiers) + . = ..() + if(. || !user.Adjacent(src)) + return + touched_by(user) + return TRUE + +/obj/machinery/artifact/attack_robot(mob/user) + if(!user.Adjacent(src)) + return ..() + touched_by(user) + return TRUE + +/obj/machinery/artifact/attack_paw(mob/living/user, list/modifiers) + if(!user.Adjacent(src)) + return ..() + touched_by(user) + return TRUE + +/obj/machinery/artifact/Bumped(atom/movable/bumped_atom) + . = ..() + if(isliving(bumped_atom)) + touched_by(bumped_atom, accidental = TRUE) + +/obj/machinery/artifact/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + refresh_suppression() + // Hauling it counts as touching it (Nova did this through Move() and Bumped()). + if(isliving(pulledby)) + touched_by(pulledby, accidental = TRUE) + +/obj/machinery/artifact/attacked_by(obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) + note_disturbance(user) + . = ..() + if(QDELETED(src)) + return + if(attacking_item.force > 0) + stimulate(ARTIFACT_TRIGGER_IMPACT, user) + if(artifact_tool_is_energetic(attacking_item)) + stimulate(ARTIFACT_TRIGGER_ENERGY, user) + if(attacking_item.get_temperature() > ARTIFACT_HEAT_TOOL_KELVIN) + stimulate(ARTIFACT_TRIGGER_HEAT, user) + +/obj/machinery/artifact/hitby(atom/movable/hit_by, skipcatch, hitpush, blocked, datum/thrownthing/throwingdatum) + var/mob/thrower = throwingdatum?.get_thrower() + note_disturbance(thrower) + . = ..() + if(QDELETED(src)) + return + var/hard_enough = isliving(hit_by) + if(isobj(hit_by)) + var/obj/thrown_object = hit_by + hard_enough = thrown_object.throwforce >= ARTIFACT_IMPACT_THROWFORCE + if(hard_enough) + stimulate(ARTIFACT_TRIGGER_IMPACT, thrower) + +/obj/machinery/artifact/bullet_act(obj/projectile/hitting_projectile, def_zone, piercing_hit = FALSE) + var/mob/firer = hitting_projectile.firer + note_disturbance(firer) + . = ..() + if(QDELETED(src)) + return + if(hitting_projectile.armor_flag == LASER || hitting_projectile.armor_flag == ENERGY) + stimulate(ARTIFACT_TRIGGER_ENERGY, firer) + else if(hitting_projectile.damage > 0) + stimulate(ARTIFACT_TRIGGER_IMPACT, firer) + +/obj/machinery/artifact/take_damage(damage_amount, damage_type = BRUTE, damage_flag = "", sound_effect = TRUE, attack_dir, armour_penetration = 0) + . = ..() + if(QDELETED(src)) + return + // Hits with a known culprit are handled where they land; these are fires, blasts and arcs. + switch(damage_flag) + if(FIRE) + stimulate(ARTIFACT_TRIGGER_HEAT) + if(ENERGY, LASER) + stimulate(ARTIFACT_TRIGGER_ENERGY) + if(BOMB) + stimulate(ARTIFACT_TRIGGER_IMPACT) + stimulate(ARTIFACT_TRIGGER_HEAT) + +/obj/machinery/artifact/emp_act(severity) + . = ..() + if(!(. & EMP_PROTECT_SELF)) + stimulate(ARTIFACT_TRIGGER_ENERGY) + +/obj/machinery/artifact/fire_act(exposed_temperature, exposed_volume) + . = ..() + if(exposed_temperature > ARTIFACT_HEAT_AIR_KELVIN) + stimulate(ARTIFACT_TRIGGER_HEAT) + +/obj/machinery/artifact/expose_reagents(list/reagents, datum/reagents/source, methods = TOUCH, volume_modifier = 1, show_message = TRUE) + . = ..() + if(. & COMPONENT_NO_EXPOSE_REAGENTS) + return + stimulate_all(artifact_reagent_triggers(reagents)) + +/obj/machinery/artifact/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(is_reagent_container(tool) && tool.is_drainable()) + return splash_with(tool, user) + if(artifact_tool_is_energetic(tool)) + user.visible_message( + span_notice("[user] presses [tool] against [src]."), + span_notice("You press [tool] against [src]."), + ) + playsound(src, 'sound/effects/sparks/sparks1.ogg', 40, TRUE) + stimulate(ARTIFACT_TRIGGER_ENERGY, user) + if(tool.get_temperature() > ARTIFACT_HEAT_TOOL_KELVIN) + stimulate(ARTIFACT_TRIGGER_HEAT, user) + return ITEM_INTERACT_SUCCESS + if(tool.get_temperature() > ARTIFACT_HEAT_TOOL_KELVIN) + return heat_with(tool, user) + return NONE + +/obj/machinery/artifact/welder_act(mob/living/user, obj/item/tool) + if(tool.get_temperature() <= ARTIFACT_HEAT_TOOL_KELVIN) + return NONE + return heat_with(tool, user) + +/// Holds a hot tool to it. +/obj/machinery/artifact/proc/heat_with(obj/item/tool, mob/living/user) + user.visible_message( + span_notice("[user] holds [tool] to [src]."), + span_notice("You hold [tool] to [src] until the surface ticks with heat."), + ) + playsound(src, 'sound/items/tools/welder.ogg', 30, TRUE) + stimulate(ARTIFACT_TRIGGER_HEAT, user) + return ITEM_INTERACT_SUCCESS + +/// Splashes a little of an open container over it. +/obj/machinery/artifact/proc/splash_with(obj/item/container, mob/living/user) + var/datum/reagents/held = container.reagents + if(!held?.total_volume) + balloon_alert(user, "empty!") + return ITEM_INTERACT_BLOCKING + note_disturbance(user) + var/amount = min(held.total_volume, 10) + user.visible_message( + span_notice("[user] splashes [src] with the contents of [container]."), + span_notice("You splash [src] with [container]."), + ) + playsound(src, 'sound/effects/slosh.ogg', 25, TRUE) + held.expose(src, TOUCH, amount / held.total_volume) + held.remove_all(amount) + return ITEM_INTERACT_SUCCESS + +/obj/machinery/artifact/wrench_act(mob/living/user, obj/item/tool) + if(istype(get_area(src), /area/voidcrew/trader_outpost)) + balloon_alert(user, "the deck won't take bolts!") + return ITEM_INTERACT_BLOCKING + if(default_unfasten_wrench(user, tool, 4 SECONDS) == SUCCESSFUL_UNFASTEN) + playsound(src, 'sound/machines/clockcult/integration_cog_install.ogg', 30, TRUE) + return ITEM_INTERACT_SUCCESS + +/// Whether a tool counts as electrical contact: a live stun baton or a lit energy blade. +/proc/artifact_tool_is_energetic(obj/item/tool) + if(istype(tool, /obj/item/melee/baton/security)) + var/obj/item/melee/baton/security/baton = tool + return baton.active + if(istype(tool, /obj/item/melee/energy)) + return HAS_TRAIT(tool, TRAIT_TRANSFORM_ACTIVE) + return FALSE + +// ===== Processing ===== + +/obj/machinery/artifact/process(seconds_per_tick) + for(var/datum/artifact_effect/effect as anything in effects()) + effect.recharge(seconds_per_tick) + if(refresh_suppression()) + return + check_level_triggers() + for(var/datum/artifact_effect/effect as anything in effects()) + effect.tick(seconds_per_tick) + +/// Off a turf, or inside a trader outpost, it does nothing at all. +/obj/machinery/artifact/proc/is_suppressed() + return !isturf(loc) || is_trader_outpost_protected(src) + +/// Re-reads suppression; puts everything to sleep when it starts. Returns TRUE while suppressed. +/obj/machinery/artifact/proc/refresh_suppression() + var/now_suppressed = is_suppressed() + if(now_suppressed == suppressed) + return suppressed + suppressed = now_suppressed + if(suppressed) + // A crate lid or an outpost's dampers: it goes still without a show. + for(var/datum/artifact_effect/effect as anything in effects()) + effect.deactivate(quiet = !isturf(loc)) + update_activity_visuals() + return suppressed + +/** + * Air and proximity triggers. Reads the tile's air without materializing it: a planet turf + * still on its shared mix stays on it. + */ +/obj/machinery/artifact/proc/check_level_triggers() + var/wanted = NONE + for(var/datum/artifact_effect/effect as anything in effects()) + if(effect.trigger & ARTIFACT_TRIGGERS_LEVEL) + wanted |= effect.trigger + if(!wanted) + return + var/met = NONE + if(wanted & ARTIFACT_TRIGGERS_AIR) + var/turf/here = get_turf(src) + var/datum/gas_mixture/air = here?.return_air_readonly() + if(air) + if(air.temperature > ARTIFACT_HEAT_AIR_KELVIN) + met |= ARTIFACT_TRIGGER_HEAT + if(air.temperature < ARTIFACT_COLD_AIR_KELVIN) + met |= ARTIFACT_TRIGGER_COLD + var/list/gases = air.gases + if(gases[/datum/gas/plasma]?[MOLES] >= ARTIFACT_GAS_TRIGGER_MOLES) + met |= ARTIFACT_TRIGGER_PLASMA + if(gases[/datum/gas/carbon_dioxide]?[MOLES] >= ARTIFACT_GAS_TRIGGER_MOLES) + met |= ARTIFACT_TRIGGER_CO2 + if(wanted & ARTIFACT_TRIGGER_PROXIMITY) + for(var/mob/living/nearby in view(ARTIFACT_PROXIMITY_RANGE, src)) + if(nearby.stat != DEAD) + met |= ARTIFACT_TRIGGER_PROXIMITY + break + if(!met) + return + for(var/datum/artifact_effect/effect as anything in effects()) + if(effect.trigger & met) + effect.wake(ARTIFACT_LEVEL_LINGER) + +// ===== Destruction ===== + +/obj/machinery/artifact/atom_destruction(damage_flag) + var/mob/culprit = last_disturbed_by?.resolve() + visible_message( + span_danger("[src] cracks apart with a sound like a bell breaking!"), + blind_message = span_hear("You hear something ring and shatter."), + ) + playsound(src, 'sound/effects/glass/glassbr3.ogg', 70, TRUE) + if(!suppressed) + log_game("Artifact [serial] ([main_effect?.log_name]) was smashed at [AREACOORD(src)][culprit ? " after [key_name(culprit)] disturbed it" : ""].") + for(var/datum/artifact_effect/effect as anything in effects()) + effect.do_effect_destroy() + return ..() diff --git a/voidcrew/modules/xenoarch/core/artifact_appearance.dm b/voidcrew/modules/xenoarch/core/artifact_appearance.dm new file mode 100644 index 00000000000..6f578ebe3a5 --- /dev/null +++ b/voidcrew/modules/xenoarch/core/artifact_appearance.dm @@ -0,0 +1,389 @@ +/** + * # Artifact looks, sounds and examine text + * + * Shapes come from icons/artifacts.dmi, assembled from clean sources only (CC BY-SA 3.0): + * NovaSector 5ce2bd2215c0 artifacts.dmi states 7-13 (pylon, terminal, vents, growth, three + * crystals), Polaris 6cd19fe0dec6 xenoarchaeology.dmi ano10/70/80/120/130/140/150 (torus, + * monolith, spire, idol, cradle, effigy, spindle), and Aurora's crystal_madness (shard; its + * active frames were drawn for Voidcrew). No Goon art: Nova artifact_1-6 and Bay/Polaris + * ano00/20/30/40/50 are Goon copies and are not used. + * + * While it sleeps an artifact breathes a faint glow in its energy kind's colour. Awake, it plays + * its shape's active animation, glows brighter, sheds particles, lights the room a little and + * hums. Waking, falling asleep and each burst it releases have their own small moment. + */ + +/// Shape key -> list("name", "descs", "tag" = list(pixel_x, pixel_y) for the label sticker) +GLOBAL_LIST_INIT(artifact_shapes, list( + "pylon" = list( + "name" = "alien pylon", + "descs" = list( + "A grey orb held above a slim stand by nothing you can see.", + "A metal stand topped with a perfectly smooth grey sphere.", + ), + "tag" = list(15, -22), + ), + "terminal" = list( + "name" = "alien terminal", + "descs" = list( + "A dark glass screen in a heavy frame, lettered in a script nobody reads.", + "It looks like a console. The keys are the wrong shape for any hand you know of.", + ), + "tag" = list(22, -22), + ), + "vents" = list( + "name" = "vented casing", + "descs" = list( + "A heavy grey casing, louvred down one side.", + "A squat metal housing with vents along its flank. Nothing comes out of them.", + ), + "tag" = list(23, -22), + ), + "growth" = list( + "name" = "fronded mass", + "descs" = list( + "A tangle of hard green fronds around a single lidded eye.", + "Green fronds, like metal grown as a plant, curl around a closed eye.", + ), + "tag" = list(21, -21), + ), + "crystal_green" = list( + "name" = "large crystal", + "descs" = list( + "A green crystal the size of a dog. It shines faintly as it catches the light.", + "A green crystal with a faint glow somewhere deep inside.", + ), + "tag" = list(17, -21), + ), + "crystal_violet" = list( + "name" = "large crystal", + "descs" = list( + "A violet crystal the size of a dog. Something twinkles deep inside it.", + "A violet crystal, cold and heavy, with a faint inner glow.", + ), + "tag" = list(17, -21), + ), + "crystal_blue" = list( + "name" = "large crystal", + "descs" = list( + "A blue crystal the size of a dog. It shines faintly as it catches the light.", + "A blue crystal, clear near the edges and cloudy at the heart.", + ), + "tag" = list(17, -21), + ), + "torus" = list( + "name" = "black torus", + "descs" = list( + "A ring of black stone, cold and smooth. The hole looks deeper than the ring is thick.", + "A small black ring, far heavier than it has any right to be.", + ), + "tag" = list(15, -14), + ), + "monolith" = list( + "name" = "green monolith", + "descs" = list( + "A leaning slab of green stone, striped with darker veins.", + "A green slab with grooves down its face, too regular to be weathering.", + ), + "tag" = list(20, -23), + ), + "spire" = list( + "name" = "violet spire", + "descs" = list( + "A twisted violet spire, ridged like horn.", + "A curled violet spike. The ridges on it are warm to the touch.", + ), + "tag" = list(22, -23), + ), + "idol" = list( + "name" = "sandstone idol", + "descs" = list( + "A weathered figure with its arms raised. Its face has been worn away.", + "A sandstone figure holding up two empty hands.", + ), + "tag" = list(17, -17), + ), + "cradle" = list( + "name" = "black cradle", + "descs" = list( + "A forked pedestal of black metal, shaped to hold something that isn't there.", + "Two black prongs rising from a heavy base. Whatever they held is gone.", + ), + "tag" = list(14, -22), + ), + "effigy" = list( + "name" = "pale effigy", + "descs" = list( + "A smooth white figure the height of a child. It has no mouth.", + "A pale figure standing very straight. Its eyes are two dark beads.", + ), + "tag" = list(16, -23), + ), + "spindle" = list( + "name" = "red spindle", + "descs" = list( + "A red crystal turning slowly in place, touching nothing.", + "A spindle of red crystal that hangs a hand's width off the ground and turns.", + ), + "tag" = list(14, -19), + ), + "shard" = list( + "name" = "pale shard", + "descs" = list( + "A cluster of pale blue crystal growing out of a dark stone.", + "Pale blue crystals jut from a lump of black rock like broken glass.", + ), + "tag" = list(16, -23), + ), +)) + +/// Close-up details; each artifact keeps two. +GLOBAL_LIST_INIT(artifact_examine_notes, list( + "It is heavier than it looks.", + "There are no seams, bolts or tool marks anywhere on it.", + "It is a few degrees warmer than the air around it.", + "Grit from the dig is still packed into its crevices.", + "Up close, the surface has a fine grain, like fingerprints.", + "It doesn't ring when tapped. It doesn't make any sound at all.", + "Dust doesn't seem to settle on it.", + "Your reflection in it is a moment late.", +)) + +/// Lines for falling asleep: "[artifact] [line]" +#define ARTIFACT_SLEEP_LINES list("grows dull.", "fades.", "goes very still.", "falls quiet.") + +/// Glow levels, faintest first. +#define ARTIFACT_GLOW_NONE 0 +#define ARTIFACT_GLOW_IDLE 1 +#define ARTIFACT_GLOW_RESTING 2 +#define ARTIFACT_GLOW_AWAKE 3 + +/obj/machinery/artifact + /// GLOB.artifact_shapes key. Set on a subtype or map to fix the shape; random otherwise. + var/shape + /// Two close-up details for examine_more + var/list/examine_notes + /// Glow level and colour now showing, so the filter is only rebuilt when they change + var/glow_level = ARTIFACT_GLOW_NONE + var/glow_color + /// Shared particle type now attached + var/particles_shown + /// Hum while awake + var/datum/looping_sound/hum + /// Shown on the last visual refresh: awake at all + var/shown_awake = FALSE + +/// Picks a shape if none is set and takes its name, description and icon. +/obj/machinery/artifact/proc/setup_shape() + if(!GLOB.artifact_shapes[shape]) + shape = pick(GLOB.artifact_shapes) + var/list/shape_data = GLOB.artifact_shapes[shape] + name = shape_data["name"] + desc = pick(shape_data["descs"]) + var/list/notes = GLOB.artifact_examine_notes.Copy() + examine_notes = list() + for(var/i in 1 to 2) + examine_notes += pick_n_take(notes) + update_appearance() + +/obj/machinery/artifact/update_icon_state() + icon_state = "[shape][shown_awake ? "_active" : ""]" + return ..() + +/obj/machinery/artifact/update_overlays() + . = ..() + if(!label) + return + var/list/tag_offset = GLOB.artifact_shapes[shape]?["tag"] || list(16, -22) + var/mutable_appearance/sticker = mutable_appearance(icon, "label_tag") + sticker.pixel_x = tag_offset[1] + sticker.pixel_y = tag_offset[2] + . += sticker + +/obj/machinery/artifact/examine(mob/user) + . = ..() + if(is_dug()) + . += "A field tag wired to it reads [serial]." + if(label) + . += label.examine_text() + if(damaged) + . += span_warning("Cracks run through it where it came out of the rock badly.") + if(anchored) + . += "It is bolted to the deck." + if(!shown_awake) + return + var/datum/artifact_energy_kind/kind = visual_kind() + if(kind) + . += span_notice(pick(kind.awake_examine)) + if(all_awake_resting()) + . += "Its glow gutters, as if gathering itself." + +/obj/machinery/artifact/examine_more(mob/user) + . = ..() + . += examine_notes + +/// The kind that sets its look: the main effect's, or the second's if it has no main. +/obj/machinery/artifact/proc/visual_kind() + var/datum/artifact_effect/lead = main_effect || second_effect + return lead ? artifact_energy_kind(lead.energy_kind) : null + +/// Every awake effect is an aura resting between runs. +/obj/machinery/artifact/proc/all_awake_resting() + var/any_awake = FALSE + for(var/datum/artifact_effect/effect as anything in effects()) + if(!effect.activated) + continue + any_awake = TRUE + if(!effect.exhausted) + return FALSE + return any_awake + +// ===== Moments ===== + +/// An effect woke. The first one to wake brings the whole artifact to life. +/obj/machinery/artifact/proc/effect_woke(datum/artifact_effect/effect) + SEND_SIGNAL(src, COMSIG_ARTIFACT_EFFECT_ACTIVATED, effect) + if(!shown_awake && !QDELETED(src)) + var/datum/artifact_energy_kind/kind = visual_kind() + if(kind) + visible_message(span_warning(kind.wake_line(src))) + playsound(src, kind.wake_sound, 50, TRUE) + throb(1.12) + update_activity_visuals() + +/// An effect went to sleep. When the last one sleeps, so does the artifact. +/obj/machinery/artifact/proc/effect_slept(datum/artifact_effect/effect, quiet = FALSE) + SEND_SIGNAL(src, COMSIG_ARTIFACT_EFFECT_DEACTIVATED, effect) + if(QDELETED(src)) + return + if(shown_awake && !is_active() && !quiet) + visible_message(span_notice("[src] [pick(ARTIFACT_SLEEP_LINES)]")) + playsound(src, 'sound/effects/stealthoff.ogg', 40, TRUE) + update_activity_visuals() + +/// An effect let off a burst. +/obj/machinery/artifact/proc/effect_pulsed(datum/artifact_effect/effect) + var/datum/artifact_energy_kind/kind = artifact_energy_kind(effect.energy_kind) + playsound(src, kind.pulse_sound, 45, TRUE) + throb(1.18) + var/old_level = glow_level + set_glow(ARTIFACT_GLOW_AWAKE, kind.color, flash = TRUE) + glow_level = old_level + +/// A quick swell and settle. +/obj/machinery/artifact/proc/throb(scale) + var/matrix/swell = matrix() + swell.Scale(scale) + animate(src, transform = swell, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(transform = matrix(), time = 0.4 SECONDS, easing = SINE_EASING | EASE_IN) + +// ===== Glow, light, particles, hum ===== + +/** + * Brings icon, glow, light, particles and hum in line with what the effects are doing. + * Cheap when nothing changed; `force` rebuilds everything (after effects were replaced). + */ +/obj/machinery/artifact/proc/update_activity_visuals(force = FALSE) + if(QDELETED(src)) + return + var/datum/artifact_energy_kind/kind = visual_kind() + var/awake = is_active() && !suppressed + var/resting = awake && all_awake_resting() + if(awake != shown_awake || force) + shown_awake = awake + update_appearance(UPDATE_ICON_STATE) + + var/wanted_glow = ARTIFACT_GLOW_NONE + if(kind && isturf(loc)) + if(!awake) + wanted_glow = ARTIFACT_GLOW_IDLE + else + wanted_glow = resting ? ARTIFACT_GLOW_RESTING : ARTIFACT_GLOW_AWAKE + set_glow(wanted_glow, kind?.color, force = force) + + var/wanted_particles = (awake && !resting) ? kind?.particle_type : null + if(wanted_particles != particles_shown || force) + if(particles_shown) + remove_shared_particles(particles_shown) + particles_shown = wanted_particles + if(particles_shown) + add_shared_particles(particles_shown) + + update_artifact_light(awake, resting, kind) + + if(awake) + if(force && hum) + QDEL_NULL(hum) + if(!hum && kind?.hum_type) + hum = new kind.hum_type(src) + hum?.start() + else + hum?.stop() + +/// Light: an awake light or darkness effect sets it; otherwise a small glow while awake. +/obj/machinery/artifact/proc/update_artifact_light(awake, resting, datum/artifact_energy_kind/kind) + if(awake) + for(var/datum/artifact_effect/effect as anything in effects()) + if(!effect.activated) + continue + var/list/override = effect.light_override() + if(override) + set_light(override[1], override[2], override[3]) + return + if(awake && kind) + set_light(2, resting ? 0.4 : 0.9, kind.color) + else + set_light(0) + +/// Sets the drop-shadow glow and starts it breathing. `flash` spikes it once, then settles. +/obj/machinery/artifact/proc/set_glow(level, color, force = FALSE, flash = FALSE) + if(!flash && !force && level == glow_level && color == glow_color) + return + glow_level = level + glow_color = color + if(level == ARTIFACT_GLOW_NONE || !color) + remove_filter("artifact_glow") + return + var/low_alpha + var/high_alpha + var/size + var/period + switch(level) + if(ARTIFACT_GLOW_AWAKE) + low_alpha = "70" + high_alpha = "c8" + size = 2.5 + period = 1 SECONDS + if(ARTIFACT_GLOW_RESTING) + low_alpha = "30" + high_alpha = "60" + size = 1.5 + period = 2 SECONDS + else + low_alpha = "14" + high_alpha = "3c" + size = 1 + period = 3 SECONDS + if(flash) + add_filter("artifact_glow", 2, drop_shadow_filter(0, 0, 4, 0, "[color]ff")) + else + add_filter("artifact_glow", 2, drop_shadow_filter(0, 0, size, 0, "[color][low_alpha]")) + transition_filter_chain("artifact_glow", -1, \ + FilterChainStep(list("color" = "[color][high_alpha]", "size" = size), period, SINE_EASING), \ + FilterChainStep(list("color" = "[color][low_alpha]", "size" = size), period, SINE_EASING)) + +/// Strips glow, particles, light and hum (deletion). +/obj/machinery/artifact/proc/clear_activity_visuals() + if(particles_shown) + remove_shared_particles(particles_shown) + particles_shown = null + remove_filter("artifact_glow") + glow_level = ARTIFACT_GLOW_NONE + QDEL_NULL(hum) + set_light(0) + +#undef ARTIFACT_SLEEP_LINES +#undef ARTIFACT_GLOW_NONE +#undef ARTIFACT_GLOW_IDLE +#undef ARTIFACT_GLOW_RESTING +#undef ARTIFACT_GLOW_AWAKE diff --git a/voidcrew/modules/xenoarch/core/artifact_effect.dm b/voidcrew/modules/xenoarch/core/artifact_effect.dm index d838ca53fa6..b9531404a84 100644 --- a/voidcrew/modules/xenoarch/core/artifact_effect.dm +++ b/voidcrew/modules/xenoarch/core/artifact_effect.dm @@ -1,8 +1,21 @@ /** * # 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. + * Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/artifacts/effect.dm, + * reworked for Voidcrew: + * - Tiers, band-weighted rolls, a label name and harvest flag drive the catalogue. + * - Charge is per second. Auras spend faster than the store refills, so they rest about two + * thirds of the time; a pulse fires when the store is full. The store refills while asleep, + * so a freshly woken artifact acts at once. + * - A stimulus wakes an effect for a while instead of toggling it; level triggers (air and + * proximity) hold it awake while their condition lasts. + * - Harmful effects log like a weapon. Helpful effects never burst when smashed. + * + * Whatever holds an effect (an artifact, or a battery once harvested) calls tick() every + * process and touch() when touched. Subtypes implement do_effect_touch/aura/pulse. + * + * A rollable effect is any subtype that sets label_name. Parents that only share code leave it + * null and never roll. Adding an effect is adding a subtype: nothing else to register. */ /datum/artifact_effect /// The plain words a labeler offers for it, such as "Heals". Null on abstract parents. @@ -15,11 +28,398 @@ 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 + /// Relative weight against other effects of its tier + var/weight = 10 + /// Weight multipliers by stamped band + var/green_weight = 1 + var/yellow_weight = 1 + var/red_weight = 1 + /// Logged as a weapon when it lands on someone + var/harmful = FALSE + /// Never fires a last burst when its artifact is smashed + var/helpful = FALSE + + /// ARTIFACT_ENERGY_* kinds it can present as; one is picked + var/list/possible_kinds = list(ARTIFACT_ENERGY_CONCENTRATED) + /// ARTIFACT_ENERGY_*: what the analyser reads off it, and how its artifact looks + var/energy_kind = ARTIFACT_ENERGY_CONCENTRATED + /// ARTIFACT_RELEASE_* it can roll + var/list/allowed_release_methods = list(ARTIFACT_RELEASE_TOUCH, ARTIFACT_RELEASE_AURA, ARTIFACT_RELEASE_PULSE) /// ARTIFACT_RELEASE_* var/release_method = ARTIFACT_RELEASE_TOUCH - /// ARTIFACT_ENERGY_*: what the analyser reads off it - var/energy_kind = ARTIFACT_ENERGY_CONCENTRATED + /// ARTIFACT_TRIGGER_* it can roll; null for any + var/list/allowed_triggers + /// The trigger that wakes it (one ARTIFACT_TRIGGER_* flag) + var/trigger = ARTIFACT_TRIGGER_TOUCH + + /// ARTIFACT_STRENGTH_* + var/strength = ARTIFACT_STRENGTH_SMALL + /// Tiles its aura and pulses reach + var/range = 3 + /// Size of its charge store; a pulse spends all of it + var/maximum_charges = 10 + /// Charge in store + var/current_charge = 0 + /// Charge one touch spends + var/touch_cost = ARTIFACT_TOUCH_COST + + /// Awake and releasing + var/activated = FALSE + /// An aura that ran its store dry and is resting until full + var/exhausted = FALSE + /// world.time it falls asleep unless woken again + var/awake_until = 0 + /// Whatever holds it: an artifact, or a battery once harvested var/atom/holder + /// Who answers for it when it hurts someone, if not its artifact's last disturber + var/datum/weakref/responsible_ref + /// REF(victim) -> world.time last logged, so an aura logs each person twice a minute at most + var/list/last_logged + +/datum/artifact_effect/New(atom/location, band) + . = ..() + holder = location + roll_traits(band) + setup() + current_charge = maximum_charges + +/datum/artifact_effect/Destroy(force) + if(activated) + activated = FALSE + on_deactivate() + holder = null + responsible_ref = null + last_logged = null + return ..() + +/// Rolls energy kind, release method, trigger and strength. +/datum/artifact_effect/proc/roll_traits(band) + energy_kind = pick(possible_kinds) + release_method = pick(allowed_release_methods) + trigger = roll_trigger() + roll_strength(band) + +/datum/artifact_effect/proc/roll_trigger() + var/list/candidates = allowed_triggers || ARTIFACT_ALL_TRIGGERS + var/list/weights = list() + for(var/candidate in candidates) + weights += artifact_trigger_weight(candidate) + var/index = xenoarch_pick_weighted_index(weights) + return index ? candidates[index] : ARTIFACT_TRIGGER_TOUCH + +/// Picks small, medium or large by band (green leans small, red leans large). +/datum/artifact_effect/proc/roll_strength(band) + var/list/odds + switch(XENOARCH_BAND_INDEX(band)) + if(ZONE_RED) + odds = ARTIFACT_STRENGTH_ODDS_RED + if(ZONE_YELLOW) + odds = ARTIFACT_STRENGTH_ODDS_YELLOW + else + odds = ARTIFACT_STRENGTH_ODDS_GREEN + switch(xenoarch_pick_weighted_index(odds)) + if(3) + set_strength(ARTIFACT_STRENGTH_LARGE) + if(2) + set_strength(ARTIFACT_STRENGTH_MEDIUM) + else + set_strength(ARTIFACT_STRENGTH_SMALL) + +/// Nova's charge and range bands for each strength, range capped at 8. +/datum/artifact_effect/proc/set_strength(new_strength) + strength = new_strength + switch(strength) + if(ARTIFACT_STRENGTH_LARGE) + maximum_charges = rand(20, 50) + range = rand(6, 8) + if(ARTIFACT_STRENGTH_MEDIUM) + maximum_charges = rand(15, 30) + range = rand(4, 6) + else + maximum_charges = rand(5, 20) + range = rand(2, 4) + current_charge = min(current_charge, maximum_charges) + +/// Subtype setup after the roll: fix a release method, pick a gas, and so on. +/datum/artifact_effect/proc/setup() + return + +// ===== Waking and sleeping ===== + +/// Keeps it awake for at least `duration` more, waking it if it slept. +/datum/artifact_effect/proc/wake(duration) + awake_until = max(awake_until, world.time + duration) + if(!activated) + activate() + +/datum/artifact_effect/proc/activate() + if(activated) + return + activated = TRUE + exhausted = FALSE + on_activate() + var/obj/machinery/artifact/artifact = holder + if(istype(artifact)) + artifact.effect_woke(src) + +/// Puts it to sleep. `quiet` skips its artifact's sleep moment (used when it is crated or deleted). +/datum/artifact_effect/proc/deactivate(quiet = FALSE) + if(!activated) + return + activated = FALSE + exhausted = FALSE + awake_until = 0 + on_deactivate() + var/obj/machinery/artifact/artifact = holder + if(istype(artifact)) + artifact.effect_slept(src, quiet) + +/// Hook: it just woke. +/datum/artifact_effect/proc/on_activate() + return + +/// Hook: it just went to sleep. +/datum/artifact_effect/proc/on_deactivate() + return + +/// The light it gives off while awake as list(range, power, colour), or null to leave the +/// artifact's own glow alone. Light and darkness effects override this. +/datum/artifact_effect/proc/light_override() + return null + +// ===== Releasing ===== + +/// Refills the store. Called every process, awake or not. +/datum/artifact_effect/proc/recharge(seconds_per_tick) + current_charge = min(maximum_charges, current_charge + ARTIFACT_RECHARGE_PER_SECOND * seconds_per_tick) + +/// Called by the holder every process: lets an expired wake lapse, then releases an aura or pulse. +/datum/artifact_effect/proc/tick(seconds_per_tick) + if(!activated) + return + if(world.time >= awake_until) + deactivate() + return + switch(release_method) + if(ARTIFACT_RELEASE_AURA) + release_aura(seconds_per_tick) + if(ARTIFACT_RELEASE_PULSE) + release_pulse() + +/datum/artifact_effect/proc/release_aura(seconds_per_tick) + var/cost = ARTIFACT_AURA_COST_PER_SECOND * seconds_per_tick + if(exhausted) + if(current_charge < maximum_charges) + return + set_exhausted(FALSE) + if(current_charge < cost) + set_exhausted(TRUE) + return + current_charge -= cost + do_effect_aura(seconds_per_tick) + +/datum/artifact_effect/proc/release_pulse() + if(current_charge < maximum_charges) + return + current_charge = 0 + var/obj/machinery/artifact/artifact = holder + if(istype(artifact)) + artifact.effect_pulsed(src) + do_effect_pulse(maximum_charges) + +/datum/artifact_effect/proc/set_exhausted(new_value) + if(exhausted == new_value) + return + exhausted = new_value + var/obj/machinery/artifact/artifact = holder + if(istype(artifact)) + artifact.update_activity_visuals() + +/// Someone touched its holder. Returns TRUE if it acted on them. +/datum/artifact_effect/proc/touch(mob/living/user) + if(!activated || release_method != ARTIFACT_RELEASE_TOUCH || !isliving(user)) + return FALSE + if(current_charge < touch_cost) + return FALSE + current_charge -= touch_cost + do_effect_touch(user) + return TRUE + +/// Hook: acts on one person touching it. +/datum/artifact_effect/proc/do_effect_touch(mob/living/user) + return + +/// Hook: acts on everything in range, once per process while the aura runs. +/datum/artifact_effect/proc/do_effect_aura(seconds_per_tick) + return + +/// Hook: one burst. `power` is the charge spent, from 5 to 50. +/datum/artifact_effect/proc/do_effect_pulse(power) + return + +/// Hook: its artifact was smashed. Harmful effects let off one last burst; helpful ones nothing. +/datum/artifact_effect/proc/do_effect_destroy() + if(helpful) + return + do_effect_pulse(maximum_charges) + +// ===== Helpers for subtypes ===== + +/// The turf it acts from. +/datum/artifact_effect/proc/source_turf() + return get_turf(holder) + +/// Living mobs within its range (plus `extra_range`). +/datum/artifact_effect/proc/living_in_range(extra_range = 0) + . = list() + var/turf/center = source_turf() + if(!center) + return + for(var/mob/living/target in range(range + extra_range, center)) + . += target + +/// Who answers for harm it does: whoever set it going on a battery, else whoever last woke or +/// moved its artifact. +/datum/artifact_effect/proc/get_responsible() + var/mob/responsible = responsible_ref?.resolve() + if(responsible) + return responsible + var/obj/machinery/artifact/artifact = holder + if(istype(artifact)) + return artifact.last_disturbed_by?.resolve() + return null + +/datum/artifact_effect/proc/set_responsible(mob/user) + responsible_ref = user ? WEAKREF(user) : null + +/// Logs harm to `victim` like a weapon hit, at most twice a minute per victim. +/datum/artifact_effect/proc/log_harm(mob/living/victim) + if(!harmful || !istype(victim) || QDELETED(holder)) + return + var/key = REF(victim) + if(LAZYACCESS(last_logged, key) > world.time - 30 SECONDS) + return + LAZYSET(last_logged, key, world.time) + var/mob/responsible = get_responsible() + log_combat(responsible || holder, victim, "exposed", holder, "to [log_name]") + +/// What the analyser can read off it: list("kind", "release", "family"), in report words. +/datum/artifact_effect/proc/report_parts() + var/datum/artifact_energy_kind/kind = artifact_energy_kind(energy_kind) + var/release_text + switch(release_method) + if(ARTIFACT_RELEASE_AURA) + release_text = "released as a field" + if(ARTIFACT_RELEASE_PULSE) + release_text = "released in bursts" + else + release_text = "released on contact" + var/family_text + switch(artifact_trigger_family(trigger)) + if(ARTIFACT_FAMILY_CONTACT) + family_text = "Reacts to physical contact." + if(ARTIFACT_FAMILY_ENERGY) + family_text = "Reacts to energetic contact." + else + family_text = "Reacts to its surroundings." + return list("kind" = kind.report_name, "release" = release_text, "family" = family_text) + +/// One report line, such as "Electromagnetic energy, released in bursts. Reacts to energetic contact." +/datum/artifact_effect/proc/report_line() + var/list/parts = report_parts() + return "[parts["kind"]], [parts["release"]]. [parts["family"]]" + +/** + * A copy of this effect for a battery (harvesting). Same kind, release, trigger, strength and + * subtype settings, with an empty store. The copy's holder is `new_holder`. + */ +/datum/artifact_effect/proc/make_harvest_copy(atom/new_holder) + var/datum/artifact_effect/copy = new type(new_holder, null) + copy.energy_kind = energy_kind + copy.release_method = release_method + copy.trigger = trigger + copy.strength = strength + copy.range = range + copy.maximum_charges = maximum_charges + copy.touch_cost = touch_cost + copy.current_charge = 0 + copy_settings_to(copy) + return copy + +/// Hook: copy subtype settings (target temperature, gas, and so on) onto a harvest copy. +/datum/artifact_effect/proc/copy_settings_to(datum/artifact_effect/copy) + return + +// ===== Catalogue ===== + +/// Every rollable effect type (has a label_name), built once. +/proc/artifact_effect_types() + var/static/list/types + if(!types) + types = list() + for(var/datum/artifact_effect/effect_type as anything in subtypesof(/datum/artifact_effect)) + if(initial(effect_type.label_name)) + types += effect_type + return types + +/// The labeler's "what it does" words: every rollable effect's label_name, sorted, no repeats. +/proc/artifact_effect_label_names() + var/static/list/names + if(!names) + names = list() + for(var/datum/artifact_effect/effect_type as anything in artifact_effect_types()) + names |= initial(effect_type.label_name) + sortTim(names, GLOBAL_PROC_REF(cmp_text_asc)) + return names.Copy() + +/// Rolls a main-effect tier for a large find from its band. +/proc/xenoarch_roll_tier(band) + var/list/odds + switch(XENOARCH_BAND_INDEX(band)) + if(ZONE_RED) + odds = ARTIFACT_TIER_ODDS_RED + if(ZONE_YELLOW) + odds = ARTIFACT_TIER_ODDS_YELLOW + else + odds = ARTIFACT_TIER_ODDS_GREEN + return max(xenoarch_pick_weighted_index(odds), ARTIFACT_TIER_COMMON) + +/** + * Picks an effect type. + * + * * tier - ARTIFACT_TIER_* to pick from, or null for any tier (minor picks) + * * band - stamped band, for band weights + * * minor_only - only effects that may be a second effect + * * exclude - a type not to pick (the main effect, when picking the second) + * + * Falls back to lower tiers if a tier has nothing to offer. + */ +/proc/xenoarch_pick_effect_type(tier, band, minor_only = FALSE, exclude) + var/band_index = XENOARCH_BAND_INDEX(band) + for(var/try_tier = tier || ARTIFACT_TIER_EXTREME; try_tier >= ARTIFACT_TIER_COMMON; try_tier--) + var/list/candidates = list() + var/list/weights = list() + for(var/datum/artifact_effect/effect_type as anything in artifact_effect_types()) + if(effect_type == exclude) + continue + if(minor_only && !initial(effect_type.minor)) + continue + if(tier && initial(effect_type.tier) != try_tier) + continue + var/band_mult = initial(effect_type.green_weight) + if(band_index == ZONE_YELLOW) + band_mult = initial(effect_type.yellow_weight) + else if(band_index == ZONE_RED) + band_mult = initial(effect_type.red_weight) + var/effect_weight = initial(effect_type.weight) * band_mult + if(effect_weight <= 0) + continue + candidates += effect_type + weights += effect_weight + var/index = xenoarch_pick_weighted_index(weights) + if(index) + return candidates[index] + if(!tier) + break + return null diff --git a/voidcrew/modules/xenoarch/core/artifact_energy_kinds.dm b/voidcrew/modules/xenoarch/core/artifact_energy_kinds.dm new file mode 100644 index 00000000000..276d72df482 --- /dev/null +++ b/voidcrew/modules/xenoarch/core/artifact_energy_kinds.dm @@ -0,0 +1,304 @@ +/** + * # Artifact energy kinds + * + * The seven kinds of energy an analyser can read off a find (Nova's type_name values). Each + * kind also sets how an artifact looks and sounds: a faint glow while it sleeps, and a brighter + * glow, drifting particles, a light and a hum while it is awake. Several effects share each + * kind, so learning the look narrows a find down without naming what it does. + * + * Sounds and particles are existing tg assets. + */ + +/// Every energy kind, keyed by ARTIFACT_ENERGY_*. +GLOBAL_LIST_INIT(artifact_energy_kinds, init_artifact_energy_kinds()) + +/proc/init_artifact_energy_kinds() + . = list() + for(var/kind_type in subtypesof(/datum/artifact_energy_kind)) + var/datum/artifact_energy_kind/kind = new kind_type + .[kind.key] = kind + +/// The energy kind datum for a key, falling back to concentrated energy. +/proc/artifact_energy_kind(key) + return GLOB.artifact_energy_kinds[key] || GLOB.artifact_energy_kinds[ARTIFACT_ENERGY_CONCENTRATED] + +/datum/artifact_energy_kind + /// ARTIFACT_ENERGY_* + var/key + /// How the analyser names it + var/report_name + /// Glow, light and particle colour + var/color + /// Particles drifting off it while awake + var/particle_type + /// The hum while awake + var/hum_type + /// Played when it wakes + var/wake_sound + /// Played on each burst it releases + var/pulse_sound = 'sound/effects/magic/repulse.ogg' + /// "[artifact] [line]" when it wakes + var/list/wake_lines + /// Examine line while it is awake + var/list/awake_examine + +/datum/artifact_energy_kind/proc/wake_line(atom/artifact) + return "[artifact] [pick(wake_lines)]" + +/datum/artifact_energy_kind/concentrated + key = ARTIFACT_ENERGY_CONCENTRATED + report_name = "Concentrated energy" + color = "#ffb347" + particle_type = /particles/artifact/concentrated + hum_type = /datum/looping_sound/artifact_hum/concentrated + wake_sound = 'sound/effects/magic/charge.ogg' + wake_lines = list( + "brightens with a rising whine.", + "flares, and warm light rolls off it.", + "starts to hum. You can feel the heat from here.", + ) + awake_examine = list( + "It is giving off a low, warm hum.", + "Heat shimmers off its surface.", + ) + +/datum/artifact_energy_kind/psionic + key = ARTIFACT_ENERGY_PSIONIC + report_name = "Psionic wavefront" + color = "#b86bff" + particle_type = /particles/artifact/psionic + hum_type = /datum/looping_sound/artifact_hum/psionic + wake_sound = 'sound/effects/magic/magic_block_mind.ogg' + pulse_sound = 'sound/effects/hallucinations/far_noise.ogg' + wake_lines = list( + "shudders, and the air around it wavers.", + "goes very quiet, and your ears start to ring.", + "shimmers like a reflection on water.", + ) + awake_examine = list( + "The air around it wavers like heat haze.", + "Looking at it for long makes your teeth ache.", + ) + +/datum/artifact_energy_kind/electromagnetic + key = ARTIFACT_ENERGY_ELECTROMAGNETIC + report_name = "Electromagnetic energy" + color = "#5fd3ff" + particle_type = /particles/artifact/electromagnetic + hum_type = /datum/looping_sound/artifact_hum/electromagnetic + wake_sound = 'sound/effects/supermatter.ogg' + pulse_sound = 'sound/effects/sparks/sparks2.ogg' + wake_lines = list( + "crackles, and static crawls over it.", + "snaps with a blue spark.", + "buzzes, and the hair on your arms stands up.", + ) + awake_examine = list( + "Static crawls across its surface.", + "It buzzes faintly, like a transformer.", + ) + +/datum/artifact_energy_kind/particle + key = ARTIFACT_ENERGY_PARTICLE + report_name = "High-frequency particles" + color = "#e6efff" + particle_type = /particles/artifact/particle + hum_type = /datum/looping_sound/artifact_hum/particle + wake_sound = 'sound/effects/magic/cosmic_energy.ogg' + wake_lines = list( + "brightens, and motes of light lift off it.", + "glitters as if dusted with frost.", + "gives off a thin, high tone.", + ) + awake_examine = list( + "Tiny motes of light drift off its surface.", + "It glitters, though nothing is shining on it.", + ) + +/datum/artifact_energy_kind/organic + key = ARTIFACT_ENERGY_ORGANIC + report_name = "Organically reactive particles" + color = "#ff5f7a" + particle_type = /particles/artifact/organic + hum_type = /datum/looping_sound/artifact_hum/organic + wake_sound = 'sound/effects/magic/enter_blood.ogg' + pulse_sound = 'sound/effects/singlebeat.ogg' + wake_lines = list( + "swells slightly, then settles into a slow pulse.", + "flushes a dull red.", + "twitches.", + ) + awake_examine = list( + "It pulses slowly, like something breathing.", + "Its surface is damp and faintly warm.", + ) + +/datum/artifact_energy_kind/bluespace + key = ARTIFACT_ENERGY_BLUESPACE + report_name = "Bluespace phasing" + color = "#4a6bff" + particle_type = /particles/artifact/bluespace + hum_type = /datum/looping_sound/artifact_hum/bluespace + wake_sound = 'sound/effects/phasein.ogg' + pulse_sound = 'sound/effects/magic/blink.ogg' + wake_lines = list( + "blurs at the edges.", + "flickers, as if it were briefly somewhere else.", + "seems to lean without moving.", + ) + awake_examine = list( + "Its edges won't hold still.", + "It is hard to say exactly how far away it is.", + ) + +/datum/artifact_energy_kind/synthesis + key = ARTIFACT_ENERGY_SYNTHESIS + report_name = "Atomic synthesis" + color = "#7dff6a" + particle_type = /particles/artifact/synthesis + hum_type = /datum/looping_sound/artifact_hum/synthesis + wake_sound = 'sound/effects/bubbles/bubbles.ogg' + pulse_sound = 'sound/effects/spray.ogg' + wake_lines = list( + "fizzes, and a sharp chemical smell rises from it.", + "beads with moisture.", + "hisses softly.", + ) + awake_examine = list( + "It fizzes faintly. The air near it tastes of metal.", + "A sharp, clean smell hangs around it.", + ) + +// ===== Hums ===== + +/datum/looping_sound/artifact_hum + volume = 20 + extra_range = -8 + falloff_distance = 2 + falloff_exponent = 5 + ignore_walls = FALSE + vary = TRUE + +/datum/looping_sound/artifact_hum/concentrated + mid_sounds = list('sound/machines/sm/loops/calm.ogg' = 1) + mid_length = 10 SECONDS + volume = 15 + +/datum/looping_sound/artifact_hum/psionic + mid_sounds = list('sound/ambience/misc/ambimystery.ogg' = 1) + mid_length = 10 SECONDS + volume = 12 + +/datum/looping_sound/artifact_hum/electromagnetic + mid_sounds = list( + 'sound/machines/tcomms/tcomms_mid1.ogg' = 1, + 'sound/machines/tcomms/tcomms_mid2.ogg' = 1, + 'sound/machines/tcomms/tcomms_mid3.ogg' = 1, + 'sound/machines/tcomms/tcomms_mid4.ogg' = 1, + ) + mid_length = 2 SECONDS + +/datum/looping_sound/artifact_hum/particle + mid_sounds = list('sound/machines/hypertorus/loops/hypertorus_nominal.ogg' = 1) + mid_length = 6.6 SECONDS + volume = 12 + +/datum/looping_sound/artifact_hum/organic + mid_sounds = list('sound/effects/singlebeat.ogg' = 1) + mid_length = 1.6 SECONDS + volume = 15 + +/datum/looping_sound/artifact_hum/bluespace + mid_sounds = list( + 'sound/machines/gravgen/grav_gen_mid1.ogg' = 12, + 'sound/machines/gravgen/grav_gen_mid2.ogg' = 1, + ) + mid_length = 2.3 SECONDS + volume = 15 + +/datum/looping_sound/artifact_hum/synthesis + mid_sounds = list( + 'sound/effects/soup_boil/soup_boil1.ogg' = 1, + 'sound/effects/soup_boil/soup_boil2.ogg' = 1, + 'sound/effects/soup_boil/soup_boil3.ogg' = 1, + 'sound/effects/soup_boil/soup_boil4.ogg' = 1, + ) + mid_length = 3 SECONDS + volume = 15 + +// ===== Particles ===== +// Shared holders (add_shared_particles): one emitter per kind serves every artifact of that kind. + +/particles/artifact + icon = 'icons/effects/particles/generic.dmi' + icon_state = "dot" + width = 64 + height = 64 + count = 24 + spawning = 0.6 + lifespan = 1.5 SECONDS + fade = 1 SECONDS + fadein = 0.2 SECONDS + position = generator(GEN_CIRCLE, 6, 13, NORMAL_RAND) + scale = generator(GEN_VECTOR, list(0.4, 0.4), list(0.8, 0.8), NORMAL_RAND) + +/particles/artifact/concentrated + color = "#ffb347" + velocity = list(0, 0.6) + drift = generator(GEN_VECTOR, list(-0.1, 0), list(0.1, 0.05), UNIFORM_RAND) + +/particles/artifact/psionic + icon = 'icons/effects/particles/echo.dmi' + icon_state = list("echo1" = 1, "echo2" = 1, "echo3" = 1) + color = "#b86bff" + spawning = 0.25 + lifespan = 2 SECONDS + scale = generator(GEN_VECTOR, list(0.15, 0.15), list(0.3, 0.3), NORMAL_RAND) + position = generator(GEN_CIRCLE, 0, 12, NORMAL_RAND) + rotation = generator(GEN_NUM, 0, 360, UNIFORM_RAND) + +/particles/artifact/electromagnetic + icon_state = "cross" + color = "#5fd3ff" + spawning = 1.2 + lifespan = 0.4 SECONDS + fade = 0.3 SECONDS + fadein = 0 + position = generator(GEN_CIRCLE, 8, 15, UNIFORM_RAND) + scale = generator(GEN_VECTOR, list(0.3, 0.3), list(0.6, 0.6), NORMAL_RAND) + +/particles/artifact/particle + color = "#e6efff" + spawning = 0.5 + lifespan = 2.5 SECONDS + velocity = list(0, 0.25) + drift = generator(GEN_VECTOR, list(-0.15, 0), list(0.15, 0), UNIFORM_RAND) + scale = generator(GEN_VECTOR, list(0.25, 0.25), list(0.5, 0.5), NORMAL_RAND) + +/particles/artifact/organic + icon_state = "drop" + color = "#ff5f7a" + spawning = 0.3 + lifespan = 1.2 SECONDS + gravity = list(0, -0.3) + position = generator(GEN_CIRCLE, 0, 9, NORMAL_RAND) + +/particles/artifact/bluespace + icon = 'icons/effects/particles/echo.dmi' + icon_state = list("echo1" = 3, "echo2" = 1, "echo3" = 1) + color = "#4a6bff" + spawning = 1 + lifespan = 0.6 SECONDS + fade = 0.5 SECONDS + friction = 0.5 + position = generator(GEN_SPHERE, 4, 12, NORMAL_RAND) + drift = generator(GEN_VECTOR, list(-1, 1), list(1, 2), NORMAL_RAND) + scale = 0.6 + +/particles/artifact/synthesis + icon_state = "curl" + color = "#7dff6a" + spawning = 0.4 + lifespan = 1.8 SECONDS + velocity = list(0, 0.4) + spin = generator(GEN_NUM, -20, 20, NORMAL_RAND) diff --git a/voidcrew/modules/xenoarch/core/artifact_helpers.dm b/voidcrew/modules/xenoarch/core/artifact_helpers.dm index 3a1308b1d81..7266df46f6c 100644 --- a/voidcrew/modules/xenoarch/core/artifact_helpers.dm +++ b/voidcrew/modules/xenoarch/core/artifact_helpers.dm @@ -112,3 +112,129 @@ GLOBAL_LIST_EMPTY(artifact_serials_used) if(roll < 0) return index return length(weights) + +/** + * How exposed a mob is to an artifact, from 0 (fully protected) to 1 (bare). + * Biosuits do most of it; latex gloves and science goggles help a little. Ported from Nova's + * get_anomaly_protection(), reading bio armor with getarmor() instead of Nova's helper. + */ +/proc/artifact_exposure(mob/living/target) + if(!ishuman(target)) + return 1 + var/mob/living/carbon/human/person = target + var/protection = 0 + if(person.getarmor(BODY_ZONE_CHEST, BIO) >= 85) + protection += 0.5 + if(person.getarmor(BODY_ZONE_HEAD, BIO) >= 85) + protection += 0.3 + if(istype(person.gloves, /obj/item/clothing/gloves/latex)) + protection += 0.1 + if(istype(person.glasses, /obj/item/clothing/glasses/science)) + protection += 0.1 + return clamp(1 - protection, 0, 1) + +/// Whether a mob's touch counts as touching the artifact's surface: a bare hand, or any gloves +/// that are not latex. Things without hands always count. +/proc/artifact_touch_counts(mob/living/toucher) + if(!iscarbon(toucher)) + return TRUE + var/mob/living/carbon/carbon_toucher = toucher + var/obj/item/clothing/gloves/worn = carbon_toucher.get_item_by_slot(ITEM_SLOT_GLOVES) + return !istype(worn, /obj/item/clothing/gloves/latex) + +// ===== Healing cap ===== +// Heal and robo-heal effects share one budget per person across every artifact and utilizer: +// ARTIFACT_HEAL_CAP brute plus burn per ARTIFACT_HEAL_WINDOW. + +#define ARTIFACT_HEAL_CAP 30 +#define ARTIFACT_HEAL_WINDOW (5 MINUTES) + +/// REF(mob) -> list(window ends at, amount healed in the window). Holds no hard references. +GLOBAL_LIST_EMPTY(artifact_heal_ledger) + +/// How much artifact healing this mob may still take in its current window. +/proc/artifact_heal_allowance(mob/living/target) + var/list/entry = GLOB.artifact_heal_ledger[REF(target)] + if(!entry || world.time >= entry[1]) + return ARTIFACT_HEAL_CAP + return max(ARTIFACT_HEAL_CAP - entry[2], 0) + +/// Records artifact healing against this mob's window. +/proc/artifact_record_heal(mob/living/target, amount) + if(amount <= 0) + return + var/key = REF(target) + var/list/entry = GLOB.artifact_heal_ledger[key] + if(!entry || world.time >= entry[1]) + entry = list(world.time + ARTIFACT_HEAL_WINDOW, 0) + GLOB.artifact_heal_ledger[key] = entry + // Lazy sweep of stale windows so the ledger never grows past the people healed recently. + if(length(GLOB.artifact_heal_ledger) > 50) + for(var/old_key in GLOB.artifact_heal_ledger) + var/list/old = GLOB.artifact_heal_ledger[old_key] + if(world.time >= old[1]) + GLOB.artifact_heal_ledger -= old_key + entry[2] += amount + +/** + * Heals brute and burn on `target`, never past what the shared cap allows. + * Returns the amount actually healed. + * + * * required_bodytype - limit to robotic or organic limbs on carbons (heal_overall_damage's arg) + */ +/proc/artifact_capped_heal(mob/living/target, amount, required_bodytype) + var/allowed = min(amount, artifact_heal_allowance(target)) + if(allowed <= 0) + return 0 + var/before = target.getBruteLoss() + target.getFireLoss() + target.heal_overall_damage(allowed / 2, allowed / 2, required_bodytype = required_bodytype) + var/healed = max(before - (target.getBruteLoss() + target.getFireLoss()), 0) + artifact_record_heal(target, healed) + return healed + +#undef ARTIFACT_HEAL_CAP +#undef ARTIFACT_HEAL_WINDOW + +// ===== Reagent triggers ===== + +/// Reagents that count as volatile (Nova's GLOB.volatile_reagents). +GLOBAL_LIST_INIT(artifact_volatile_reagents, typecacheof(list( + /datum/reagent/thermite, + /datum/reagent/toxin/plasma, + /datum/reagent/nitroglycerin, + /datum/reagent/clf3, + /datum/reagent/sorium, + /datum/reagent/liquid_dark_matter, + /datum/reagent/gunpowder, + /datum/reagent/rdx, + /datum/reagent/tatp, + /datum/reagent/flash_powder, + /datum/reagent/phlogiston, + /datum/reagent/napalm, + /datum/reagent/pyrosium, + /datum/reagent/teslium, +))) + +/// The trigger one reagent type counts as, or NONE. +/proc/artifact_reagent_trigger(reagent_type) + if(!ispath(reagent_type, /datum/reagent)) + return NONE + if(ispath(reagent_type, /datum/reagent/water) || ispath(reagent_type, /datum/reagent/hydrogen)) + return ARTIFACT_TRIGGER_WATER + if(ispath(reagent_type, /datum/reagent/toxin/acid)) + return ARTIFACT_TRIGGER_ACID + if(GLOB.artifact_volatile_reagents[reagent_type]) + return ARTIFACT_TRIGGER_VOLATILE + if(ispath(reagent_type, /datum/reagent/toxin) || ispath(reagent_type, /datum/reagent/consumable/ethanol/neurotoxin)) + return ARTIFACT_TRIGGER_TOXIN + return NONE + +/// Every trigger a list of reagents counts as (datums or types), as one bitfield. +/proc/artifact_reagent_triggers(list/reagents) + . = NONE + for(var/reagent in reagents) + var/reagent_type = reagent + if(istype(reagent, /datum/reagent)) + var/datum/reagent/reagent_datum = reagent + reagent_type = reagent_datum.type + . |= artifact_reagent_trigger(reagent_type) diff --git a/voidcrew/modules/xenoarch/effects/blood.dm b/voidcrew/modules/xenoarch/effects/blood.dm new file mode 100644 index 00000000000..b3d3dec9433 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/blood.dm @@ -0,0 +1,63 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/blood.dm. +// Voidcrew: blood_volume set directly (Nova's adjust_blood_volume() doesn't exist here); regen stops +// at a normal amount; drain never takes anyone below the point where they start to pass out. + +/datum/artifact_effect/blood + tier = ARTIFACT_TIER_RARE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC) + +/// Whether `target` has blood to work on. +/datum/artifact_effect/blood/proc/bleeds(mob/living/target) + return target.stat != DEAD && target.blood_volume > 0 && !HAS_TRAIT(target, TRAIT_NOBLOOD) + +/datum/artifact_effect/blood/regen + label_name = "Restores blood" + log_name = "Blood regeneration" + helpful = TRUE + +/datum/artifact_effect/blood/regen/do_effect_touch(mob/living/user) + if(restore(user, 20)) + to_chat(user, span_notice("Your body feels full, somehow.")) + +/datum/artifact_effect/blood/regen/do_effect_aura(seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + if(restore(target, 2 * seconds_per_tick) && prob(10)) + to_chat(target, span_notice("Your chest feels warm.")) + +/datum/artifact_effect/blood/regen/do_effect_pulse(power) + for(var/mob/living/target as anything in living_in_range()) + if(restore(target, power * 1.5)) + to_chat(target, span_notice("Warmth floods through your veins.")) + +/datum/artifact_effect/blood/regen/proc/restore(mob/living/target, amount) + if(!bleeds(target) || target.blood_volume >= BLOOD_VOLUME_NORMAL) + return FALSE + target.blood_volume = min(BLOOD_VOLUME_NORMAL, target.blood_volume + amount * artifact_exposure(target)) + return TRUE + +/datum/artifact_effect/blood/drain + label_name = "Drains blood" + log_name = "Blood drain" + harmful = TRUE + +/datum/artifact_effect/blood/drain/do_effect_touch(mob/living/user) + if(bleed(user, 40)) + to_chat(user, span_warning("Blood beads up through the skin of your palm!")) + +/datum/artifact_effect/blood/drain/do_effect_aura(seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + if(bleed(target, 2 * seconds_per_tick) && prob(10)) + to_chat(target, span_warning("Your nose starts to bleed.")) + +/datum/artifact_effect/blood/drain/do_effect_pulse(power) + for(var/mob/living/target as anything in living_in_range()) + if(bleed(target, power)) + to_chat(target, span_warning("You feel suddenly light-headed.")) + target.add_splatter_floor(get_turf(target), small_drip = TRUE) + +/datum/artifact_effect/blood/drain/proc/bleed(mob/living/target, amount) + if(!bleeds(target) || target.blood_volume <= BLOOD_VOLUME_OKAY) + return FALSE + target.blood_volume = max(BLOOD_VOLUME_OKAY, target.blood_volume - amount * artifact_exposure(target)) + log_harm(target) + return TRUE diff --git a/voidcrew/modules/xenoarch/effects/bodyhorror.dm b/voidcrew/modules/xenoarch/effects/bodyhorror.dm new file mode 100644 index 00000000000..c5dc7911e73 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/bodyhorror.dm @@ -0,0 +1,68 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/bodyhorror.dm. +// Voidcrew: half as likely in green; one victim per burst; not harvestable; logged. + +/datum/artifact_effect/bodyhorror + label_name = "Twists flesh" + log_name = "Body horror" + tier = ARTIFACT_TIER_EXTREME + harvestable = FALSE + harmful = TRUE + green_weight = 0.5 + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC) + allowed_release_methods = list(ARTIFACT_RELEASE_PULSE) + +/datum/artifact_effect/bodyhorror/setup() + range = min(range, 4) + +/datum/artifact_effect/bodyhorror/do_effect_pulse(power) + var/list/victims = list() + for(var/mob/living/carbon/target in living_in_range()) + if(target.stat == DEAD || HAS_TRAIT(target, TRAIT_NODISMEMBER) || artifact_exposure(target) <= 0.5) + continue + victims += target + while(length(victims)) + if(mutate_limb(pick_n_take(victims))) + return + +/// Swaps one arm or leg (or a missing one) for a living flesh limb. Returns TRUE if it did. +/datum/artifact_effect/bodyhorror/proc/mutate_limb(mob/living/carbon/target) + var/list/zones = target.get_missing_limbs() + for(var/obj/item/bodypart/part as anything in target.bodyparts) + if(part.body_zone == BODY_ZONE_HEAD || part.body_zone == BODY_ZONE_CHEST) + continue + if(HAS_TRAIT(part, TRAIT_IGNORED_BY_LIVING_FLESH) || (part.bodypart_flags & BODYPART_UNREMOVABLE)) + continue + zones += part.body_zone + if(!length(zones)) + return FALSE + var/zone = pick(zones) + var/part_type + switch(zone) + if(BODY_ZONE_L_ARM) + part_type = /obj/item/bodypart/arm/left/flesh + if(BODY_ZONE_R_ARM) + part_type = /obj/item/bodypart/arm/right/flesh + if(BODY_ZONE_L_LEG) + part_type = /obj/item/bodypart/leg/left/flesh + if(BODY_ZONE_R_LEG) + part_type = /obj/item/bodypart/leg/right/flesh + if(!part_type) + return FALSE + var/obj/item/bodypart/old_part = target.get_bodypart(zone) + if(old_part) + old_part.dismember() + else + target.emote("scream") + target.visible_message( + span_danger("[target]'s limb swells and splits, and something red and living pushes out of it!"), + span_userdanger("Your limb swells and splits, and something red and living pushes out of it!"), + blind_message = span_hear("You hear tearing flesh and cracking bone."), + ) + // The same steps the living flesh mob uses to take a limb (ruin_defender/flesh.dm). + var/obj/item/bodypart/new_part = new part_type() + var/mob/living/basic/living_limb_flesh/parasite = new + parasite.forceMove(new_part) + new_part.replace_limb(target, TRUE) + parasite.register_to_limb(new_part) + log_harm(target) + return TRUE diff --git a/voidcrew/modules/xenoarch/effects/cell.dm b/voidcrew/modules/xenoarch/effects/cell.dm new file mode 100644 index 00000000000..5121c9fd4e8 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/cell.dm @@ -0,0 +1,104 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/cellcharge.dm and celldrain.dm. +// Voidcrew: no free power. Charging works on at most two ordinary power cells at a time, no +// faster than a tier-1 recharger (the cell's own charge rate, capped at a standard cell's), and +// never on APCs, SMES units, megacells or any machine. Nova gave 125 kW to everything in range +// and a billion joules to every cell when smashed. Draining reaches the same cells. + +/datum/artifact_effect/cell + tier = ARTIFACT_TIER_UNCOMMON + minor = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC) + /// Cells it works on at once + var/cell_limit = 2 + +/// Seconds of recharger-rate charge for this cell, capped at a standard cell's rate. +/datum/artifact_effect/cell/proc/charge_for(obj/item/stock_parts/power_store/cell/cell, seconds) + return min(cell.chargerate, STANDARD_CELL_RATE) * seconds + +/// Ordinary power cells carried by `holder_atom` (itself, or anything inside it). +/datum/artifact_effect/cell/proc/cells_in(atom/holder_atom) + return holder_atom.get_all_contents_type(/obj/item/stock_parts/power_store/cell) + +/// Cells in range, loose or carried, never inside a machine. `want_room` picks cells that are +/// not full; otherwise cells that are not empty. +/datum/artifact_effect/cell/proc/cells_nearby(want_room) + . = list() + var/turf/center = source_turf() + if(!center) + return + for(var/atom/movable/thing in range(range, center)) + if(!isitem(thing) && !isliving(thing)) + continue + for(var/obj/item/stock_parts/power_store/cell/cell as anything in cells_in(thing)) + if(want_room ? cell.charge < cell.maxcharge : cell.charge > 0) + . += cell + if(length(.) >= cell_limit) + return + +/datum/artifact_effect/cell/proc/after_change(obj/item/stock_parts/power_store/cell/cell) + cell.update_appearance() + if(isatom(cell.loc)) + var/atom/cell_holder = cell.loc + cell_holder.update_appearance() + +/datum/artifact_effect/cell/charge + label_name = "Charges cells" + log_name = "Cell charge" + helpful = TRUE + +/datum/artifact_effect/cell/charge/do_effect_touch(mob/living/user) + var/charged = 0 + for(var/obj/item/stock_parts/power_store/cell/cell as anything in cells_in(user)) + if(charged >= cell_limit) + break + if(cell.charge >= cell.maxcharge) + continue + cell.give(charge_for(cell, 5)) + after_change(cell) + charged++ + if(charged && issilicon(user)) + to_chat(user, span_notice("Your cell takes a charge.")) + +/datum/artifact_effect/cell/charge/do_effect_aura(seconds_per_tick) + for(var/obj/item/stock_parts/power_store/cell/cell as anything in cells_nearby(want_room = TRUE)) + cell.give(charge_for(cell, seconds_per_tick)) + after_change(cell) + +/datum/artifact_effect/cell/charge/do_effect_pulse(power) + for(var/obj/item/stock_parts/power_store/cell/cell as anything in cells_nearby(want_room = TRUE)) + cell.give(charge_for(cell, 10)) + after_change(cell) + +/datum/artifact_effect/cell/drain + label_name = "Drains cells" + log_name = "Cell drain" + harmful = TRUE + +/datum/artifact_effect/cell/drain/do_effect_touch(mob/living/user) + var/drained = 0 + for(var/obj/item/stock_parts/power_store/cell/cell as anything in cells_in(user)) + if(drained >= cell_limit) + break + if(cell.charge <= 0) + continue + cell.use(charge_for(cell, 30), force = TRUE) + after_change(cell) + drained++ + if(drained) + log_harm(user) + if(issilicon(user)) + to_chat(user, span_warning("Your cell is draining fast!")) + +/datum/artifact_effect/cell/drain/do_effect_aura(seconds_per_tick) + drain_nearby(charge_seconds = seconds_per_tick * 2) + +/datum/artifact_effect/cell/drain/do_effect_pulse(power) + drain_nearby(charge_seconds = 20) + +/datum/artifact_effect/cell/drain/proc/drain_nearby(charge_seconds) + for(var/obj/item/stock_parts/power_store/cell/cell as anything in cells_nearby(want_room = FALSE)) + cell.use(charge_for(cell, charge_seconds), force = TRUE) + after_change(cell) + var/mob/living/carrier = get(cell, /mob/living) + if(carrier) + log_harm(carrier) diff --git a/voidcrew/modules/xenoarch/effects/disgust.dm b/voidcrew/modules/xenoarch/effects/disgust.dm new file mode 100644 index 00000000000..579a6e3575d --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/disgust.dm @@ -0,0 +1,33 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/disgust.dm. +// Voidcrew: nausea that builds, rather than one touch that empties your stomach. + +/datum/artifact_effect/disgust + label_name = "Nausea" + log_name = "Disgust" + tier = ARTIFACT_TIER_UNCOMMON + minor = TRUE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_ORGANIC) + +/datum/artifact_effect/disgust/do_effect_touch(mob/living/user) + sicken(user, 50) + +/datum/artifact_effect/disgust/do_effect_aura(seconds_per_tick) + for(var/mob/living/carbon/target in living_in_range()) + if(SPT_PROB(40, seconds_per_tick)) + sicken(target, 4) + +/datum/artifact_effect/disgust/do_effect_pulse(power) + for(var/mob/living/carbon/target in living_in_range()) + sicken(target, clamp(power * 1.5, 10, 60)) + +/datum/artifact_effect/disgust/proc/sicken(mob/living/target, amount) + if(!iscarbon(target) || target.stat == DEAD) + return + var/exposure = artifact_exposure(target) + if(exposure <= 0) + return + if(prob(30)) + to_chat(target, span_warning(pick("Your stomach turns over.", "You don't feel so good.", "Your mouth floods with spit."))) + target.adjust_disgust(amount * exposure) + log_harm(target) diff --git a/voidcrew/modules/xenoarch/effects/emp.dm b/voidcrew/modules/xenoarch/effects/emp.dm new file mode 100644 index 00000000000..2a1af40041e --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/emp.dm @@ -0,0 +1,19 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/emp.dm. +// Voidcrew: smaller pulses (Nova's light radius reached ten tiles); logged. + +/datum/artifact_effect/emp + label_name = "EMP" + log_name = "EMP" + tier = ARTIFACT_TIER_RARE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC) + allowed_release_methods = list(ARTIFACT_RELEASE_PULSE) + +/datum/artifact_effect/emp/do_effect_pulse(power) + var/turf/here = source_turf() + if(!here) + return + var/light_range = clamp(range - 1, 1, 6) + empulse(here, round(light_range / 3), light_range) + for(var/mob/living/carbon/human/target in living_in_range()) + log_harm(target) diff --git a/voidcrew/modules/xenoarch/effects/feelings.dm b/voidcrew/modules/xenoarch/effects/feelings.dm new file mode 100644 index 00000000000..142fcfb1b2b --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/feelings.dm @@ -0,0 +1,105 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/feelings.dm. +// Voidcrew: trimmed message lists, plain spans instead of font tags, dizziness only from dread. + +/datum/artifact_effect/feelings + tier = ARTIFACT_TIER_COMMON + minor = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC) + /// Strong feelings + var/list/major_messages + /// Passing feelings + var/list/minor_messages + var/major_mood_type + var/minor_mood_type + /// Span proc for messages + var/good = TRUE + +/datum/artifact_effect/feelings/do_effect_touch(mob/living/user) + feel(user, 50, 100) + +/datum/artifact_effect/feelings/do_effect_aura(seconds_per_tick) + for(var/mob/living/carbon/human/target in living_in_range()) + if(SPT_PROB(2.5, seconds_per_tick)) + feel(target, 100, 0) + else if(SPT_PROB(5, seconds_per_tick)) + feel(target, 0, 100) + +/datum/artifact_effect/feelings/do_effect_pulse(power) + for(var/mob/living/carbon/human/target in living_in_range()) + feel(target, 80, 100) + +/// Gives `target` a strong feeling with `major_chance`, else a passing one with `minor_chance`. +/datum/artifact_effect/feelings/proc/feel(mob/living/target, major_chance, minor_chance) + if(!ishuman(target) || prob(100 * (1 - artifact_exposure(target)))) + return + if(prob(major_chance)) + to_chat(target, good ? span_nicegreen("[pick(major_messages)]") : span_boldwarning(pick(major_messages))) + target.add_mood_event("artifact_feeling_major", major_mood_type) + if(!good) + target.adjust_dizzy_up_to(4 SECONDS, 20 SECONDS) + else if(prob(minor_chance)) + to_chat(target, good ? span_nicegreen(pick(minor_messages)) : span_warning(pick(minor_messages))) + target.add_mood_event("artifact_feeling_minor", minor_mood_type) + +/datum/artifact_effect/feelings/good + label_name = "Good mood" + log_name = "Good feeling" + good = TRUE + major_mood_type = /datum/mood_event/artifact_good_major + minor_mood_type = /datum/mood_event/artifact_good_minor + major_messages = list( + "You want to hug everyone you meet!", + "Everything is going so well!", + "You feel giddy.", + "You could dance.", + ) + minor_messages = list( + "You feel good.", + "You've got a good feeling about this.", + "Something smells good.", + "You fight the urge to smile.", + "The colours around you seem a bit brighter.", + "Your worries seem very far away.", + ) + +/datum/artifact_effect/feelings/bad + label_name = "Dread" + log_name = "Bad feeling" + good = FALSE + major_mood_type = /datum/mood_event/artifact_bad_major + minor_mood_type = /datum/mood_event/artifact_bad_minor + major_messages = list( + "You've got to get out of here!", + "Someone's trying to kill you!", + "There's something out there!", + "What's happening to you?", + ) + minor_messages = list( + "You feel worried.", + "Something doesn't feel right.", + "You get a strange feeling in your gut.", + "You've got a bad feeling about this.", + "Your scalp prickles.", + "The shadows seem longer than they were.", + "The walls feel closer.", + ) + +/datum/mood_event/artifact_good_major + description = "Everything's going great and I don't even know why." + mood_change = 6 + timeout = 5 MINUTES + +/datum/mood_event/artifact_good_minor + description = "I feel strangely relaxed." + mood_change = 3 + timeout = 3 MINUTES + +/datum/mood_event/artifact_bad_major + description = "Something is very, very wrong around here." + mood_change = -6 + timeout = 5 MINUTES + +/datum/mood_event/artifact_bad_minor + description = "I feel uneasy and I can't say why." + mood_change = -3 + timeout = 3 MINUTES diff --git a/voidcrew/modules/xenoarch/effects/gas.dm b/voidcrew/modules/xenoarch/effects/gas.dm new file mode 100644 index 00000000000..9e617b33629 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/gas.dm @@ -0,0 +1,58 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/gas.dm. +// Voidcrew: oxygen, nitrogen or carbon dioxide only (plasma and nitrous oxide are their own +// effects); room-temperature gas; vents indoors only, never into planet air; stops at a modest +// pressure; no destroy burst. + +/datum/artifact_effect/gas + label_name = "Vents gas" + log_name = "Gas" + tier = ARTIFACT_TIER_COMMON + possible_kinds = list(ARTIFACT_ENERGY_PARTICLE, ARTIFACT_ENERGY_SYNTHESIS) + /// The gas it vents + var/gas_type = /datum/gas/oxygen + /// It stops venting once its tile reaches this pressure (kPa) + var/max_pressure = ONE_ATMOSPHERE + +/datum/artifact_effect/gas/setup() + var/static/list/gas_weights = list( + /datum/gas/oxygen = 4, + /datum/gas/nitrogen = 4, + /datum/gas/carbon_dioxide = 2, + ) + gas_type = pick_weight(gas_weights) + max_pressure = rand(105, 150) + +/datum/artifact_effect/gas/copy_settings_to(datum/artifact_effect/gas/copy) + copy.gas_type = gas_type + copy.max_pressure = max_pressure + +/datum/artifact_effect/gas/do_effect_touch(mob/living/user) + if(vent(rand(2, 8))) + to_chat(user, span_warning("Gas hisses out of [holder] around your hand.")) + +/datum/artifact_effect/gas/do_effect_aura(seconds_per_tick) + vent(rand(0, 3) * seconds_per_tick) + +/datum/artifact_effect/gas/do_effect_pulse(power) + if(vent(power / 2)) + playsound(holder, 'sound/effects/spray.ogg', 40, TRUE) + +/datum/artifact_effect/gas/do_effect_destroy() + return + +/// Lets `moles` of its gas out onto its tile. Returns TRUE if any came out. +/datum/artifact_effect/gas/proc/vent(moles) + if(moles <= 0) + return FALSE + var/turf/open/here = source_turf() + if(!istype(here) || here.planetary_atmos || isspaceturf(here)) + return FALSE + var/datum/gas_mixture/air = here.return_air() + if(!air || air.return_pressure() >= max_pressure) + return FALSE + var/datum/gas_mixture/puff = new + puff.assert_gas(gas_type) + puff.gases[gas_type][MOLES] = moles + puff.temperature = T20C + here.assume_air(puff) + return TRUE diff --git a/voidcrew/modules/xenoarch/effects/gravity.dm b/voidcrew/modules/xenoarch/effects/gravity.dm new file mode 100644 index 00000000000..e864b8e520f --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/gravity.dm @@ -0,0 +1,58 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/gravity.dm. +// Voidcrew: shorter reach (four tiles of pull or push, not eight), anchored and heavy things stay +// put, only loose items, structures and people move; any trigger. Moving people is logged. + +#define ARTIFACT_GRAVITY_PULL 1 +#define ARTIFACT_GRAVITY_PUSH 2 +#define ARTIFACT_GRAVITY_SCATTER 3 + +/datum/artifact_effect/gravity + label_name = "Gravity" + log_name = "Gravity" + tier = ARTIFACT_TIER_UNCOMMON + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_BLUESPACE) + allowed_release_methods = list(ARTIFACT_RELEASE_PULSE) + /// Pull, push or scatter + var/mode = ARTIFACT_GRAVITY_PULL + +/datum/artifact_effect/gravity/setup() + mode = pick(ARTIFACT_GRAVITY_PULL, ARTIFACT_GRAVITY_PUSH, ARTIFACT_GRAVITY_SCATTER) + range = min(range, 6) + +/datum/artifact_effect/gravity/copy_settings_to(datum/artifact_effect/gravity/copy) + copy.mode = mode + +/datum/artifact_effect/gravity/do_effect_pulse(power) + var/turf/center = source_turf() + if(!center) + return + new /obj/effect/temp_visual/circle_wave/gravity(center) + for(var/atom/movable/thing in range(range, center)) + if(thing == holder || !can_shift(thing)) + continue + var/exposure = isliving(thing) ? artifact_exposure(thing) : 1 + if(exposure <= 0) + continue + switch(mode) + if(ARTIFACT_GRAVITY_PULL) + for(var/step in 1 to round(4 * exposure)) + step_towards(thing, center) + if(ARTIFACT_GRAVITY_PUSH) + for(var/step in 1 to round(4 * exposure)) + step_away(thing, center) + if(ARTIFACT_GRAVITY_SCATTER) + var/turf/landing = pick(RANGE_TURFS(range, center)) + thing.throw_at(landing, round(4 * exposure), 2) + if(isliving(thing)) + log_harm(thing) + +/// Loose items, unanchored structures and the living; nothing anchored or immovable. +/datum/artifact_effect/gravity/proc/can_shift(atom/movable/thing) + if(thing.anchored || thing.move_resist > MOVE_FORCE_STRONG) + return FALSE + return isitem(thing) || isstructure(thing) || isliving(thing) + +#undef ARTIFACT_GRAVITY_PULL +#undef ARTIFACT_GRAVITY_PUSH +#undef ARTIFACT_GRAVITY_SCATTER diff --git a/voidcrew/modules/xenoarch/effects/hallucination.dm b/voidcrew/modules/xenoarch/effects/hallucination.dm new file mode 100644 index 00000000000..677ae52e793 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/hallucination.dm @@ -0,0 +1,35 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/drugs.dm. +// Voidcrew: real hallucinations as well as the colours, capped at a minute. + +/datum/artifact_effect/hallucination + label_name = "Hallucinations" + log_name = "Hallucination" + tier = ARTIFACT_TIER_UNCOMMON + minor = TRUE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_ORGANIC) + +/datum/artifact_effect/hallucination/do_effect_touch(mob/living/user) + if(dose(user, 20 SECONDS)) + to_chat(user, span_hypnophrase("The colours go soft at the edges.")) + +/datum/artifact_effect/hallucination/do_effect_aura(seconds_per_tick) + for(var/mob/living/carbon/target in living_in_range()) + if(SPT_PROB(20, seconds_per_tick) && dose(target, 5 SECONDS)) + to_chat(target, span_hypnophrase("Something at the corner of your eye moves.")) + +/datum/artifact_effect/hallucination/do_effect_pulse(power) + for(var/mob/living/carbon/target in living_in_range()) + if(dose(target, power SECONDS)) + to_chat(target, span_hypnophrase("The world tilts and blooms.")) + +/datum/artifact_effect/hallucination/proc/dose(mob/living/target, duration) + if(!iscarbon(target) || target.stat == DEAD) + return FALSE + var/exposure = artifact_exposure(target) + if(exposure <= 0) + return FALSE + target.adjust_hallucinations_up_to(duration * exposure, 60 SECONDS) + target.adjust_drugginess_up_to(duration * exposure, 30 SECONDS) + log_harm(target) + return TRUE diff --git a/voidcrew/modules/xenoarch/effects/health.dm b/voidcrew/modules/xenoarch/effects/health.dm new file mode 100644 index 00000000000..5c5135220d9 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/health.dm @@ -0,0 +1,94 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/health.dm. +// Voidcrew: healing shares one cap per person across every artifact (artifact_capped_heal()), +// so a heal artifact bolted in a corridor is a comfort, not a medbay. Robo variants work on +// silicons, robotic mobs and prosthetic limbs. Hurt effects log like weapons. Nova's numbers +// (pulses scaling to 250 healing or 160 damage) are cut to sane ranges. + +/datum/artifact_effect/heal + label_name = "Heals" + log_name = "Heal" + tier = ARTIFACT_TIER_UNCOMMON + minor = TRUE + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC) + /// Which limbs it works on for carbons, and which mobs otherwise + var/robotic = FALSE + +/datum/artifact_effect/heal/do_effect_touch(mob/living/user) + if(mend(user, 12)) + to_chat(user, span_notice(robotic ? "Your damaged parts knit themselves back together." : "A soothing warmth spreads through you.")) + +/datum/artifact_effect/heal/do_effect_aura(seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + if(mend(target, 0.75 * seconds_per_tick) && prob(10)) + to_chat(target, span_notice(robotic ? "A faint hum steadies your systems." : "You feel a gentle warmth from something nearby.")) + +/datum/artifact_effect/heal/do_effect_pulse(power) + for(var/mob/living/target as anything in living_in_range()) + if(mend(target, clamp(power * 0.6, 4, 20))) + to_chat(target, span_notice(robotic ? "A wave of energy mends your plating." : "A wave of warmth washes through you.")) + +/// Heals one mob within the shared cap. Returns the amount healed. +/datum/artifact_effect/heal/proc/mend(mob/living/target, amount) + if(target.stat == DEAD) + return 0 + amount *= artifact_exposure(target) + if(iscarbon(target)) + return artifact_capped_heal(target, amount, robotic ? BODYTYPE_ROBOTIC : BODYTYPE_ORGANIC) + if(robotic ? !(target.mob_biotypes & MOB_ROBOTIC) : !(target.mob_biotypes & MOB_ORGANIC)) + return 0 + return artifact_capped_heal(target, amount) + +/datum/artifact_effect/heal/robotic + label_name = "Mends machines" + log_name = "Robo-heal" + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC, ARTIFACT_ENERGY_PARTICLE) + robotic = TRUE + +/datum/artifact_effect/hurt + label_name = "Hurts" + log_name = "Hurt" + tier = ARTIFACT_TIER_UNCOMMON + minor = TRUE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_CONCENTRATED, ARTIFACT_ENERGY_ORGANIC) + var/robotic = FALSE + +/datum/artifact_effect/hurt/do_effect_touch(mob/living/user) + if(wound(user, 8)) + to_chat(user, span_warning(robotic ? "Your systems report sudden damage!" : "A painful discharge of energy strikes you!")) + +/datum/artifact_effect/hurt/do_effect_aura(seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + if(wound(target, 0.5 * seconds_per_tick) && prob(10)) + to_chat(target, span_warning(robotic ? "Something nearby is stressing your plating." : "You feel a painful force radiating from something nearby.")) + +/datum/artifact_effect/hurt/do_effect_pulse(power) + for(var/mob/living/target as anything in living_in_range()) + if(wound(target, clamp(power * 0.3, 3, 12))) + to_chat(target, span_warning(robotic ? "A wave of energy buckles your plating!" : "A wave of energy tears at your skin!")) + +/// Hurts one mob, split between brute and burn. Returns TRUE if it took any. +/datum/artifact_effect/hurt/proc/wound(mob/living/target, amount) + if(target.stat == DEAD) + return FALSE + amount *= artifact_exposure(target) + if(amount <= 0) + return FALSE + if(iscarbon(target)) + var/before = target.getBruteLoss() + target.getFireLoss() + target.take_overall_damage(amount / 2, amount / 2, required_bodytype = robotic ? BODYTYPE_ROBOTIC : BODYTYPE_ORGANIC) + if(target.getBruteLoss() + target.getFireLoss() <= before) + return FALSE + else + if(robotic ? !(target.mob_biotypes & MOB_ROBOTIC) : !(target.mob_biotypes & MOB_ORGANIC)) + return FALSE + target.take_overall_damage(amount / 2, amount / 2) + log_harm(target) + return TRUE + +/datum/artifact_effect/hurt/robotic + label_name = "Damages machines" + log_name = "Robo-hurt" + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC, ARTIFACT_ENERGY_PARTICLE) + robotic = TRUE diff --git a/voidcrew/modules/xenoarch/effects/light.dm b/voidcrew/modules/xenoarch/effects/light.dm new file mode 100644 index 00000000000..a66120473f6 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/light.dm @@ -0,0 +1,41 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/light.dm. +// Voidcrew: shines (or darkens) the whole time it is awake and costs no charge; any trigger; +// brightness kept to an ordinary lamp's range. + +/datum/artifact_effect/light + label_name = "Light" + log_name = "Light" + tier = ARTIFACT_TIER_COMMON + minor = TRUE + possible_kinds = list(ARTIFACT_ENERGY_CONCENTRATED, ARTIFACT_ENERGY_PARTICLE) + allowed_release_methods = list(ARTIFACT_RELEASE_AURA) + /// Light colour + var/light_tint = "#fff3d6" + /// Light power sign: 1 for light, -1 for darkness + var/light_sign = 1 + +/datum/artifact_effect/light/release_aura(seconds_per_tick) + return + +/datum/artifact_effect/light/do_effect_destroy() + return + +/datum/artifact_effect/light/light_override() + return list(range, light_sign * clamp(maximum_charges / 12, 0.8, 3), light_tint) + +/datum/artifact_effect/light/on_activate() + // An artifact refreshes its own light; anything else (a battery in a utilizer) is lit here. + if(holder && !istype(holder, /obj/machinery/artifact)) + var/list/glow = light_override() + holder.set_light(glow[1], glow[2], glow[3]) + +/datum/artifact_effect/light/on_deactivate() + if(holder && !istype(holder, /obj/machinery/artifact)) + holder.set_light(0) + +/datum/artifact_effect/light/darkness + label_name = "Darkness" + log_name = "Darkness" + possible_kinds = list(ARTIFACT_ENERGY_PARTICLE, ARTIFACT_ENERGY_BLUESPACE) + light_tint = COLOR_WHITE + light_sign = -1 diff --git a/voidcrew/modules/xenoarch/effects/noise.dm b/voidcrew/modules/xenoarch/effects/noise.dm new file mode 100644 index 00000000000..b0bf2c93d43 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/noise.dm @@ -0,0 +1,34 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/noise.dm. +// Voidcrew: eerie sounds only (Nova's list mixed in bike horns and sitcom laughter); any trigger +// (Nova fixed it to oxygen, which is gone); not harvestable. + +/datum/artifact_effect/noise + label_name = "Noise" + log_name = "Noise" + tier = ARTIFACT_TIER_COMMON + minor = TRUE + harvestable = FALSE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC) + allowed_release_methods = list(ARTIFACT_RELEASE_PULSE) + +/datum/artifact_effect/noise/do_effect_pulse(power) + var/static/list/noises = list( + 'sound/effects/hallucinations/wail.ogg', + 'sound/effects/hallucinations/far_noise.ogg', + 'sound/effects/hallucinations/veryfar_noise.ogg', + 'sound/effects/hallucinations/growl1.ogg', + 'sound/effects/hallucinations/growl2.ogg', + 'sound/effects/hallucinations/growl3.ogg', + 'sound/effects/hallucinations/radio_static.ogg', + 'sound/effects/screech.ogg', + 'sound/effects/gong.ogg', + 'sound/effects/clang.ogg', + 'sound/effects/magic/curse.ogg', + 'sound/ambience/misc/signal.ogg', + 'sound/machines/airlock/airlock_alien_prying.ogg', + 'sound/misc/scary_horn.ogg', + ) + playsound(holder, pick(noises), 60, TRUE, extrarange = 6) + +/datum/artifact_effect/noise/do_effect_destroy() + playsound(holder, 'sound/effects/hallucinations/wail.ogg', 80, TRUE, extrarange = 20, pressure_affected = FALSE) diff --git a/voidcrew/modules/xenoarch/effects/radiation.dm b/voidcrew/modules/xenoarch/effects/radiation.dm new file mode 100644 index 00000000000..5c518663dcf --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/radiation.dm @@ -0,0 +1,42 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/radiate.dm. +// Voidcrew: lower aura chances (Nova reached 100% per tick); exposure logged. + +/datum/artifact_effect/radiation + label_name = "Radiation" + log_name = "Radiation" + tier = ARTIFACT_TIER_RARE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PARTICLE, ARTIFACT_ENERGY_ORGANIC) + /// How hot it runs, 1 to 5 + var/intensity = 1 + +/datum/artifact_effect/radiation/setup() + intensity = rand(1, 5) + +/datum/artifact_effect/radiation/copy_settings_to(datum/artifact_effect/radiation/copy) + copy.intensity = intensity + +/datum/artifact_effect/radiation/do_effect_touch(mob/living/user) + var/turf/here = source_turf() + if(!here) + return + radiation_pulse(here, max_range = 1, threshold = RAD_MEDIUM_INSULATION, chance = 50) + log_harm(user) + +/datum/artifact_effect/radiation/do_effect_aura(seconds_per_tick) + var/turf/here = source_turf() + if(!here) + return + radiation_pulse(here, max_range = range, threshold = RAD_MEDIUM_INSULATION, chance = 4 + intensity * 2) + log_nearby() + +/datum/artifact_effect/radiation/do_effect_pulse(power) + var/turf/here = source_turf() + if(!here) + return + radiation_pulse(here, max_range = range, threshold = RAD_MEDIUM_INSULATION, chance = clamp(power, 10, 50)) + log_nearby() + +/datum/artifact_effect/radiation/proc/log_nearby() + for(var/mob/living/carbon/human/target in living_in_range()) + log_harm(target) diff --git a/voidcrew/modules/xenoarch/effects/sleepy.dm b/voidcrew/modules/xenoarch/effects/sleepy.dm new file mode 100644 index 00000000000..38a2a324fcc --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/sleepy.dm @@ -0,0 +1,35 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/sleepy.dm. +// Voidcrew: drowsiness first, then short sleeps (Nova slept you 25 seconds per touch). + +/datum/artifact_effect/sleepy + label_name = "Puts people to sleep" + log_name = "Sleep" + tier = ARTIFACT_TIER_UNCOMMON + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_ORGANIC) + +/datum/artifact_effect/sleepy/do_effect_touch(mob/living/user) + lull(user, 30 SECONDS, 4 SECONDS) + +/datum/artifact_effect/sleepy/do_effect_aura(seconds_per_tick) + for(var/mob/living/carbon/target in living_in_range()) + if(SPT_PROB(30, seconds_per_tick)) + lull(target, 4 SECONDS) + +/datum/artifact_effect/sleepy/do_effect_pulse(power) + for(var/mob/living/carbon/target in living_in_range()) + lull(target, power * 0.8 SECONDS, power >= 30 ? 3 SECONDS : 0) + +/// Makes `target` drowsy (up to 40 seconds) and maybe nod off. +/datum/artifact_effect/sleepy/proc/lull(mob/living/target, drowsy, nap = 0) + if(!iscarbon(target) || target.stat == DEAD) + return + var/exposure = artifact_exposure(target) + if(exposure <= 0) + return + if(prob(25)) + to_chat(target, span_notice(pick("You feel a yawn coming on.", "Your eyelids are heavy.", "You could do with a nap."))) + target.adjust_drowsiness_up_to(drowsy * exposure, 40 SECONDS) + if(nap) + target.Sleeping(nap * exposure) + log_harm(target) diff --git a/voidcrew/modules/xenoarch/effects/stun.dm b/voidcrew/modules/xenoarch/effects/stun.dm new file mode 100644 index 00000000000..e970bc04431 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/stun.dm @@ -0,0 +1,38 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/stun.dm. +// Voidcrew: Nova's touch put 200 stamina damage into one hand; touch now knocks you down briefly, +// auras sap stamina, bursts can floor a room. Logged like a weapon. + +/datum/artifact_effect/stun + label_name = "Knocks people down" + log_name = "Stun" + tier = ARTIFACT_TIER_UNCOMMON + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_ORGANIC) + +/datum/artifact_effect/stun/do_effect_touch(mob/living/user) + if(sap(user, 45, 2 SECONDS)) + to_chat(user, span_warning("A jolt runs up your arm and your legs give out.")) + +/datum/artifact_effect/stun/do_effect_aura(seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + if(SPT_PROB(15, seconds_per_tick) && sap(target, 12)) + to_chat(target, span_warning("Your limbs go heavy for a moment.")) + +/datum/artifact_effect/stun/do_effect_pulse(power) + for(var/mob/living/target as anything in living_in_range()) + if(sap(target, clamp(power * 2, 20, 70), power >= 20 ? 1.5 SECONDS : 0)) + to_chat(target, span_warning("A wave of force knocks the wind out of you!")) + +/// Saps stamina and maybe knocks down. Returns TRUE if it took. +/datum/artifact_effect/stun/proc/sap(mob/living/target, stamina, knockdown = 0) + if(target.stat != CONSCIOUS || issilicon(target)) + return FALSE + var/exposure = artifact_exposure(target) + if(exposure <= 0) + return FALSE + target.adjust_confusion_up_to(3 SECONDS * exposure, 10 SECONDS) + target.adjustStaminaLoss(stamina * exposure) + if(knockdown) + target.Knockdown(knockdown * exposure) + log_harm(target) + return TRUE diff --git a/voidcrew/modules/xenoarch/effects/teleport.dm b/voidcrew/modules/xenoarch/effects/teleport.dm new file mode 100644 index 00000000000..f5f6bfbb237 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/teleport.dm @@ -0,0 +1,55 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/teleport.dm. +// Voidcrew: short hops onto open floor (never into walls, space or a chasm), through unforced +// do_teleport() so no-teleport areas and the zone guard still apply; no sparks, which light +// plasma; logged. + +/datum/artifact_effect/teleport + label_name = "Teleports" + log_name = "Teleport" + tier = ARTIFACT_TIER_RARE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_BLUESPACE) + +/datum/artifact_effect/teleport/do_effect_touch(mob/living/user) + if(hop(user, 6)) + to_chat(user, span_warning("The world folds, and you are somewhere else.")) + +/datum/artifact_effect/teleport/do_effect_aura(seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + if(SPT_PROB(10, seconds_per_tick) && hop(target, range)) + to_chat(target, span_warning("You are displaced by a strange force!")) + +/datum/artifact_effect/teleport/do_effect_pulse(power) + var/turf/center = source_turf() + if(center) + new /obj/effect/temp_visual/circle_wave/bluespace(center) + for(var/mob/living/target as anything in living_in_range()) + if(hop(target, range + 2)) + to_chat(target, span_warning("You are displaced by a strange force!")) + +/// Hops `target` to a random open floor up to `distance` tiles away. Returns TRUE if it moved. +/datum/artifact_effect/teleport/proc/hop(mob/living/target, distance) + if(target.buckled || target.anchored) + return FALSE + var/exposure = artifact_exposure(target) + distance = round(distance * exposure) + if(distance < 1) + return FALSE + var/turf/origin = get_turf(target) + var/list/landings = list() + for(var/turf/open/spot in RANGE_TURFS(distance, origin)) + if(spot == origin || isspaceturf(spot) || isgroundlessturf(spot) || islava(spot)) + continue + if(spot.is_blocked_turf(exclude_mobs = TRUE)) + continue + landings += spot + if(!length(landings)) + return FALSE + var/turf/landing = pick(landings) + new /obj/effect/temp_visual/bluespace_fissure(origin) + if(!do_teleport(target, landing, precision = 0, no_effects = TRUE, channel = TELEPORT_CHANNEL_BLUESPACE)) + return FALSE + playsound(origin, 'sound/effects/magic/blink.ogg', 40, TRUE) + playsound(landing, 'sound/effects/phasein.ogg', 40, TRUE) + log_harm(target) + return TRUE diff --git a/voidcrew/modules/xenoarch/effects/temperature.dm b/voidcrew/modules/xenoarch/effects/temperature.dm new file mode 100644 index 00000000000..752c88dba9d --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/temperature.dm @@ -0,0 +1,84 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/temperature.dm. +// Voidcrew: gentle targets (a common minor effect makes a room uncomfortable, not lethal); bodies +// never pushed past the damage limits; planet air is never written (bodies only on planet turfs); +// no destroy burst. + +/datum/artifact_effect/temperature + tier = ARTIFACT_TIER_COMMON + minor = TRUE + allowed_release_methods = list(ARTIFACT_RELEASE_TOUCH, ARTIFACT_RELEASE_AURA, ARTIFACT_RELEASE_PULSE) + /// Air near it moves toward this + var/target_temp = T20C + var/target_low = T20C + var/target_high = T20C + /// Bodies never go past this (kept short of the damage limit) + var/body_limit = T20C + /// Kelvin per second the tile's air moves while the aura runs + var/air_rate = 8 + /// Kelvin per second bodies in range move while the aura runs + var/body_rate = 1.5 + /// Shown to a toucher + var/touch_message + +/datum/artifact_effect/temperature/setup() + target_temp = rand(target_low, target_high) + +/datum/artifact_effect/temperature/copy_settings_to(datum/artifact_effect/temperature/copy) + copy.target_temp = target_temp + +/datum/artifact_effect/temperature/do_effect_touch(mob/living/user) + to_chat(user, span_warning(touch_message)) + nudge_body(user, 15) + nudge_air(20) + +/datum/artifact_effect/temperature/do_effect_aura(seconds_per_tick) + nudge_air(air_rate * seconds_per_tick) + for(var/mob/living/target as anything in living_in_range()) + nudge_body(target, body_rate * seconds_per_tick) + +/datum/artifact_effect/temperature/do_effect_pulse(power) + nudge_air(clamp(power * 2, 10, 60)) + for(var/mob/living/target as anything in living_in_range()) + nudge_body(target, clamp(power / 3, 3, 12)) + +/datum/artifact_effect/temperature/do_effect_destroy() + return + +/// Moves the air on its own tile toward the target. Never planet air, never vacuum. +/datum/artifact_effect/temperature/proc/nudge_air(amount) + var/turf/open/here = source_turf() + if(!istype(here) || here.planetary_atmos || isspaceturf(here)) + return + var/datum/gas_mixture/air = here.return_air() + if(!air || air.total_moles() <= 0) + return + var/difference = target_temp - air.temperature + if(abs(difference) < 1) + return + air.temperature += clamp(difference, -amount, amount) + here.air_update_turf(FALSE, FALSE) + +/datum/artifact_effect/temperature/proc/nudge_body(mob/living/target, amount) + var/goal = target_temp > T20C ? min(target_temp, body_limit) : max(target_temp, body_limit) + var/difference = goal - target.bodytemperature + if(abs(difference) < 0.5) + return + target.adjust_bodytemperature(clamp(difference, -amount, amount) * artifact_exposure(target)) + +/datum/artifact_effect/temperature/heat + label_name = "Heat" + log_name = "Heat" + possible_kinds = list(ARTIFACT_ENERGY_CONCENTRATED, ARTIFACT_ENERGY_SYNTHESIS) + target_low = 310 + target_high = 345 + body_limit = BODYTEMP_HEAT_DAMAGE_LIMIT - 5 + touch_message = "A wave of heat runs up your arm." + +/datum/artifact_effect/temperature/cold + label_name = "Cold" + log_name = "Cold" + possible_kinds = list(ARTIFACT_ENERGY_SYNTHESIS, ARTIFACT_ENERGY_BLUESPACE) + target_low = 255 + target_high = 280 + body_limit = BODYTEMP_COLD_DAMAGE_LIMIT + 5 + touch_message = "A chill runs up your spine." diff --git a/voidcrew/modules/xenoarch/effects/tesla.dm b/voidcrew/modules/xenoarch/effects/tesla.dm new file mode 100644 index 00000000000..9d88b5f38a0 --- /dev/null +++ b/voidcrew/modules/xenoarch/effects/tesla.dm @@ -0,0 +1,26 @@ +// Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/tesla.dm. +// Voidcrew: ZAP_GENERATES_POWER removed, so tesla coils get nothing from it; weaker arcs (Nova's +// reached 90 burn); not harvestable; logged. + +/datum/artifact_effect/tesla + label_name = "Lightning" + log_name = "Tesla" + tier = ARTIFACT_TIER_EXTREME + harvestable = FALSE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC, ARTIFACT_ENERGY_CONCENTRATED) + allowed_release_methods = list(ARTIFACT_RELEASE_PULSE) + /// Never ZAP_GENERATES_POWER: no running a ship off an artifact. + var/zap_flags = ZAP_OBJ_DAMAGE | ZAP_MOB_DAMAGE | ZAP_MOB_STUN + +/datum/artifact_effect/tesla/setup() + maximum_charges = max(maximum_charges, 15) + +/datum/artifact_effect/tesla/do_effect_pulse(power) + if(QDELETED(holder)) + return + var/reach = 3 + rand(0, 2) + playsound(holder, 'sound/effects/magic/lightningshock.ogg', 60, TRUE, extrarange = 5) + tesla_zap(source = holder, zap_range = reach, power = rand(12, 30) * 1000, cutoff = 1e4, zap_flags = zap_flags) + for(var/mob/living/target in range(reach, source_turf())) + log_harm(target) From 8c93939327407a8a1be5695866ba4ecdf92e40bf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:06:10 -0800 Subject: [PATCH 22/72] Tidy artifact flavour text and brighten the idle glow --- voidcrew/modules/xenoarch/core/artifact_appearance.dm | 6 +++--- voidcrew/modules/xenoarch/effects/hallucination.dm | 2 +- voidcrew/modules/xenoarch/effects/teleport.dm | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/xenoarch/core/artifact_appearance.dm b/voidcrew/modules/xenoarch/core/artifact_appearance.dm index 6f578ebe3a5..347790a2883 100644 --- a/voidcrew/modules/xenoarch/core/artifact_appearance.dm +++ b/voidcrew/modules/xenoarch/core/artifact_appearance.dm @@ -146,7 +146,7 @@ GLOBAL_LIST_INIT(artifact_examine_notes, list( "Up close, the surface has a fine grain, like fingerprints.", "It doesn't ring when tapped. It doesn't make any sound at all.", "Dust doesn't seem to settle on it.", - "Your reflection in it is a moment late.", + "It smells faintly of ozone.", )) /// Lines for falling asleep: "[artifact] [line]" @@ -360,8 +360,8 @@ GLOBAL_LIST_INIT(artifact_examine_notes, list( size = 1.5 period = 2 SECONDS else - low_alpha = "14" - high_alpha = "3c" + low_alpha = "18" + high_alpha = "50" size = 1 period = 3 SECONDS if(flash) diff --git a/voidcrew/modules/xenoarch/effects/hallucination.dm b/voidcrew/modules/xenoarch/effects/hallucination.dm index 677ae52e793..49357b5df31 100644 --- a/voidcrew/modules/xenoarch/effects/hallucination.dm +++ b/voidcrew/modules/xenoarch/effects/hallucination.dm @@ -21,7 +21,7 @@ /datum/artifact_effect/hallucination/do_effect_pulse(power) for(var/mob/living/carbon/target in living_in_range()) if(dose(target, power SECONDS)) - to_chat(target, span_hypnophrase("The world tilts and blooms.")) + to_chat(target, span_hypnophrase("Everything starts to swim.")) /datum/artifact_effect/hallucination/proc/dose(mob/living/target, duration) if(!iscarbon(target) || target.stat == DEAD) diff --git a/voidcrew/modules/xenoarch/effects/teleport.dm b/voidcrew/modules/xenoarch/effects/teleport.dm index f5f6bfbb237..5b31e1ed267 100644 --- a/voidcrew/modules/xenoarch/effects/teleport.dm +++ b/voidcrew/modules/xenoarch/effects/teleport.dm @@ -12,7 +12,7 @@ /datum/artifact_effect/teleport/do_effect_touch(mob/living/user) if(hop(user, 6)) - to_chat(user, span_warning("The world folds, and you are somewhere else.")) + to_chat(user, span_warning("You're suddenly standing somewhere else!")) /datum/artifact_effect/teleport/do_effect_aura(seconds_per_tick) for(var/mob/living/target as anything in living_in_range()) From a01a027cd52d61cc6e54c106507c246df54cf409 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:50:42 -0800 Subject: [PATCH 23/72] Give the specimen crate its own art and a lid tag A lead-grey recolour of the plain crate with padded straps and a stencilled lid. A shut crate holding a find shows its field tag. --- .../modules/xenoarch/icons/specimen_crate.dmi | Bin 0 -> 1131 bytes .../modules/xenoarch/trade/specimen_crate.dm | 22 ++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 voidcrew/modules/xenoarch/icons/specimen_crate.dmi diff --git a/voidcrew/modules/xenoarch/icons/specimen_crate.dmi b/voidcrew/modules/xenoarch/icons/specimen_crate.dmi new file mode 100644 index 0000000000000000000000000000000000000000..8820a1096e6bf0a990cbf984a057746ef9f0233d GIT binary patch literal 1131 zcmeAS@N?(olHy`uVBq!ia0vp^4nUm1!3HGP9xZtRq}r=OB1&9Ri<65o3raHc^B5Q^ z<^+co6qSDe5?t`{>k}<+U9EFx&IfM@HMnT};E~RGAI*~tMLoScER2JUFB^L~7>UIk zRqa$=>98!Q&@rnhEiY@Hka)URTA*RTi3#1tYc&kbMGqYdm=W*L^61WyJ!iIDc_h@t zbvn2>C@8o<6KK`fV#aE2h6^SRLg^K4a~K$y=X<(1hE&XXJICHbI#A~L{-1?c_~trI z+~(FkNikz$(2~i{%Xx&l7FbNV;Mvt2%&uF)c~o%cWxegcToP1M9syb34XgC_s@^mwkYmC>l;r67LLWmdDCNWa~*%S zJTU5ikKfN7v-rN=xt#FE=trJPWAk%%rS%5>j#z&bTs=i1%yHP&C+UuFhm!c1{x=nYZ81~L%D_bG_H16LM=AD9{YPrswI~ysr z_-d_<6#FT`>{?@sm+#(B7u>-j6Uuj`_(0h;t0n6B;-Qfi_4hX=s^2|vqT`de2*;s| zKh8H#Z2u8;B0jpYeBHTyTXJrAeqSLJ`0rKW_qZi*tz9?o>I;7G^2z39@|oMsjjpFG zPn+!f(r8YOvERSB8(tf~eU<&JEo*7gRGGE$|I?oST5s{~>tWC7*BR8`Y~o(LTdTA6 zv#0mpKaXBZ|FwR3>u+}7-o%GD{U0=jR!q-(u}1W6*oQ;=udUd)>tj%=;D)q~l2#YL zUa|*8XiLxbqw%_WFXL=FKD_PP&$Rja@2B=x>SN<@L^fvh6D{&&+QSH)ZHL%Z)D&d+ zUN2w1Jpbo|Vmqmm;@z&clTSvZ{FwFn{JC=>(YBImQ@vi6MHX9Mzgc?rxm1kO?6Yxm zk3as{wrOclr+d$>)x$fLAf!!&d+H7yjc`e0qc7oA$Ogg>PmvPCqTE zsOV5wyfgF9vZL?!`#R_@_`Fw+<>7?;vL9yGU(*pdx}fH>2e9;D@O1TaS?83{1OVFL B5`zE$ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/xenoarch/trade/specimen_crate.dm b/voidcrew/modules/xenoarch/trade/specimen_crate.dm index 5242834cb0d..3bcd9203c23 100644 --- a/voidcrew/modules/xenoarch/trade/specimen_crate.dm +++ b/voidcrew/modules/xenoarch/trade/specimen_crate.dm @@ -3,13 +3,24 @@ * * 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. + * Any shut crate gets a find past an outpost checkpoint and up to a buyer; this one takes only + * the find and small things, never people or other machines, and shows the find's field tag on + * the lid so a hold full of crates can be told apart. + * + * Art: a recolour of tg's plain crate (icons/obj/storage/crates.dmi "crate"/"crateopen"), lead + * grey with padded straps; "manifest" and "welded" are tg's own, copied so crate overlays line up. */ /obj/structure/closet/crate/specimen name = "specimen crate" desc = "A padded, lead-lined crate big enough for one large find." + icon = 'voidcrew/modules/xenoarch/icons/specimen_crate.dmi' + icon_state = "specimen" + base_icon_state = "specimen" allow_dense = TRUE allow_objects = TRUE storage_capacity = 10 + // Its own sprite sheet holds no other crate's states + paint_jobs = null /obj/structure/closet/crate/specimen/insertion_allowed(atom/movable/thing) // The only dense thing it takes is one artifact. No people, no other machines. @@ -21,3 +32,14 @@ if(ismob(thing) || (isobj(thing) && thing.density)) return FALSE return ..() + +/obj/structure/closet/crate/specimen/examine(mob/user) + . = ..() + if(opened) + return + var/obj/machinery/artifact/find = locate() in contents + if(!find) + return + . += "It sits heavy on its straps." + if(find.is_dug()) + . += "A field tag clipped to the lid reads [find.serial]." From 24a561c4a17bd7d86248c4b97e137abc55b63136 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:50:42 -0800 Subject: [PATCH 24/72] Sell dig finds to Barnaby, Boffin and Vex Each buyer gets a large-find row and (Barnaby, Boffin) a curio row on their wanted ledger. A large find sells when the seller hauls it to the counter, loose or in a shut crate: the trader looks it over, pays the sale value into the usual account, hands over a receipt in their own hand and says what they made of it. Vex adds a voucher for an extreme find. Undug finds are refused. Traders remark on finds hauled in. Outpost checkpoints turn back a loose find on the way in, with a line from the guard. Sales record the artifact_sold metric and send COMSIG_ARTIFACT_SOLD. --- .../unit_tests/voidcrew_xenoarch_trade.dm | 271 +++++ voidcrew/modules/xenoarch/trade/buyers.dm | 941 +++++++++++++++++- voidcrew/modules/xenoarch/trade/checkpoint.dm | 79 +- voidcrew/modules/xenoarch/trade/metrics.dm | 23 +- 4 files changed, 1311 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm index 46dc0d06d1f..35bd1273292 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm @@ -1 +1,272 @@ // Xenoarchaeology tests: trade. +// +// Test files compile before voidcrew/_DEFINES, so the values below are literals. They mirror +// voidcrew/_DEFINES/xenoarch.dm and the part-local defines in xenoarch/trade/: +// bands (ZONE_*): green 1, yellow 2, red 3 +// tiers: common 1, uncommon 2, rare 3, extreme 4 +// COMSIG_ARTIFACT_SOLD "artifact_sold" +// XENOARCH_FIND_DEMAND 3, XENOARCH_CURIO_DEMAND 6 +// buyer line keys "perfect", "close", "wrong", "blank", "live" +// checkpoint refusal for a loose find "xenoarch_loose_find" + +/// Shared helpers for the trade tests; runs nothing itself. +/datum/unit_test/voidcrew_xenoarch_trade + abstract_type = /datum/unit_test/voidcrew_xenoarch_trade + +/// A seller with a bank card in hand, the way the outpost ledger finds an account without a crew. +/datum/unit_test/voidcrew_xenoarch_trade/proc/make_seller(datum/bank_account/account) + var/mob/living/carbon/human/consistent/seller = allocate(/mob/living/carbon/human/consistent) + var/obj/item/card/id/advanced/card = allocate(/obj/item/card/id/advanced) + qdel(card.registered_account) + card.registered_account = account + seller.put_in_hands(card) + return seller + +/// Deletes the receipts and vouchers a sale left in hand or underfoot. +/datum/unit_test/voidcrew_xenoarch_trade/proc/tidy(mob/living/seller) + for(var/obj/item/paper/xenoarch_receipt/receipt in near_seller(seller)) + qdel(receipt) + for(var/obj/item/stack/trade_voucher/chits in near_seller(seller)) + qdel(chits) + +/// Everything the seller holds, plus everything at their feet. +/datum/unit_test/voidcrew_xenoarch_trade/proc/near_seller(mob/living/seller) + var/turf/feet = get_turf(seller) + return seller.get_all_contents() + feet.contents + +/// Barnaby, Boffin and Vex buy finds; Vex takes no curios; nobody else buys either. +/datum/unit_test/voidcrew_xenoarch_trade/buyers/Run() + var/list/expected = list( + /datum/outpost_shop/general = list(TRUE, TRUE), + /datum/outpost_shop/vendor/skunkworks = list(TRUE, TRUE), + /datum/outpost_shop/black_market = list(TRUE, FALSE), + /datum/outpost_shop/outfitter = list(FALSE, FALSE), + /datum/outpost_shop/vendor/dregs_bar = list(FALSE, FALSE), + ) + for(var/shop_type in expected) + var/datum/outpost_shop/shop = allocate(shop_type) + var/list/wants = expected[shop_type] + var/datum/shop_buyback/xenoarch_find/find_row = locate() in shop.buybacks + var/datum/shop_buyback/xenoarch_curio/curio_row = locate() in shop.buybacks + TEST_ASSERT_EQUAL(!!find_row, wants[1], "[shop_type] large-find row") + TEST_ASSERT_EQUAL(!!curio_row, wants[2], "[shop_type] curio row") + if(find_row) + TEST_ASSERT(find_row.buyer == shop.xenoarch_buyer, "[shop_type]'s find row has no buyer") + TEST_ASSERT_EQUAL(find_row.demand, 3, "[shop_type] wants the wrong number of large finds") + TEST_ASSERT_NULL(find_row.item_path, "[shop_type]'s find row has an item path, so Pike's ledger would sell finds remotely") + TEST_ASSERT(find_row.get_ui_icon(), "[shop_type]'s find row has no picture") + if(curio_row) + TEST_ASSERT_EQUAL(curio_row.demand, 6, "[shop_type] wants the wrong number of curios") + TEST_ASSERT(curio_row.get_ui_icon(), "[shop_type]'s curio row has no picture") + // A convoy tops the rows up like any other ledger row + if(find_row) + find_row.demand = 0 + shop.convoy_restock() + TEST_ASSERT(find_row.demand > 0, "[shop_type]'s find demand never comes back") + TEST_ASSERT_EQUAL(xenoarch_credits_text(1125), "1,125 cr", "credit formatting") + TEST_ASSERT_EQUAL(xenoarch_credits_text(250), "250 cr", "credit formatting") + TEST_ASSERT_EQUAL(xenoarch_credits_text(1000000), "1,000,000 cr", "credit formatting") + +/// A sale pays the sale value once, takes the find, prints a receipt and tells the artifact. +/datum/unit_test/voidcrew_xenoarch_trade/sale + /// Arguments of the last COMSIG_ARTIFACT_SOLD seen + var/list/sold_args + +/datum/unit_test/voidcrew_xenoarch_trade/sale/proc/on_sold(datum/source, credits, vouchers, mob/seller) + SIGNAL_HANDLER + sold_args = list(credits, vouchers, seller) + +/datum/unit_test/voidcrew_xenoarch_trade/sale/Run() + var/datum/outpost_shop/general/shop = allocate(/datum/outpost_shop/general) + var/datum/shop_buyback/xenoarch_find/row = locate() in shop.buybacks + var/datum/bank_account/account = allocate(/datum/bank_account, "Xenoarch sale test", null, 1, FALSE) + var/mob/living/carbon/human/seller = make_seller(account) + + // Never dug: refused, nothing moves + var/obj/machinery/artifact/stray = allocate(/obj/machinery/artifact, get_step(seller, EAST)) + stray.replace_effects(/datum/artifact_effect/heal, null, 2) + TEST_ASSERT_EQUAL(row.settle(stray, seller, account, null), 0, "an undug find sold") + TEST_ASSERT(!QDELETED(stray), "an undug find was taken without pay") + TEST_ASSERT_EQUAL(account.account_balance, 0, "an undug find paid out") + TEST_ASSERT_EQUAL(row.demand, 3, "a refused sale used up demand") + qdel(stray) + + // Dug in yellow, labelled right: full price + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, get_step(seller, EAST), 2) + find.replace_effects(/datum/artifact_effect/heal, null, 2) + find.apply_label(new /datum/artifact_label("Heals", artifact_trigger_name(find.main_effect.trigger), "None")) + var/expected = artifact_sale_value(find) + TEST_ASSERT_EQUAL(expected, 675, "a perfectly labelled yellow uncommon find is not worth 675") + TEST_ASSERT(shop.xenoarch_buyer.closing_line(find) in shop.xenoarch_buyer.lines["perfect"], "Barnaby didn't praise a perfect label") + var/serial = find.serial + RegisterSignal(find, "artifact_sold", PROC_REF(on_sold)) + TEST_ASSERT_EQUAL(row.settle(find, seller, account, null), expected, "the sale paid the wrong amount") + TEST_ASSERT_EQUAL(account.account_balance, expected, "the account got the wrong amount") + TEST_ASSERT(QDELETED(find), "the sold find stayed in the world") + TEST_ASSERT_EQUAL(row.demand, 2, "a sale did not use up demand") + TEST_ASSERT_NOTNULL(sold_args, "COMSIG_ARTIFACT_SOLD was not sent") + TEST_ASSERT_EQUAL(sold_args[1], expected, "COMSIG_ARTIFACT_SOLD carried the wrong credits") + TEST_ASSERT_EQUAL(sold_args[2], 0, "Barnaby paid a voucher") + TEST_ASSERT_EQUAL(sold_args[3], seller, "COMSIG_ARTIFACT_SOLD named the wrong seller") + var/obj/item/paper/xenoarch_receipt/receipt = locate() in near_seller(seller) + TEST_ASSERT_NOTNULL(receipt, "no receipt was handed over") + TEST_ASSERT(findtext(receipt.name, serial), "the receipt doesn't name the find") + TEST_ASSERT(!(locate(/obj/item/stack/trade_voucher) in near_seller(seller)), "Barnaby handed over a voucher") + tidy(seller) + + // Selling it again is impossible: it is gone + TEST_ASSERT_EQUAL(row.settle(find, seller, account, null), 0, "a sold find sold twice") + TEST_ASSERT_EQUAL(account.account_balance, expected, "a second sale of one find paid") + + // Out of demand: refused + var/obj/machinery/artifact/late = allocate(/obj/machinery/artifact, get_step(seller, EAST), 1) + row.demand = 0 + TEST_ASSERT_EQUAL(row.settle(late, seller, account, null), 0, "a buyer with no demand left bought a find") + TEST_ASSERT(!QDELETED(late), "a refused find was taken") + + // No account: refused + row.demand = 3 + TEST_ASSERT_EQUAL(row.settle(late, seller, null, null), 0, "a find sold with nowhere to pay") + TEST_ASSERT(!QDELETED(late), "a find was taken with nowhere to pay") + +/// Vex pays one voucher on top for an extreme main effect, and only for that. +/datum/unit_test/voidcrew_xenoarch_trade/vex_voucher/Run() + var/datum/outpost_shop/black_market/shop = allocate(/datum/outpost_shop/black_market) + var/datum/shop_buyback/xenoarch_find/row = locate() in shop.buybacks + var/datum/bank_account/account = allocate(/datum/bank_account, "Xenoarch voucher test", null, 1, FALSE) + var/mob/living/carbon/human/seller = make_seller(account) + + var/obj/machinery/artifact/plain = allocate(/obj/machinery/artifact, get_step(seller, EAST), 3) + plain.replace_effects(/datum/artifact_effect/heal, null, 3) + TEST_ASSERT_EQUAL(shop.xenoarch_buyer.vouchers_for(plain), 0, "Vex pays a voucher for an uncommon find") + TEST_ASSERT(row.settle(plain, seller, account, null) > 0, "Vex refused a dug red find") + TEST_ASSERT(!(locate(/obj/item/stack/trade_voucher) in near_seller(seller)), "Vex handed over a voucher for an uncommon find") + tidy(seller) + + var/obj/machinery/artifact/live = allocate(/obj/machinery/artifact, get_step(seller, EAST), 3) + live.replace_effects(/datum/artifact_effect/tesla, null, 3) + TEST_ASSERT_EQUAL(live.main_effect.tier, 4, "the tesla effect is no longer extreme; pick another extreme effect for this test") + TEST_ASSERT(shop.xenoarch_buyer.closing_line(live) in shop.xenoarch_buyer.lines["live"], "Vex didn't remark on an extreme find") + var/before = account.account_balance + var/expected = artifact_sale_value(live) + TEST_ASSERT_EQUAL(expected, 560, "an unlabelled red extreme find is not worth 560") + TEST_ASSERT_EQUAL(row.settle(live, seller, account, null), expected, "Vex paid the wrong credits for an extreme find") + TEST_ASSERT_EQUAL(account.account_balance - before, expected, "the account got the wrong amount") + var/vouchers = 0 + for(var/obj/item/stack/trade_voucher/chits in near_seller(seller)) + vouchers += chits.amount + TEST_ASSERT_EQUAL(vouchers, 1, "Vex didn't pay exactly one voucher for an extreme find") + tidy(seller) + + // The others pay credits only, extreme or not + var/datum/outpost_shop/general/halcyon = allocate(/datum/outpost_shop/general) + var/obj/machinery/artifact/other = allocate(/obj/machinery/artifact, get_step(seller, EAST), 3) + other.replace_effects(/datum/artifact_effect/tesla, null, 3) + TEST_ASSERT_EQUAL(halcyon.xenoarch_buyer.vouchers_for(other), 0, "Barnaby pays vouchers") + +/// Only a find the seller hauls counts: pulled loose, or in a shut crate they pull. +/datum/unit_test/voidcrew_xenoarch_trade/hauling/Run() + var/datum/outpost_shop/vendor/skunkworks/shop = allocate(/datum/outpost_shop/vendor/skunkworks) + var/datum/shop_buyback/xenoarch_find/row = locate() in shop.buybacks + var/datum/bank_account/account = allocate(/datum/bank_account, "Xenoarch hauling test", null, 1, FALSE) + var/mob/living/carbon/human/seller = make_seller(account) + var/turf/beside = get_step(seller, EAST) + + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, beside, 2) + TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 0, "a find nobody pulls counted as hauled") + TEST_ASSERT_EQUAL(row.count_carried_units(seller), 0, "a find nobody pulls showed on the ledger") + seller.start_pulling(find) + TEST_ASSERT(seller.pulling == find, "the seller could not pull the find") + TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 1, "a pulled find did not count") + seller.stop_pulling() + + var/obj/structure/closet/crate/specimen/crate = allocate(/obj/structure/closet/crate/specimen, beside) + if(crate.opened) + crate.close() + find.forceMove(crate) + TEST_ASSERT(find.loc == crate, "the find would not go in the crate") + TEST_ASSERT(findtext(jointext(crate.examine(seller), " "), find.serial), "the crate lid doesn't show the find's tag") + seller.start_pulling(crate) + TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 1, "a find in a pulled crate did not count") + TEST_ASSERT_EQUAL(xenoarch_find_holder(find), crate, "a crated find's holder is not its crate") + crate.open() + TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 0, "an open crate still counted as holding the find") + seller.stop_pulling() + + // A plain crate works too + var/obj/structure/closet/crate/plain = allocate(/obj/structure/closet/crate, beside) + if(plain.opened) + plain.close() + find.forceMove(plain) + seller.start_pulling(plain) + TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 1, "a find in a pulled plain crate did not count") + seller.stop_pulling() + + // An undug find shows as hauled but never sellable + var/obj/machinery/artifact/stray = allocate(/obj/machinery/artifact, get_step(seller, NORTH)) + seller.start_pulling(stray) + TEST_ASSERT_EQUAL(length(xenoarch_hauled_finds(seller)), 1, "an undug pulled find wasn't hauled") + TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 0, "an undug find was sellable") + TEST_ASSERT_NOTNULL(row.get_denial_reason(seller), "an undug find could be sold") + seller.stop_pulling() + + // The find row never matches an inventory item (plunder appraisal, Pike's ledger) + var/obj/item/relic/relic = allocate(/obj/item/relic) + TEST_ASSERT(!row.matches(relic), "the large-find row matched an item") + +/// Curios sell only with the dug mark, at their own price; only Boffin takes strange objects. +/datum/unit_test/voidcrew_xenoarch_trade/curios/Run() + var/datum/outpost_shop/general/halcyon = allocate(/datum/outpost_shop/general) + var/datum/outpost_shop/vendor/skunkworks/skunkworks = allocate(/datum/outpost_shop/vendor/skunkworks) + var/datum/shop_buyback/xenoarch_curio/barnaby_row = locate() in halcyon.buybacks + var/datum/shop_buyback/xenoarch_curio/boffin_row = locate() in skunkworks.buybacks + + var/obj/item/relic/maint_relic = allocate(/obj/item/relic) + TEST_ASSERT(!boffin_row.matches(maint_relic), "an unmarked strange object passed as a find") + var/obj/item/relic/dug_relic = allocate(/obj/item/relic) + TEST_ASSERT(xenoarch_mark_curio(dug_relic, 200), "could not mark a strange object as dug") + TEST_ASSERT(boffin_row.matches(dug_relic), "Boffin refused a dug strange object") + TEST_ASSERT(!barnaby_row.matches(dug_relic), "Barnaby took a strange object") + TEST_ASSERT(!xenoarch_mark_curio(maint_relic, 0), "a curio was marked as worth nothing") + + var/datum/bank_account/account = allocate(/datum/bank_account, "Xenoarch curio test", null, 1, FALSE) + var/mob/living/carbon/human/seller = make_seller(account) + var/obj/item/statuebust/shard = allocate(/obj/item/statuebust) + xenoarch_mark_curio(shard, 50) + seller.put_in_hands(shard) + TEST_ASSERT_EQUAL(barnaby_row.count_carried_units(seller), 1, "a carried curio didn't count") + TEST_ASSERT_NULL(barnaby_row.get_denial_reason(seller), "a carried curio was refused") + TEST_ASSERT(barnaby_row.try_sell(seller, null), "a curio didn't sell") + TEST_ASSERT_EQUAL(account.account_balance, 50, "a curio paid the wrong amount") + TEST_ASSERT(QDELETED(shard), "a sold curio stayed in hand") + TEST_ASSERT_EQUAL(barnaby_row.demand, 5, "a curio sale did not use up demand") + TEST_ASSERT(!barnaby_row.try_sell(seller, null), "a curio sold that wasn't there") + +/// The checkpoint turns back a loose find, lets a crated one through, and never stops one leaving. +/datum/unit_test/voidcrew_xenoarch_trade/checkpoint/Run() + var/datum/outpost_checkpoint/checkpoint = allocate(/datum/outpost_checkpoint) + var/turf/chamber = run_loc_floor_bottom_left + checkpoint.chamber_turfs = list(chamber) + TEST_ASSERT_EQUAL(length(checkpoint.inspect_chamber()), 0, "an empty chamber refused something") + + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, chamber, 1) + var/list/refused = checkpoint.inspect_chamber() + TEST_ASSERT_EQUAL(refused[find], "xenoarch_loose_find", "a loose find walked through the checkpoint") + TEST_ASSERT(checkpoint.refusing_only_loose_finds(), "a loose find read as something else") + + checkpoint.exit_request = TRUE + var/list/leaving = checkpoint.inspect_chamber() + TEST_ASSERT(!leaving[find], "a find was stopped on its way out") + checkpoint.exit_request = FALSE + + var/obj/structure/closet/crate/specimen/crate = allocate(/obj/structure/closet/crate/specimen, chamber) + if(crate.opened) + crate.close() + find.forceMove(crate) + TEST_ASSERT_EQUAL(length(checkpoint.inspect_chamber()), 0, "a crated find was turned back") + + var/datum/outpost_shop/vendor/checkpoint/halcyon/desk = allocate(/datum/outpost_shop/vendor/checkpoint/halcyon) + TEST_ASSERT(desk.get_line("xenoarch_loose_find") in desk.xenoarch_loose_lines, "Hollis has nothing to say about a loose find") + TEST_ASSERT(desk.get_line("checkpoint_pass"), "Hollis lost his ordinary lines") + var/obj/machinery/outpost_checkpoint_sensor/sensor = allocate(/obj/machinery/outpost_checkpoint_sensor) + TEST_ASSERT(sensor.machine_line("xenoarch_loose_find"), "the wall scanner has nothing to say about a loose find") diff --git a/voidcrew/modules/xenoarch/trade/buyers.dm b/voidcrew/modules/xenoarch/trade/buyers.dm index 8577a192a25..5fb61281f84 100644 --- a/voidcrew/modules/xenoarch/trade/buyers.dm +++ b/voidcrew/modules/xenoarch/trade/buyers.dm @@ -1 +1,940 @@ -// Outpost traders buying large finds and curios. +/** + * # Outpost buyers for dig finds + * + * Barnaby at Halcyon, Boffin at the Quartermain's Skunkworks and Vex at the Undertow buy what + * comes out of the old dig sites. They are the outposts' own traders: this file gives each one a + * buyer's voice (/datum/xenoarch_buyer) and two rows on their wanted ledger, so a find sells from + * the same Sell tab as everything else they buy. + * + * - Large finds are machinery, so the stock ledger (which only takes inventory items) can't hold + * them. /datum/shop_buyback/xenoarch_find takes the find the seller is pulling, loose or in a + * shut crate, once it is at the counter. The trader looks it over while the seller waits, then + * pays artifact_sale_value() into the account every other sale pays (get_shop_buyback_account()), + * prints a receipt and says what they made of it. The sale is final: nothing is quoted, so a + * label can't be tried out on the buyer. + * - Only finds stamped as dug sell. Vex pays a voucher on top for an extreme main effect. + * - Curios (/datum/shop_buyback/xenoarch_curio) are ordinary items marked as dug finds with + * xenoarch_mark_curio(); Barnaby and Boffin take them, and only Boffin takes strange objects. + * - Each buyer takes XENOARCH_FIND_DEMAND large finds and XENOARCH_CURIO_DEMAND curios at a time, + * topped up by the convoy like any other ledger row (shop.dm convoy_restock()). + * - A buyer notices a find being hauled up to the counter and says so, once per find. + * - Selling earns no reputation; that comes from jobs (trader_favor.dm). + */ + +/// Large finds each buyer takes before the next convoy +#define XENOARCH_FIND_DEMAND 3 +/// Curios each buyer takes before the next convoy +#define XENOARCH_CURIO_DEMAND 6 +/// How far from the trader a hauled find may be and still count as at the counter +#define XENOARCH_COUNTER_REACH 3 +/// How long the trader looks a find over before paying +#define XENOARCH_INSPECT_TIME (2.5 SECONDS) +/// Least time between the starts of two inspections, so the Sell button can't be used to make the trader spam +#define XENOARCH_INSPECT_COOLDOWN (4 SECONDS) +/// How far away a trader notices a find being hauled in +#define XENOARCH_NOTICE_RANGE 4 +/// Least time between two "what have you got there" remarks +#define XENOARCH_NOTICE_COOLDOWN (30 SECONDS) +/// How many finds a trader remembers having remarked on +#define XENOARCH_NOTICE_MEMORY 20 + +// Line keys for /datum/xenoarch_buyer/var/lines +/// Spoken when a find is hauled up +#define XENOARCH_LINE_NOTICE "notice" +/// Emotes while looking over a loose find; %THING% is the find +#define XENOARCH_LINE_INSPECT "inspect" +/// Emotes while looking into a crate; %THING% is the crate +#define XENOARCH_LINE_INSPECT_CRATED "inspect_crated" +/// Spoken while looking over a find that came out of the rock cracked +#define XENOARCH_LINE_CRACKED "cracked" +/// Spoken while looking over a find that has been drained +#define XENOARCH_LINE_TAPPED "tapped" +/// Spoken on paying for a find labelled right on every line +#define XENOARCH_LINE_PERFECT "perfect" +/// Spoken on paying for a find labelled mostly right +#define XENOARCH_LINE_CLOSE "close" +/// Spoken on paying for a find labelled mostly wrong +#define XENOARCH_LINE_WRONG "wrong" +/// Spoken on paying for a find with no label +#define XENOARCH_LINE_BLANK "blank" +/// Spoken on paying for a find with an extreme main effect +#define XENOARCH_LINE_LIVE "live" +/// Spoken when the seller walks off mid-inspection +#define XENOARCH_LINE_WALKED "walked" + +// ========================================================================= +// HAULING +// ========================================================================= + +/// The large find in `thing`: the artifact itself, or the first artifact in a shut crate. Null otherwise. +/proc/xenoarch_find_in(atom/movable/thing) + if(istype(thing, /obj/machinery/artifact)) + return thing + if(istype(thing, /obj/structure/closet)) + var/obj/structure/closet/box = thing + if(!box.opened) + return locate(/obj/machinery/artifact) in box.contents + return null + +/// Every large find `user` is hauling: the artifact they pull, or those in the shut crate they pull. +/proc/xenoarch_hauled_finds(mob/living/user) + . = list() + var/atom/movable/hauled = user?.pulling + if(istype(hauled, /obj/machinery/artifact)) + . += hauled + else if(istype(hauled, /obj/structure/closet)) + var/obj/structure/closet/box = hauled + if(!box.opened) + for(var/obj/machinery/artifact/find in box.contents) + . += find + +/// What a hauled find rides in: its crate, or the find itself when loose. +/proc/xenoarch_find_holder(obj/machinery/artifact/find) + if(!find) + return null + return isturf(find.loc) ? find : find.loc + +/// "1,125 cr" +/proc/xenoarch_credits_text(amount) + var/digits = num2text(round(abs(amount)), 12) + var/grouped = "" + while(length(digits) > 3) + grouped = ",[copytext(digits, -3)][grouped]" + digits = copytext(digits, 1, -3) + return "[amount < 0 ? "-" : ""][digits][grouped] cr" + +// ========================================================================= +// THE BUYERS +// ========================================================================= + +/** + * # Dig-find buyer + * + * The buying side of one outpost trader: their voice for finds, what they take, and how their + * receipt reads. One per shop, made by the shop (see /datum/outpost_shop/New below). + */ +/datum/xenoarch_buyer + /// The shop this buyer works for + var/datum/outpost_shop/shop + /// Vouchers paid on top of credits for a find whose main effect is extreme + var/extreme_vouchers = 0 + /// Buys curios at all + var/buys_curios = TRUE + /// Takes tg's strange objects among the curios + var/takes_strange_objects = FALSE + /// Large-find ledger row: name, description and picture + var/find_name = "dig pieces" + var/find_desc = "Large finds out of the old dig sites." + var/find_icon = 'voidcrew/modules/xenoarch/icons/artifacts.dmi' + var/find_icon_state = "idol" + /// Curio ledger row: name, description and picture + var/curio_name = "curios" + var/curio_desc = "Small finds out of the old dig sites." + var/curio_icon = 'icons/obj/mining.dmi' + var/curio_icon_state = "skull" + /// Receipt heading, subheading, sign-off, hand, ink and paper stamp + var/receipt_title = "RECEIPT" + var/receipt_subtitle + var/receipt_signoff + var/receipt_font = PEN_FONT + var/receipt_ink = "#202020" + var/receipt_stamp + /// Heard while the buyer looks a find over + var/inspect_sound = 'sound/items/handling/paper_pickup.ogg' + /// Speech and emotes, by XENOARCH_LINE_* key + var/list/lines = list() + /// Extra idle chatter added to the shop's own + var/list/idle_lines = list() + /// Serials already remarked on, oldest first + var/list/noticed = list() + /// The find being looked over right now + var/datum/weakref/inspecting + /// When the buyer may next remark on a hauled find + COOLDOWN_DECLARE(notice_cooldown) + /// When the buyer may next start looking a find over + COOLDOWN_DECLARE(inspect_cooldown) + +/datum/xenoarch_buyer/New(datum/outpost_shop/shop) + . = ..() + src.shop = shop + +/datum/xenoarch_buyer/Destroy() + shop = null + inspecting = null + return ..() + +/// Ledger rows this buyer adds to their shop. +/datum/xenoarch_buyer/proc/ledger_types() + . = list(/datum/shop_buyback/xenoarch_find) + if(buys_curios) + . += /datum/shop_buyback/xenoarch_curio + +/// The trader at the counter, if the interior has one. +/datum/xenoarch_buyer/proc/trader() + return shop?.trader_npc + +/// Looking a find over right now (a live find, not one sold or lost meanwhile), or only just done with one. +/datum/xenoarch_buyer/proc/is_busy() + var/obj/machinery/artifact/find = inspecting?.resolve() + if(QDELETED(find)) + inspecting = null + return !COOLDOWN_FINISHED(src, inspect_cooldown) + return TRUE + +/// Vouchers this buyer adds for `find`. +/datum/xenoarch_buyer/proc/vouchers_for(obj/machinery/artifact/find) + if(extreme_vouchers > 0 && find?.main_effect?.tier == ARTIFACT_TIER_EXTREME) + return extreme_vouchers + return 0 + +/// A random line for `key`, or null. +/datum/xenoarch_buyer/proc/line(key) + var/list/options = lines[key] + return length(options) ? pick(options) : null + +/// The trader says `text`, and holds their other chatter back for a moment. +/datum/xenoarch_buyer/proc/speak(mob/living/basic/outpost_trader/vendor, text) + if(QDELETED(vendor) || !text) + return + COOLDOWN_START(vendor, speak_cooldown, 3 SECONDS) + vendor.say(text) + +/// The trader starts looking over `find`, riding in `holder` (the find itself, or its crate). +/datum/xenoarch_buyer/proc/begin_inspection(mob/living/basic/outpost_trader/vendor, obj/machinery/artifact/find, atom/movable/holder) + if(QDELETED(vendor)) + return + var/emote = line(holder == find ? XENOARCH_LINE_INSPECT : XENOARCH_LINE_INSPECT_CRATED) + if(emote) + vendor.manual_emote(replacetext(emote, "%THING%", "\the [holder]")) + if(holder != find) + playsound(holder, 'sound/machines/crate/crate_open.ogg', 20, TRUE) + if(inspect_sound) + playsound(vendor, inspect_sound, 30, TRUE) + // Cracks and a drained feel are plain to anyone looking; said while looking, not as a verdict. + if(find.damaged) + speak(vendor, line(XENOARCH_LINE_CRACKED)) + else if(find.harvests_taken() > 0) + speak(vendor, line(XENOARCH_LINE_TAPPED)) + +/// What the trader says as they pay for `find`. Only said once the find is theirs. +/datum/xenoarch_buyer/proc/closing_line(obj/machinery/artifact/find) + if(find.main_effect?.tier == ARTIFACT_TIER_EXTREME) + return line(XENOARCH_LINE_LIVE) + if(!find.label) + return line(XENOARCH_LINE_BLANK) + var/accuracy = round(find.label_accuracy(), 0.01) + if(accuracy >= 1) + return line(XENOARCH_LINE_PERFECT) + if(accuracy >= ARTIFACT_LABEL_ACCURACY_FLOOR) + return line(XENOARCH_LINE_CLOSE) + return line(XENOARCH_LINE_WRONG) + +/** + * Remarks on a find being hauled up to the counter, once per find and not too often. Runs on the + * trader's life tick. Only finds a player is pulling count: that is someone showing it off. + */ +/datum/xenoarch_buyer/proc/look_around(mob/living/basic/outpost_trader/vendor) + if(!COOLDOWN_FINISHED(src, notice_cooldown) || is_busy() || !isturf(vendor.loc)) + return + for(var/atom/movable/thing in range(XENOARCH_NOTICE_RANGE, vendor)) + var/mob/living/hauler = thing.pulledby + if(!hauler?.client) + continue + var/obj/machinery/artifact/find = xenoarch_find_in(thing) + if(!find?.is_dug() || (find.serial in noticed)) + continue + noticed += find.serial + if(length(noticed) > XENOARCH_NOTICE_MEMORY) + noticed.Cut(1, 2) + COOLDOWN_START(src, notice_cooldown, XENOARCH_NOTICE_COOLDOWN) + speak(vendor, line(XENOARCH_LINE_NOTICE)) + return + +/// The receipt's body: the facts of one sale, in this buyer's hand. +/datum/xenoarch_buyer/proc/receipt_text(list/sale) + var/list/parts = list() + parts += "
[receipt_title][receipt_subtitle ? "
[receipt_subtitle]" : ""]

" + parts += "Bought of [sale["seller"]], [sale["time"]].
" + parts += "One [sale["find"]], field tag [sale["serial"]].
" + parts += sale["label"] ? "The tag on it says: [sale["label"]].
" : "No label on it.
" + if(sale["condition"]) + parts += "Condition: [sale["condition"]].
" + parts += "
Paid [sale["price"]] to [sale["account"]].
" + if(sale["vouchers"]) + parts += "Also handed over: [sale["vouchers"]] trade voucher[sale["vouchers"] > 1 ? "s" : ""].
" + if(receipt_signoff) + parts += "
[receipt_signoff]" + return parts.Join() + +/// The facts of a sale, taken before the find is gone. +/datum/xenoarch_buyer/proc/sale_record(obj/machinery/artifact/find, mob/living/seller, price, vouchers, datum/bank_account/account) + var/list/condition = list() + if(find.damaged) + condition += "cracked" + if(find.harvests_taken() > 0) + condition += "drained" + var/label_text + if(find.label) + label_text = "[find.label.effect_name || "?"] / [find.label.trigger_name || "?"] / [find.label.second_name || ARTIFACT_LABEL_NONE]" + return list( + "seller" = seller?.real_name || "a stranger", + "time" = station_time_timestamp("hh:mm"), + "find" = find.name, + "serial" = find.serial, + "label" = label_text, + "label_off" = !!find.label && round(find.label_accuracy(), 0.01) < 1, + "condition" = length(condition) ? condition.Join(", ") : null, + "price" = xenoarch_credits_text(price), + "vouchers" = vouchers, + "account" = account?.account_holder || "the seller", + ) + +// ----- Barnaby: Waystation Halcyon, green ----- + +/datum/xenoarch_buyer/barnaby + find_name = "dig pieces" + find_desc = "Anything big and old out of the cliffs. Barnaby pays fair and keeps the nice ones in the back." + find_icon_state = "idol" + curio_name = "curios" + curio_desc = "Fossils, pots, little figures. The gift shop does well out of them." + curio_icon = 'icons/obj/mining.dmi' + curio_icon_state = "skull" + receipt_title = "WAYSTATION HALCYON" + receipt_subtitle = "General Store" + receipt_signoff = "With thanks, B." + receipt_font = FOUNTAIN_PEN_FONT + receipt_ink = "#24305e" + receipt_stamp = "stamp-qm" + inspect_sound = 'sound/items/handling/book_pickup.ogg' + lines = list( + XENOARCH_LINE_NOTICE = list( + "Goodness, what have you dragged in? Bring it round, dear.", + "Oh, that's come out of the ground, hasn't it. Let's have a look.", + "Mind the floor with that. It's only just been mopped.", + ), + XENOARCH_LINE_INSPECT = list( + "pushes his reading glasses up his nose and leans over %THING%.", + "peers at %THING% over his glasses, humming.", + ), + XENOARCH_LINE_INSPECT_CRATED = list( + "lifts the lid of %THING% and peers inside, glasses on the end of his nose.", + "props open %THING% with one hand and has a good long look.", + ), + XENOARCH_LINE_CRACKED = list( + "Oh dear. Cracked right down the side.", + "Somebody's been rough with this one.", + ), + XENOARCH_LINE_TAPPED = list( + "Feels tired, this one. Somebody's had a go at it.", + "Hm. Not as lively as it ought to be.", + ), + XENOARCH_LINE_PERFECT = list( + "Labelled proper and everything. You've done your homework, dear.", + "Exactly what it says on the tag. Lovely.", + ), + XENOARCH_LINE_CLOSE = list( + "Near enough what it says on the tag. I'll not quibble.", + "Mostly right, that label. Mostly.", + ), + XENOARCH_LINE_WRONG = list( + "I'm not sure this label's been anywhere near this thing, dear.", + "Whoever wrote this tag was guessing. I'll pay for the rock.", + ), + XENOARCH_LINE_BLANK = list( + "No label? Then I'm buying a mystery, and mysteries come cheap.", + "Not a word on it about what it does. I'll take it on faith, at a faith price.", + ), + XENOARCH_LINE_LIVE = list( + "Goodness. This one's very much awake in there. I'll be glad when the courier takes it.", + "Oh my. I'll be keeping this one well away from the biscuit tin.", + ), + XENOARCH_LINE_WALKED = list( + "Oh! Never mind, then.", + "Changed your mind? That's all right, dear.", + ), + ) + idle_lines = list( + "Folk keep digging funny old things out of the cliffs. I'll buy them, if they don't hum too loud.", + "Had a lad bring in a stone that whistled. Paid him for it. It's in the back. It still whistles.", + ) + +// ----- Boffin: the Skunkworks, Quartermain Depot, yellow ----- + +/datum/xenoarch_buyer/boffin + takes_strange_objects = TRUE + find_name = "alien artifacts" + find_desc = "Large finds out of the old dig sites. Boffin likes them catalogued and in one piece." + find_icon_state = "terminal" + curio_name = "curios and oddities" + curio_desc = "Small dig finds, and the strange objects nobody can explain. Especially those." + curio_icon = 'icons/obj/devices/artefacts.dmi' + curio_icon_state = "prototype3" + receipt_title = "THE SKUNKWORKS" + receipt_subtitle = "Specimen intake" + receipt_signoff = "Received for bench testing. Boffin, QA." + receipt_font = PEN_FONT + receipt_ink = "#1d3b6e" + receipt_stamp = "stamp-rd" + inspect_sound = 'sound/machines/scanner/scanner.ogg' + lines = list( + XENOARCH_LINE_NOTICE = list( + "Oh. Oh, that's not from around here. Bring it closer. No, not THAT close.", + "Is that a dig piece? Wheel it over, wheel it over.", + "I can hear that from here. Lovely.", + ), + XENOARCH_LINE_INSPECT = list( + "waves his multitool over %THING%, squinting at the readout.", + "taps %THING% with his multitool and listens.", + ), + XENOARCH_LINE_INSPECT_CRATED = list( + "pries up the lid of %THING% and pokes his multitool inside.", + "leans into %THING% with his multitool held out like a divining rod.", + ), + XENOARCH_LINE_CRACKED = list( + "Cracked straight through. Somebody dug this out with a shovel and a prayer.", + "Fracture lines. I can see them. I can always see them.", + ), + XENOARCH_LINE_TAPPED = list( + "Someone's been siphoning this. It's running on fumes.", + "Drained. Partly. The hum's a semitone flat.", + ), + XENOARCH_LINE_PERFECT = list( + "Label checks out on every line. Somebody on your crew owns a notebook. Keep them.", + "Correct on every count. I'm almost disappointed. Nothing left for me to discover.", + ), + XENOARCH_LINE_CLOSE = list( + "Label's close. I'll fix the rest in pencil.", + "Mostly right. The rest is what we in the trade call a learning opportunity.", + ), + XENOARCH_LINE_WRONG = list( + "Your label and my multitool disagree. My multitool is right.", + "This label is fiction. Good fiction. Still fiction.", + ), + XENOARCH_LINE_BLANK = list( + "No label. I love a blind test, but I don't pay for one.", + "Unlabelled. So I'm paying for a rock and a hunch.", + ), + XENOARCH_LINE_LIVE = list( + "Oh, this is a LIVE one. Nobody touch anything. Especially me.", + "That's the real thing. I'm going to need a bigger bench.", + ), + XENOARCH_LINE_WALKED = list( + "Hey! I was measuring that!", + "Fine. Take your rock. I'll measure the dent it left.", + ), + ) + idle_lines = list( + "Old dig pieces! Alien, ancient, occasionally ticking. My three favourite words.", + "Bring me something out of the deep rock. I promise to only lick it once.", + ) + +// A lab intake sheet, with a pencil note when the label needed fixing. +/datum/xenoarch_buyer/boffin/receipt_text(list/sale) + var/list/parts = list() + parts += "
[receipt_title]
[receipt_subtitle]

" + parts += "Specimen: [sale["serial"]] ([sale["find"]])
" + parts += "From: [sale["seller"]], [sale["time"]]
" + parts += "Label as received: [sale["label"] ? "[sale["label"]][sale["label_off"] ? " (corrected in pencil)" : ""]" : "none"]
" + parts += "Condition: [sale["condition"] || "intact"]
" + parts += "Paid: [sale["price"]] to [sale["account"]]
" + parts += "
[receipt_signoff]" + return parts.Join() + +// ----- Vex: Undertow Exchange, red ----- + +/datum/xenoarch_buyer/vex + extreme_vouchers = 1 + buys_curios = FALSE + find_name = "dig pieces" + find_desc = "Old alien finds. Vex has collectors in the deep ring who pay for the strange ones." + find_icon_state = "spindle" + receipt_title = "UNDERTOW EXCHANGE" + receipt_signoff = "No returns. This never happened." + receipt_font = PRINTER_FONT + receipt_ink = "#111111" + receipt_stamp = "stamp-void" + inspect_sound = null + lines = list( + XENOARCH_LINE_NOTICE = list( + "That's a dig piece. Bring it here. Slowly.", + "Now that's a conversation piece. Over here.", + "Collectors in the deep ring would kill for one of those. Some have.", + ), + XENOARCH_LINE_INSPECT = list( + "slides their sunglasses down and looks %THING% over without a word.", + "walks once around %THING%, slowly.", + ), + XENOARCH_LINE_INSPECT_CRATED = list( + "lifts the lid of %THING% with one gloved finger and looks in.", + "cracks %THING% open an inch and studies the gap.", + ), + XENOARCH_LINE_CRACKED = list( + "Cracked. Collectors hate cracks.", + "Somebody dropped this. More than once.", + ), + XENOARCH_LINE_TAPPED = list( + "Tapped. Somebody's had their fun with this.", + "Half of what it was. I can tell.", + ), + XENOARCH_LINE_PERFECT = list( + "Clean paperwork. My buyer likes clean paperwork.", + "Label's right. That's rarer than the rock.", + ), + XENOARCH_LINE_CLOSE = list( + "Label's close enough for my buyer. Not for me.", + "Mostly right. Mostly gets mostly paid.", + ), + XENOARCH_LINE_WRONG = list( + "Somebody guessed. I don't pay for guesses.", + "Wrong label. My buyer's going to ask questions. I'll answer them, for a fee.", + ), + XENOARCH_LINE_BLANK = list( + "No label. You're selling me a rock with a story you don't know.", + "Blank. Fine. I'll make up a story. Mine sell better.", + ), + XENOARCH_LINE_LIVE = list( + "Now this is worth something to people who don't deal in credits. Take the chit.", + "My buyer's going to want this very badly. Here. A little extra.", + ), + XENOARCH_LINE_WALKED = list( + "Walking away. Smart, or stupid. We'll see.", + "Suit yourself.", + ), + ) + idle_lines = list( + "The deep-ring collectors pay silly money for old dig pieces. The scarier the better.", + "Somebody brought me a crystal that hummed in a key I didn't like. Sold it in an hour.", + ) + +// Vex writes as little as possible down. +/datum/xenoarch_buyer/vex/receipt_text(list/sale) + var/list/parts = list() + parts += "
[receipt_title]

" + parts += "[sale["serial"]]. [sale["price"]] to [sale["account"]].[sale["vouchers"] ? " One chit." : ""]
" + parts += "
[receipt_signoff]" + return parts.Join() + +// ========================================================================= +// SHOPS AND TRADERS +// ========================================================================= + +/datum/outpost_shop + /// /datum/xenoarch_buyer type for a shop that buys dig finds, or null + var/xenoarch_buyer_type + /// This shop's dig-find buyer, if it buys them + var/datum/xenoarch_buyer/xenoarch_buyer + +/datum/outpost_shop/general + xenoarch_buyer_type = /datum/xenoarch_buyer/barnaby + +/datum/outpost_shop/vendor/skunkworks + xenoarch_buyer_type = /datum/xenoarch_buyer/boffin + +/datum/outpost_shop/black_market + xenoarch_buyer_type = /datum/xenoarch_buyer/vex + +// The buyer's ledger rows go in before the base New() makes the buybacks, so they share the +// shop's demand and convoy restock like any other row. +/datum/outpost_shop/New(obj/structure/overmap/trader_outpost/outpost) + if(xenoarch_buyer_type) + xenoarch_buyer = new xenoarch_buyer_type(src) + buyback_types = buyback_types + xenoarch_buyer.ledger_types() + if(length(xenoarch_buyer.idle_lines)) + var/list/idle = trader_lines[TRADER_LINE_IDLE] + trader_lines[TRADER_LINE_IDLE] = (islist(idle) ? idle : list()) + xenoarch_buyer.idle_lines + . = ..() + if(!xenoarch_buyer) + return + for(var/datum/shop_buyback/buyback as anything in buybacks) + if(istype(buyback, /datum/shop_buyback/xenoarch_find)) + var/datum/shop_buyback/xenoarch_find/find_row = buyback + find_row.attach(xenoarch_buyer) + else if(istype(buyback, /datum/shop_buyback/xenoarch_curio)) + var/datum/shop_buyback/xenoarch_curio/curio_row = buyback + curio_row.attach(xenoarch_buyer) + +/datum/outpost_shop/Destroy() + QDEL_NULL(xenoarch_buyer) + return ..() + +/mob/living/basic/outpost_trader/Life(seconds_per_tick, times_fired) + . = ..() + shop?.xenoarch_buyer?.look_around(src) + +// ========================================================================= +// LARGE FINDS +// ========================================================================= + +/** + * # Large-find ledger row + * + * Buys the find the seller is pulling (loose, or in a shut crate), not anything in their pack. + * item_path stays null on purpose: Pike's remote ledger and the contract pricing skip rows without + * one, so a large find can only be sold by hauling it to an outpost. + */ +/datum/shop_buyback/xenoarch_find + name = "dig pieces" + desc = "Large finds out of the old dig sites." + category = "Dig Finds" + item_path = null + demand_min = XENOARCH_FIND_DEMAND + demand_max = XENOARCH_FIND_DEMAND + /// The buyer whose ledger this is + var/datum/xenoarch_buyer/buyer + +/datum/shop_buyback/xenoarch_find/Destroy() + buyer = null + return ..() + +/// Takes on `new_buyer`'s wording for the ledger. +/datum/shop_buyback/xenoarch_find/proc/attach(datum/xenoarch_buyer/new_buyer) + buyer = new_buyer + name = new_buyer.find_name + desc = new_buyer.find_desc + cached_icon = null + +/datum/shop_buyback/xenoarch_find/get_ui_icon() + if(!cached_icon && buyer) + cached_icon = icon2base64(icon(buyer.find_icon, buyer.find_icon_state, SOUTH, frame = 1)) + return cached_icon + +/datum/shop_buyback/xenoarch_find/get_payment_text() + return "by the piece" + +// Never an inventory item, whoever asks (plunder appraisal, Pike's ledger). +/datum/shop_buyback/xenoarch_find/matches(obj/item/offered) + return FALSE + +/datum/shop_buyback/xenoarch_find/find_refused_variant(mob/living/user) + return null + +/// The finds `user` is hauling that a buyer pays for (dug, and worth something), first to sell at the front. +/datum/shop_buyback/xenoarch_find/proc/sellable_finds(mob/living/user) + . = list() + for(var/obj/machinery/artifact/find as anything in xenoarch_hauled_finds(user)) + if(artifact_sale_value(find) > 0) + . += find + +// The trader takes one piece at a time, so a crate of three still shows one. +/datum/shop_buyback/xenoarch_find/count_carried_units(mob/living/user) + if(!istype(user)) + return 0 + return length(sellable_finds(user)) ? 1 : 0 + +/// Whether `find` is close enough to `vendor` to be looked at. +/datum/shop_buyback/xenoarch_find/proc/at_counter(obj/machinery/artifact/find, mob/living/basic/outpost_trader/vendor) + var/atom/movable/holder = xenoarch_find_holder(find) + return !QDELETED(vendor) && holder && get_dist(holder, vendor) <= XENOARCH_COUNTER_REACH + +/datum/shop_buyback/xenoarch_find/get_denial_reason(mob/living/user) + if(demand <= 0) + return "Not buying any more this shift." + if(buyer?.is_busy()) + return "Already looking one over." + var/list/hauled = xenoarch_hauled_finds(user) + if(!length(hauled)) + return "Nothing at the counter." + var/list/finds = sellable_finds(user) + if(!length(finds)) + for(var/obj/machinery/artifact/find as anything in hauled) + if(!find.is_dug()) + return "No field tag on it." + return "Not buying that." + if(!at_counter(finds[1], buyer?.trader())) + return "Bring it closer." + if(!get_shop_buyback_account(user)) + return "No bank account on your ID." + return null + +// The sale finishes once the trader has looked the find over, so this starts it and reports +// nothing sold yet: the window's own sale sound and line would come too early. The buyer makes +// their own noise when the money changes hands. +/datum/shop_buyback/xenoarch_find/try_sell(mob/living/user, mob/living/basic/outpost_trader/vendor, quiet = FALSE) + if(!buyer || QDELETED(vendor)) + return FALSE + INVOKE_ASYNC(src, PROC_REF(sell_hauled_find), user, vendor) + return FALSE + +/datum/shop_buyback/xenoarch_find/try_sell_bulk(mob/living/user, mob/living/basic/outpost_trader/vendor) + try_sell(user, vendor) + return 0 + +/// Whether the sale of `find` can still go ahead: same seller, same counter, still hauled, still wanted. +/datum/shop_buyback/xenoarch_find/proc/sale_still_on(mob/living/seller, mob/living/basic/outpost_trader/vendor, obj/machinery/artifact/find) + if(QDELETED(src) || QDELETED(find) || QDELETED(vendor) || QDELETED(seller) || !buyer) + return FALSE + if(demand <= 0 || !(find in sellable_finds(seller)) || !at_counter(find, vendor)) + return FALSE + if(!vendor.check_menu(seller) || vendor.outpost?.is_user_barred(seller)) + return FALSE + if(vendor.outpost && !vendor.outpost.contains_site_turf(get_turf(vendor))) + return FALSE + return TRUE + +/** + * The seller waits at the counter while the trader looks over the first find they are hauling. + * Walking off, letting go of it or the trader's demand running out calls it off. Sleeps. + */ +/datum/shop_buyback/xenoarch_find/proc/sell_hauled_find(mob/living/seller, mob/living/basic/outpost_trader/vendor) + var/denial = get_denial_reason(seller) + if(denial) + to_chat(seller, span_warning(denial)) + vendor?.play_denial() + return FALSE + var/obj/machinery/artifact/find = sellable_finds(seller)[1] + if(!sale_still_on(seller, vendor, find)) + vendor?.play_denial() + return FALSE + var/atom/movable/holder = xenoarch_find_holder(find) + buyer.inspecting = WEAKREF(find) + COOLDOWN_START(buyer, inspect_cooldown, XENOARCH_INSPECT_COOLDOWN) + buyer.begin_inspection(vendor, find, holder) + var/looked = do_after(seller, XENOARCH_INSPECT_TIME, holder, timed_action_flags = IGNORE_HELD_ITEM, extra_checks = CALLBACK(src, PROC_REF(sale_still_on), seller, vendor, find), hidden = TRUE) + if(buyer) + buyer.inspecting = null + if(!looked || !sale_still_on(seller, vendor, find)) + if(!QDELETED(find) && buyer) + buyer.speak(vendor, buyer.line(XENOARCH_LINE_WALKED)) + return FALSE + if(settle(find, seller, get_shop_buyback_account(seller), vendor) > 0) + return TRUE + vendor.play_denial() + return FALSE + +/** + * Pays for `find` and takes it: credits into `account`, any voucher and the receipt into the + * seller's hands, the find gone. All checks and the payment happen without a sleep between them. + * Returns the credits paid, or 0 if nothing was sold. + */ +/datum/shop_buyback/xenoarch_find/proc/settle(obj/machinery/artifact/find, mob/living/seller, datum/bank_account/account, mob/living/basic/outpost_trader/vendor) + if(QDELETED(find) || !find.is_dug() || demand <= 0 || !account) + return 0 + var/price = artifact_sale_value(find) + if(price <= 0) + return 0 + var/vouchers = buyer ? buyer.vouchers_for(find) : 0 + var/list/sale = buyer?.sale_record(find, seller, price, vouchers, account) + var/reaction = buyer?.closing_line(find) + var/atom/drop_spot = seller?.drop_location() || vendor?.drop_location() || get_turf(find) + var/serial = find.serial + var/trader_name = buyer?.shop?.trader_name || "The buyer" + + demand-- + SEND_SIGNAL(find, COMSIG_ARTIFACT_SOLD, price, vouchers, seller) + metric_artifact_sold(find, seller, price, vouchers, buyer?.shop) + log_game("XENOARCH: [key_name(seller)] sold [serial] ([find.main_effect?.log_name]) to [trader_name] for [price] cr[vouchers ? " and [vouchers] voucher\s" : ""] at [AREACOORD(drop_spot)].") + if(isturf(find.loc)) + new /obj/effect/temp_visual/xenoarch_sold_find(find.loc, find) + qdel(find) + + account.adjust_money(price, "Trader Outpost: [trader_name] bought [serial]") + if(vouchers > 0) + var/obj/item/stack/trade_voucher/chits = new(drop_spot, vouchers) + seller?.put_in_hands(chits) + if(sale) + var/obj/item/paper/xenoarch_receipt/receipt = new(drop_spot) + receipt.fill_in(buyer, sale) + seller?.put_in_hands(receipt) + if(!QDELETED(vendor)) + playsound(vendor, 'sound/effects/cashregister.ogg', 40, TRUE) + if(sale) + playsound(vendor, 'sound/items/handling/standard_stamp.ogg', 35, TRUE) + buyer?.speak(vendor, reaction) + if(seller) + to_chat(seller, span_notice("[trader_name] pays [xenoarch_credits_text(price)] for [serial][vouchers ? " and slides over a trade voucher" : ""].")) + return price + +/// The sold find, fading off the counter as it is carried into the back. +/obj/effect/temp_visual/xenoarch_sold_find + duration = 0.8 SECONDS + randomdir = FALSE + +/obj/effect/temp_visual/xenoarch_sold_find/Initialize(mapload, atom/movable/find) + . = ..() + if(find) + icon = find.icon + icon_state = find.icon_state + pixel_x = find.pixel_x + pixel_y = find.pixel_y + animate(src, alpha = 0, pixel_z = 6, time = duration, easing = SINE_EASING | EASE_IN) + +// ========================================================================= +// CURIOS +// ========================================================================= + +/** + * # Dug curio + * + * Marks a small find as having come out of a dig site, and what a buyer pays for it. Buyers take + * only marked items, so strange objects from maintenance or the experimentor (never marked) can't + * pass as finds. Copies made of a curio don't carry the mark. + */ +/datum/component/xenoarch_curio + /// Credits a buyer pays for it + var/value = 0 + +/datum/component/xenoarch_curio/Initialize(value) + if(!isitem(parent)) + return COMPONENT_INCOMPATIBLE + src.value = max(round(value), 0) + +/// Marks `find` as a dug curio worth `value` credits to a buyer. Call when a small find comes out of the rock. +/proc/xenoarch_mark_curio(obj/item/find, value) + if(!istype(find) || value <= 0) + return FALSE + find.AddComponent(/datum/component/xenoarch_curio, value) + return TRUE + +/// What a buyer pays for `thing` as a curio: 0 if it never came out of a dig site. +/proc/xenoarch_curio_value(obj/item/thing) + var/datum/component/xenoarch_curio/mark = thing?.GetComponent(/datum/component/xenoarch_curio) + return mark ? mark.value : 0 + +/** + * # Curio ledger row + * + * An ordinary inventory buyback, except that every curio carries its own price. item_path stays + * null so contract pricing and Pike's remote ledger leave it alone. + */ +/datum/shop_buyback/xenoarch_curio + name = "curios" + desc = "Small finds out of the old dig sites." + category = "Dig Finds" + item_path = null + demand_min = XENOARCH_CURIO_DEMAND + demand_max = XENOARCH_CURIO_DEMAND + /// The buyer whose ledger this is + var/datum/xenoarch_buyer/buyer + /// Credits the last single sale paid (bulk sales add these up) + var/last_sale_value = 0 + +/datum/shop_buyback/xenoarch_curio/Destroy() + buyer = null + return ..() + +/// Takes on `new_buyer`'s wording for the ledger. +/datum/shop_buyback/xenoarch_curio/proc/attach(datum/xenoarch_buyer/new_buyer) + buyer = new_buyer + name = new_buyer.curio_name + desc = new_buyer.curio_desc + cached_icon = null + +/datum/shop_buyback/xenoarch_curio/get_ui_icon() + if(!cached_icon && buyer) + cached_icon = icon2base64(icon(buyer.curio_icon, buyer.curio_icon_state, SOUTH, frame = 1)) + return cached_icon + +/datum/shop_buyback/xenoarch_curio/get_payment_text() + return "by the piece" + +/datum/shop_buyback/xenoarch_curio/matches(obj/item/offered) + if(!isitem(offered) || is_trader_good(offered)) + return FALSE + if((offered.item_flags & ABSTRACT) || (offered.flags_1 & HOLOGRAM_1)) + return FALSE + if(istype(offered, /obj/item/relic) && !buyer?.takes_strange_objects) + return FALSE + return xenoarch_curio_value(offered) > 0 + +/datum/shop_buyback/xenoarch_curio/find_refused_variant(mob/living/user) + return null + +/datum/shop_buyback/xenoarch_curio/get_denial_reason(mob/living/user) + if(demand <= 0) + return "Not buying any more this shift." + if(count_carried_units(user) < 1) + return "Carrying none of the goods: [get_wanted_text()]." + if(!get_shop_buyback_account(user)) + return "No bank account on your ID." + return null + +/datum/shop_buyback/xenoarch_curio/try_sell(mob/living/user, mob/living/basic/outpost_trader/vendor, quiet = FALSE) + last_sale_value = 0 + if(demand <= 0) + return FALSE + var/datum/bank_account/account = get_shop_buyback_account(user) + if(!account) + return FALSE + var/list/found = find_offered_items(user) + if(!length(found)) + return FALSE + var/obj/item/curio = found[1] + var/value = xenoarch_curio_value(curio) + if(value <= 0) + return FALSE + var/curio_name = curio.name + var/curio_type = curio.type + qdel(curio) + demand-- + account.adjust_money(value, "Trader Outpost: sold [curio_name]") + last_sale_value = value + if(!quiet) + to_chat(user, span_notice("Sold: [curio_name] ([xenoarch_credits_text(value)]).")) + // The ordinary ledger metric, priced by the piece + if(SSmetrics.accepting) + var/list/details = list("name" = name, "sales" = 1, "curio" = curio_name) + if(vendor?.shop) + details["shop"] = "[vendor.shop.type]" + record_metric(METRIC_TRADE, "shop_buyback", subject = curio_type, credits = value, quantity = 1, details = details, actor = user) + return TRUE + +/datum/shop_buyback/xenoarch_curio/try_sell_bulk(mob/living/user, mob/living/basic/outpost_trader/vendor) + var/sold = 0 + var/total = 0 + var/safety = 50 + while(demand > 0 && safety-- > 0) + if(!try_sell(user, vendor, quiet = TRUE)) + break + sold++ + total += last_sale_value + if(sold > 0) + to_chat(user, span_notice("Sold [sold] [sold > 1 ? "curios" : "curio"] ([xenoarch_credits_text(total)]).")) + return sold + +// ========================================================================= +// THE RECEIPT +// ========================================================================= + +/// What a buyer hands over with the money: a dealer's paper, in their own hand. +/obj/item/paper/xenoarch_receipt + name = "receipt" + desc = "A dealer's receipt for something old and heavy." + /// Paper stamp icon state to press on once the stamp sheet is ready + var/pending_stamp + +/obj/item/paper/xenoarch_receipt/proc/fill_in(datum/xenoarch_buyer/buyer, list/sale) + name = "receipt ([sale["serial"]])" + add_raw_text(buyer.receipt_text(sale), buyer.receipt_font, buyer.receipt_ink) + update_appearance() + if(buyer.receipt_stamp) + pending_stamp = buyer.receipt_stamp + // The stamp's class comes from the paper spritesheet, which may have to be made ready first + INVOKE_ASYNC(src, PROC_REF(stamp_it)) + +/obj/item/paper/xenoarch_receipt/proc/stamp_it() + var/datum/asset/spritesheet_batched/sheet = get_asset_datum(/datum/asset/spritesheet/simple/paper) + if(QDELETED(src) || !sheet || !pending_stamp) + return + add_stamp(sheet.icon_class_name(pending_stamp), rand(180, 260), rand(40, 120), rand(-15, 15), pending_stamp) + pending_stamp = null + update_appearance() + +#undef XENOARCH_FIND_DEMAND +#undef XENOARCH_CURIO_DEMAND +#undef XENOARCH_COUNTER_REACH +#undef XENOARCH_INSPECT_TIME +#undef XENOARCH_INSPECT_COOLDOWN +#undef XENOARCH_NOTICE_RANGE +#undef XENOARCH_NOTICE_COOLDOWN +#undef XENOARCH_NOTICE_MEMORY +#undef XENOARCH_LINE_NOTICE +#undef XENOARCH_LINE_INSPECT +#undef XENOARCH_LINE_INSPECT_CRATED +#undef XENOARCH_LINE_CRACKED +#undef XENOARCH_LINE_TAPPED +#undef XENOARCH_LINE_PERFECT +#undef XENOARCH_LINE_CLOSE +#undef XENOARCH_LINE_WRONG +#undef XENOARCH_LINE_BLANK +#undef XENOARCH_LINE_LIVE +#undef XENOARCH_LINE_WALKED diff --git a/voidcrew/modules/xenoarch/trade/checkpoint.dm b/voidcrew/modules/xenoarch/trade/checkpoint.dm index 49b987779d0..43ec2af87bc 100644 --- a/voidcrew/modules/xenoarch/trade/checkpoint.dm +++ b/voidcrew/modules/xenoarch/trade/checkpoint.dm @@ -1 +1,78 @@ -// Outpost checkpoints turning back loose finds. +/** + * # Outpost checkpoints turning back loose finds + * + * A large find comes into a trader outpost crated, or not at all. The scan chamber + * (voidcrew/modules/trade/outpost_checkpoint.dm) refuses an artifact standing loose on its floor + * the way it refuses a gun on the floor: the inner door stays shut, the guard (or the wall + * scanner) says so, and the outer door waits until the find has been taken back out. A find in + * any shut crate, or carried in an exosuit, passes. Leaving through an exterior hatch is never + * refused for a find. + * + * Inside, a find does nothing anyway (the outpost holds it still); this keeps one from being + * dragged through the crowd at the door and gives the guards something to say about it. + */ + +/// The refusal category for a loose find: a refused[] value and a line key for guards and scanners +#define XENOARCH_CHECKPOINT_LINE_LOOSE "xenoarch_loose_find" + +/datum/outpost_checkpoint/inspect_chamber() + var/list/refused = ..() + if(exit_request) + return refused + for(var/turf/tile as anything in chamber_turfs) + for(var/obj/machinery/artifact/find in tile) + if(!refused[find]) + refused[find] = XENOARCH_CHECKPOINT_LINE_LOOSE + return refused + +/// Whether everything the chamber refuses right now is a loose find. +/datum/outpost_checkpoint/proc/refusing_only_loose_finds() + var/list/refused = inspect_chamber() + if(!length(refused)) + return FALSE + for(var/atom/movable/thing as anything in refused) + if(refused[thing] != XENOARCH_CHECKPOINT_LINE_LOOSE) + return FALSE + return TRUE + +// A refusal is announced as "armed"; when the only thing refused is a loose find, say that instead. +/datum/outpost_checkpoint/announce(category, urgent = FALSE) + if(category == CHECKPOINT_LINE_ARMED && state == CHECKPOINT_REJECTING && refusing_only_loose_finds()) + category = XENOARCH_CHECKPOINT_LINE_LOOSE + return ..(category, urgent) + +/obj/machinery/outpost_checkpoint_sensor/machine_line(category) + if(category == XENOARCH_CHECKPOINT_LINE_LOOSE) + return "Uncontained specimen." + return ..() + +/datum/outpost_shop/vendor/checkpoint + /// What this guard says to someone pulling a find in loose + var/list/xenoarch_loose_lines = list( + "Crate it first.", + ) + +/datum/outpost_shop/vendor/checkpoint/get_line(category) + if(category == XENOARCH_CHECKPOINT_LINE_LOOSE) + return length(xenoarch_loose_lines) ? pick(xenoarch_loose_lines) : null + return ..() + +/datum/outpost_shop/vendor/checkpoint/halcyon + xenoarch_loose_lines = list( + "Crate it first, please. That doesn't come in loose.", + "Whatever that is, it goes in a crate before it comes past me.", + ) + +/datum/outpost_shop/vendor/checkpoint/quartermain + xenoarch_loose_lines = list( + "Crate it first.", + "Not loose. Box it and come back.", + ) + +/datum/outpost_shop/vendor/checkpoint/undertow + xenoarch_loose_lines = list( + "Crate it.", + "Loose rocks stay outside.", + ) + +#undef XENOARCH_CHECKPOINT_LINE_LOOSE diff --git a/voidcrew/modules/xenoarch/trade/metrics.dm b/voidcrew/modules/xenoarch/trade/metrics.dm index 72bea68c7ee..9612690ccee 100644 --- a/voidcrew/modules/xenoarch/trade/metrics.dm +++ b/voidcrew/modules/xenoarch/trade/metrics.dm @@ -1 +1,22 @@ -// Round metrics for digging, analysing, harvesting and selling finds. +// Round metrics for selling finds. Digging, analysing and harvesting record their own events +// (XENOARCH_METRIC_DUG/LOST/ANALYSED/HARVESTED) where they happen; curios sold over the counter use +// the ordinary "shop_buyback" event. + +/// A large find sold at an outpost. Call before the find is deleted. `zone` is the band it was dug in. +/proc/metric_artifact_sold(obj/machinery/artifact/find, mob/living/seller, credits, vouchers, datum/outpost_shop/shop) + if(!SSmetrics.accepting || QDELETED(find)) + return + var/list/details = list( + "serial" = find.serial, + "accuracy" = round(find.label_accuracy(), 0.01), + "labelled" = !!find.label, + "tier" = find.main_effect?.tier, + "band" = xenoarch_band_name(find.origin_band), + "harvests" = find.harvests_taken(), + "damaged" = !!find.damaged, + "crated" = !isturf(find.loc), + ) + if(shop) + details["buyer"] = shop.trader_name + details["shop"] = "[shop.type]" + record_metric(METRIC_TRADE, XENOARCH_METRIC_SOLD, zone = xenoarch_band_name(find.origin_band), subject = find.main_effect?.type || find.type, credits = credits, vouchers = vouchers, quantity = 1, details = details, actor = seller) From 7a1ca41dada0a85690f33f1004f986c1e458b1b6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:53:11 -0800 Subject: [PATCH 25/72] core change: split capped artifact heals by the wounds present artifact_capped_heal() always asked for half brute and half burn, so a one-sided wound (most wounds) healed only half the amount asked. It now splits the heal in proportion to the brute and burn the target has. The cap and ledger are unchanged. --- voidcrew/modules/xenoarch/core/artifact_helpers.dm | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/xenoarch/core/artifact_helpers.dm b/voidcrew/modules/xenoarch/core/artifact_helpers.dm index 7266df46f6c..6ac141d22c0 100644 --- a/voidcrew/modules/xenoarch/core/artifact_helpers.dm +++ b/voidcrew/modules/xenoarch/core/artifact_helpers.dm @@ -177,7 +177,8 @@ GLOBAL_LIST_EMPTY(artifact_heal_ledger) entry[2] += amount /** - * Heals brute and burn on `target`, never past what the shared cap allows. + * Heals brute and burn on `target`, never past what the shared cap allows. The heal is split in + * proportion to the brute and burn they have, so a one-sided wound gets the whole amount. * Returns the amount actually healed. * * * required_bodytype - limit to robotic or organic limbs on carbons (heal_overall_damage's arg) @@ -187,7 +188,10 @@ GLOBAL_LIST_EMPTY(artifact_heal_ledger) if(allowed <= 0) return 0 var/before = target.getBruteLoss() + target.getFireLoss() - target.heal_overall_damage(allowed / 2, allowed / 2, required_bodytype = required_bodytype) + if(before <= 0) + return 0 + var/brute_share = allowed * target.getBruteLoss() / before + target.heal_overall_damage(brute_share, allowed - brute_share, required_bodytype = required_bodytype) var/healed = max(before - (target.getBruteLoss() + target.getFireLoss()), 0) artifact_record_heal(target, healed) return healed From 3b67b1beb557e6b10561e97775b3e6d3fb87f921 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:53:18 -0800 Subject: [PATCH 26/72] Add the Polaris and VOREStation artifact effects Twenty effects rewritten for tg from Polaris 6cd19fe0dec6 and VOREStation 9335d66b9fdf: extinguisher, animate, sweat, electric field, fortify, faraday, sprint, berserk, disarm, force walls, gaia, plasma vent, poltergeist, feysight, sleeping gas, life drain, gravity wave, spines, life steal and resurrect. Polaris modifiers become tg status effects. Gas vents are indoor only with a finite reserve; resurrect gives three lives and only to a body whose player can come back. --- .../voidcrew_xenoarch_effects_polaris.dm | 267 +++++++++++++++ .../xenoarch/effects_polaris/animate.dm | 120 +++++++ .../xenoarch/effects_polaris/berserk.dm | 32 ++ .../xenoarch/effects_polaris/disarm.dm | 46 +++ .../effects_polaris/electric_field.dm | 94 ++++++ .../xenoarch/effects_polaris/extinguisher.dm | 108 ++++++ .../xenoarch/effects_polaris/faraday.dm | 24 ++ .../xenoarch/effects_polaris/feysight.dm | 32 ++ .../xenoarch/effects_polaris/forcefield.dm | 128 ++++++++ .../xenoarch/effects_polaris/fortify.dm | 24 ++ .../modules/xenoarch/effects_polaris/gaia.dm | 117 +++++++ .../xenoarch/effects_polaris/gravity_wave.dm | 121 +++++++ .../xenoarch/effects_polaris/life_drain.dm | 101 ++++++ .../xenoarch/effects_polaris/life_steal.dm | 81 +++++ .../xenoarch/effects_polaris/plasma_vent.dm | 115 +++++++ .../xenoarch/effects_polaris/poltergeist.dm | 105 ++++++ .../xenoarch/effects_polaris/resurrect.dm | 190 +++++++++++ .../xenoarch/effects_polaris/sleeping_gas.dm | 14 + .../xenoarch/effects_polaris/spines.dm | 98 ++++++ .../xenoarch/effects_polaris/sprint.dm | 24 ++ .../effects_polaris/status_effects.dm | 307 ++++++++++++++++++ .../modules/xenoarch/effects_polaris/sweat.dm | 91 ++++++ 22 files changed, 2239 insertions(+) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm index 0042d09ba8f..7461949a10b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm @@ -1 +1,268 @@ // Xenoarchaeology tests: effects_polaris. +// +// Test files compile before voidcrew/_DEFINES, so the values below are literals. They mirror +// voidcrew/_DEFINES/xenoarch.dm: +// bands (ZONE_*): green 1, yellow 2, red 3 +// tiers: common 1, uncommon 2, rare 3, extreme 4 +// triggers: plasma 512 (the only one a test names) +// release methods: "touch", "aura", "pulse" + +/// The Polaris and VOREStation effects: type -> list(tier, minor, harvestable), from the design catalogue. +/proc/voidcrew_xenoarch_polaris_catalogue() + return list( + /datum/artifact_effect/extinguisher = list(1, TRUE, TRUE), + /datum/artifact_effect/animate = list(1, FALSE, FALSE), + /datum/artifact_effect/sweat = list(1, TRUE, TRUE), + /datum/artifact_effect/electric_field = list(2, FALSE, TRUE), + /datum/artifact_effect/bestow/fortify = list(2, FALSE, TRUE), + /datum/artifact_effect/bestow/faraday = list(2, FALSE, TRUE), + /datum/artifact_effect/bestow/sprint = list(2, FALSE, TRUE), + /datum/artifact_effect/bestow/berserk = list(2, FALSE, TRUE), + /datum/artifact_effect/disarm = list(2, FALSE, TRUE), + /datum/artifact_effect/forcefield = list(2, FALSE, FALSE), + /datum/artifact_effect/gaia = list(2, FALSE, TRUE), + /datum/artifact_effect/gas_vent/plasma = list(2, FALSE, FALSE), + /datum/artifact_effect/poltergeist = list(3, FALSE, TRUE), + /datum/artifact_effect/bestow/feysight = list(3, FALSE, TRUE), + /datum/artifact_effect/gas_vent/sleeping = list(3, FALSE, FALSE), + /datum/artifact_effect/life_drain = list(3, FALSE, TRUE), + /datum/artifact_effect/gravity_wave = list(4, FALSE, FALSE), + /datum/artifact_effect/spines = list(4, FALSE, FALSE), + /datum/artifact_effect/life_steal = list(4, FALSE, TRUE), + /datum/artifact_effect/resurrect = list(4, FALSE, FALSE), + ) + +/// Every Polaris effect is rollable with the catalogue's tier, minor and harvest flags, and rolls sane traits. +/datum/unit_test/voidcrew_xenoarch_polaris_catalogue/Run() + var/list/catalogue = voidcrew_xenoarch_polaris_catalogue() + var/list/rollable = artifact_effect_types() + TEST_ASSERT(!(/datum/artifact_effect/bestow in rollable), "the shared status-giving parent can roll") + TEST_ASSERT(!(/datum/artifact_effect/gas_vent in rollable), "the shared gas vent parent can roll") + for(var/effect_type in catalogue) + var/list/row = catalogue[effect_type] + TEST_ASSERT(effect_type in rollable, "[effect_type] can't roll") + var/label_count = 0 + var/datum/artifact_effect/probe = effect_type + for(var/datum/artifact_effect/other as anything in rollable) + if(initial(other.label_name) == initial(probe.label_name)) + label_count++ + TEST_ASSERT_EQUAL(label_count, 1, "[effect_type]'s label word is shared with another effect") + for(var/band in 1 to 3) + for(var/roll in 1 to 10) + var/datum/artifact_effect/effect = new effect_type(null, band) + TEST_ASSERT_EQUAL(effect.tier, row[1], "[effect_type] has the wrong tier") + TEST_ASSERT_EQUAL(effect.minor, row[2], "[effect_type] has the wrong minor flag") + TEST_ASSERT_EQUAL(effect.harvestable, row[3], "[effect_type] has the wrong harvest flag") + TEST_ASSERT(effect.release_method in effect.allowed_release_methods, "[effect_type] rolled a release it doesn't allow") + TEST_ASSERT(effect.energy_kind in effect.possible_kinds, "[effect_type] rolled a kind it doesn't allow") + TEST_ASSERT(effect.range >= 1 && effect.range <= 8, "[effect_type] rolled range [effect.range]") + TEST_ASSERT(effect.touch_cost <= effect.maximum_charges, "[effect_type] can never be touched off") + qdel(effect) + // Plasma air never wakes the plasma vent, or it would feed itself. + for(var/roll in 1 to 100) + var/datum/artifact_effect/gas_vent/plasma/vent = new(null, 3) + TEST_ASSERT(vent.trigger != 512, "the plasma vent rolled a plasma-air trigger") + qdel(vent) + var/datum/artifact_effect/spines/spines = new(null, 1) + TEST_ASSERT_EQUAL(spines.green_weight, 0.5, "spines are not halved in green") + qdel(spines) + +/// Every Polaris effect wakes, touches, runs a field and bursts on a real artifact without errors, +/// and leaves no force walls standing once it is gone. +/datum/unit_test/voidcrew_xenoarch_polaris_effects_run/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + var/turf/home = get_turf(find) + var/mob/living/carbon/human/consistent/tester = allocate(/mob/living/carbon/human/consistent, get_step(home, EAST)) + tester.mind_initialize() + allocate(/obj/item/crowbar, get_step(get_step(home, NORTH), NORTH)) + for(var/effect_type in voidcrew_xenoarch_polaris_catalogue()) + find.forceMove(home) + tester.forceMove(get_step(home, EAST)) + find.replace_effects(effect_type, null, 3) + var/datum/artifact_effect/effect = find.main_effect + TEST_ASSERT(effect, "[effect_type] did not attach to the artifact") + if(istype(effect, /datum/artifact_effect/gas_vent)) + var/datum/artifact_effect/gas_vent/vent = effect + vent.reserve = 0 // Venting is tested on its own, with the air put back afterwards. + effect.wake(1 MINUTES) + TEST_ASSERT(effect.activated, "[effect_type] did not wake") + effect.do_effect_touch(tester) + effect.do_effect_aura(2) + effect.do_effect_pulse(effect.maximum_charges) + effect.deactivate(quiet = TRUE) + tester.fully_heal(HEAL_ALL) + find.replace_effects(null, null) + for(var/obj/effect/forcefield/artifact/wall in range(4, home)) + TEST_ASSERT(!wall.density, "a force wall still stands after its effect is gone") + for(var/obj/effect/decal/cleanable/artifact_ooze/puddle in range(4, home)) + TEST_ASSERT(puddle.reagents?.total_volume <= 30, "an ooze puddle grew past a cupful") + +/// Resurrect: three lives, then spent; refuses the long dead, minds that moved on, and the absent. +/datum/unit_test/voidcrew_xenoarch_resurrect/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/resurrect, null, 3) + var/datum/artifact_effect/resurrect/rite = find.main_effect + var/mob/living/carbon/human/consistent/patient = allocate(/mob/living/carbon/human/consistent, get_step(find, EAST)) + patient.mind_initialize() + for(var/life in 1 to 3) + patient.adjustBruteLoss(40) + patient.death() + TEST_ASSERT_EQUAL(patient.stat, DEAD, "the patient did not die (life [life])") + TEST_ASSERT_EQUAL(rite.raise_refusal(patient), "gone", "a body with no player to come back was offered a revival") + TEST_ASSERT(rite.raise(patient), "a fresh body was not raised (life [life])") + TEST_ASSERT(patient.stat != DEAD, "the patient is still dead after being raised (life [life])") + TEST_ASSERT_EQUAL(rite.uses_left, 3 - life, "a revival did not spend a use") + patient.death() + TEST_ASSERT_EQUAL(rite.raise_refusal(patient), "spent", "a spent artifact offered a fourth life") + TEST_ASSERT(!rite.raise(patient), "a spent artifact raised the dead") + TEST_ASSERT_EQUAL(patient.stat, DEAD, "a spent artifact brought the patient back") + + // A fresh artifact for the remaining rules. + find.replace_effects(/datum/artifact_effect/resurrect, null, 3) + rite = find.main_effect + patient.timeofdeath = world.time - 6 MINUTES + TEST_ASSERT_EQUAL(rite.raise_refusal(patient), "too late", "a body dead six minutes was offered a revival") + patient.timeofdeath = world.time + var/mob/living/carbon/human/consistent/clone = allocate(/mob/living/carbon/human/consistent, get_step(find, WEST)) + patient.mind.transfer_to(clone) + TEST_ASSERT_EQUAL(rite.raise_refusal(patient), "gone", "a body whose mind moved on was offered a revival") + clone.mind.transfer_to(patient) + ADD_TRAIT(patient, TRAIT_SUICIDED, "unit_test") + TEST_ASSERT_EQUAL(rite.raise_refusal(patient), "ruined", "a suicide was offered a revival") + REMOVE_TRAIT(patient, TRAIT_SUICIDED, "unit_test") + + // Its mending is capped like any artifact healing. + var/mob/living/carbon/human/consistent/wreck = allocate(/mob/living/carbon/human/consistent, get_step(find, NORTH)) + wreck.mind_initialize() + wreck.adjustBruteLoss(170) + wreck.death() + rite.raise(wreck) + TEST_ASSERT(wreck.getBruteLoss() >= 139.5, "resurrect mended past the artifact healing cap ([wreck.getBruteLoss()] brute left)") + +/// Life steal: never heals past the shared cap, and takes exactly what it gives. +/datum/unit_test/voidcrew_xenoarch_life_steal/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/life_steal, null, 3) + var/datum/artifact_effect/life_steal/thief = find.main_effect + thief.range = 3 + var/mob/living/carbon/human/consistent/user = allocate(/mob/living/carbon/human/consistent, get_step(find, EAST)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, get_step(find, WEST)) + TEST_ASSERT_EQUAL(thief.steal_for(user, 10), 0, "life steal healed an unhurt person") + TEST_ASSERT_EQUAL(victim.getBruteLoss() + victim.getFireLoss(), 0, "life steal hurt someone without healing anyone") + user.take_overall_damage(60, 0) + var/healed = 0 + for(var/i in 1 to 6) + healed += thief.steal_for(user, 10) + TEST_ASSERT(healed <= 30.5, "life steal healed past the artifact cap ([healed])") + TEST_ASSERT(healed >= 29.5, "life steal stopped short of the cap ([healed])") + var/lost = victim.getBruteLoss() + victim.getFireLoss() + TEST_ASSERT(abs(lost - healed) < 1, "life steal took [lost] but gave [healed]") + +/// The status effects do what they say while they last and leave nothing behind. +/datum/unit_test/voidcrew_xenoarch_polaris_status_effects/Run() + var/mob/living/carbon/human/consistent/tester = allocate(/mob/living/carbon/human/consistent) + + tester.apply_status_effect(/datum/status_effect/artifact/fortify, 30 SECONDS) + tester.apply_damage(20, BRUTE, BODY_ZONE_CHEST) + var/fortified_loss = tester.getBruteLoss() + TEST_ASSERT(fortified_loss > 15 && fortified_loss < 19, "fortified skin took [fortified_loss] from a 20 brute hit") + tester.remove_status_effect(/datum/status_effect/artifact/fortify) + tester.apply_damage(20, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(tester.getBruteLoss() - fortified_loss > 19.5, "fortify outlived its status effect") + tester.fully_heal(HEAL_ALL) + + // Doses never stack past one dose, nor past the cap. + var/datum/status_effect/artifact/fortify/dose = tester.apply_status_effect(/datum/status_effect/artifact/fortify, 10 SECONDS) + tester.apply_status_effect(/datum/status_effect/artifact/fortify, 10 SECONDS) + TEST_ASSERT(dose.duration <= world.time + 10 SECONDS, "two fortify doses stacked") + tester.apply_status_effect(/datum/status_effect/artifact/fortify, 10 MINUTES) + TEST_ASSERT(dose.duration <= world.time + 90 SECONDS, "a fortify dose ran past its cap") + tester.remove_status_effect(/datum/status_effect/artifact/fortify) + + tester.apply_status_effect(/datum/status_effect/artifact/sprint, 30 SECONDS) + TEST_ASSERT(tester.has_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_sprint), "sprint did not speed anyone up") + tester.remove_status_effect(/datum/status_effect/artifact/sprint) + TEST_ASSERT(!tester.has_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_sprint), "sprint outlived its status effect") + + tester.apply_status_effect(/datum/status_effect/artifact/faraday, 30 SECONDS) + TEST_ASSERT(tester.emp_act(EMP_HEAVY) & EMP_PROTECT_SELF, "faraday did not stop an EMP") + tester.remove_status_effect(/datum/status_effect/artifact/faraday) + TEST_ASSERT(!(tester.emp_act(EMP_HEAVY) & EMP_PROTECT_SELF), "faraday outlived its status effect") + + tester.apply_status_effect(/datum/status_effect/artifact/feysight, 30 SECONDS) + TEST_ASSERT(HAS_TRAIT(tester, TRAIT_THERMAL_VISION), "feysight gave no sight through walls") + tester.remove_status_effect(/datum/status_effect/artifact/feysight) + TEST_ASSERT(!HAS_TRAIT(tester, TRAIT_THERMAL_VISION), "feysight outlived its status effect") + + tester.apply_status_effect(/datum/status_effect/artifact/berserk, 30 SECONDS) + TEST_ASSERT(HAS_TRAIT(tester, TRAIT_PERFECT_ATTACKER), "berserk did not take") + tester.remove_status_effect(/datum/status_effect/artifact/berserk) + TEST_ASSERT(!HAS_TRAIT(tester, TRAIT_PERFECT_ATTACKER), "berserk outlived its status effect") + TEST_ASSERT(tester.has_status_effect(/datum/status_effect/artifact/exhausted), "berserk ended without the crash") + tester.apply_status_effect(/datum/status_effect/artifact/berserk, 30 SECONDS) + TEST_ASSERT(!tester.has_status_effect(/datum/status_effect/artifact/berserk), "an exhausted person was enraged again") + tester.remove_status_effect(/datum/status_effect/artifact/exhausted) + TEST_ASSERT(!tester.has_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_exhausted), "exhaustion outlived its status effect") + +/// Gas vents: indoors they vent from a finite reserve, then run dry for good. +/datum/unit_test/voidcrew_xenoarch_gas_vent_reserve/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + var/turf/open/here = get_turf(find) + var/datum/gas_mixture/original = here.return_air().copy() + find.replace_effects(/datum/artifact_effect/gas_vent/plasma, null, 3) + var/datum/artifact_effect/gas_vent/plasma/vent = find.main_effect + vent.reserve = 10 + vent.max_pressure = 1000 + TEST_ASSERT_EQUAL(vent.vent(6), 6, "the plasma vent did not vent indoors") + TEST_ASSERT_EQUAL(vent.vent(6), 4, "the plasma vent vented past its reserve") + TEST_ASSERT_EQUAL(vent.vent(6), 0, "a dry plasma vent kept venting") + var/list/plasma = here.return_air().gases[/datum/gas/plasma] + TEST_ASSERT(plasma && plasma[MOLES] >= 9.5, "the vented plasma is not on the tile") + here.return_air().copy_from(original) + here.air_update_turf(FALSE, FALSE) + +/// Animate never walks while bolted down, and follows someone when loose. +/datum/unit_test/voidcrew_xenoarch_animate/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/animate, null, 1) + var/datum/artifact_effect/animate/walker = find.main_effect + walker.range = 5 + var/turf/start = get_turf(find) + var/mob/living/carbon/human/consistent/quarry = allocate(/mob/living/carbon/human/consistent, locate(start.x + 3, start.y, start.z)) + quarry.mind_initialize() + find.set_anchored(TRUE) + walker.stalk() + TEST_ASSERT_EQUAL(get_turf(find), start, "a bolted artifact walked") + find.set_anchored(FALSE) + TEST_ASSERT(walker.stalk(), "a loose artifact did not follow someone in reach") + TEST_ASSERT(get_dist(find, quarry) < 3, "the artifact did not close in on its quarry") + +/** + * Polaris effects on planet ground never give a planet turf private air: the gas vents, the + * extinguisher and the force walls. (A real flame writes the air itself, so none is lit here.) Uses the shared planet air room from voidcrew_planet_shared_air.dm. + */ +/datum/unit_test/voidcrew_planet_shared_air/xenoarch_polaris_effects/Run() + if(!planetary_atmos_shared_mix_enabled()) + return + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, center) + find.replace_effects(/datum/artifact_effect/gas_vent/plasma, /datum/artifact_effect/gas_vent/sleeping, 3) + TEST_ASSERT(room_is_pristine(), "placing an artifact gave the planet private air") + for(var/datum/artifact_effect/gas_vent/vent as anything in find.effects()) + vent.release_method = "aura" + vent.wake(1 MINUTES) + TEST_ASSERT_EQUAL(vent.vent(10), 0, "a gas vent vented into planet air") + vent.do_effect_touch(null) + vent.do_effect_pulse(50) + for(var/i in 1 to 3) + find.process(2) + TEST_ASSERT(room_is_pristine(), "a gas vent running on a planet gave it private air") + find.replace_effects(/datum/artifact_effect/extinguisher, /datum/artifact_effect/forcefield, 3) + var/datum/artifact_effect/extinguisher/snuffer = find.main_effect + snuffer.range = 2 + snuffer.do_effect_pulse(50) + snuffer.do_effect_aura(2) + var/datum/artifact_effect/forcefield/field = find.second_effect + field.ring_radius = 2 + field.raise_ring(10 SECONDS) + TEST_ASSERT(room_is_pristine(), "the extinguisher or force walls gave the planet private air") + field.drop_ring() diff --git a/voidcrew/modules/xenoarch/effects_polaris/animate.dm b/voidcrew/modules/xenoarch/effects_polaris/animate.dm index 9dc7522445f..17d22f7b658 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/animate.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/animate.dm @@ -1 +1,121 @@ // Artifact effect: animate. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/animate_anomaly.dm, +// also in VOREStation (9335d66b9fdf). Voidcrew: the artifact itself lurches one step at a time +// toward the nearest person (or away from a hand laid on it). It only moves with an ordinary +// Move(), so walls, doors and people stop it, and it never steps into space, open air, lava or a +// chasm. Bolted down, it strains against the bolts instead. Not harvestable: only an artifact +// can walk. No destroy burst. + +/datum/artifact_effect/animate + label_name = "Moves on its own" + log_name = "Animate" + tier = ARTIFACT_TIER_COMMON + harvestable = FALSE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_BLUESPACE) + /// Who it is following + var/datum/weakref/quarry_ref + /// Throttles the lurch and strain messages + COOLDOWN_DECLARE(message_cooldown) + +/datum/artifact_effect/animate/setup() + range = max(range, 3) + +/datum/artifact_effect/animate/Destroy(force) + quarry_ref = null + return ..() + +/datum/artifact_effect/animate/do_effect_touch(mob/living/user) + var/obj/machinery/artifact/artifact = holder + if(!istype(artifact) || !isturf(artifact.loc)) + return + if(lurch(get_step_away(artifact, user))) + artifact.visible_message(span_warning("[artifact] lurches away from [user]!")) + +/datum/artifact_effect/animate/do_effect_aura(seconds_per_tick) + stalk() + +/datum/artifact_effect/animate/do_effect_pulse(power) + for(var/step in 1 to clamp(round(power / 12), 1, 3)) + if(!stalk()) + return + +/datum/artifact_effect/animate/do_effect_destroy() + return + +/// One step toward whoever it is following. Returns TRUE if it moved. +/datum/artifact_effect/animate/proc/stalk() + var/obj/machinery/artifact/artifact = holder + if(!istype(artifact) || !isturf(artifact.loc)) + return FALSE + var/mob/living/quarry = find_quarry() + if(!quarry) + return FALSE + artifact.setDir(get_dir(artifact, quarry)) + if(get_dist(artifact, quarry) <= 1) + return FALSE + if(!lurch(get_step_to(artifact, quarry))) + return FALSE + if(COOLDOWN_FINISHED(src, message_cooldown)) + COOLDOWN_START(src, message_cooldown, 8 SECONDS) + artifact.visible_message(span_warning("[artifact] lurches toward [quarry].")) + return TRUE + +/// Steps onto `destination` if it can stand there. Returns TRUE if it moved. +/datum/artifact_effect/animate/proc/lurch(turf/destination) + var/obj/machinery/artifact/artifact = holder + if(!istype(artifact) || !isturf(destination) || destination.z != artifact.z) + return FALSE + if(isspaceturf(destination) || isgroundlessturf(destination) || islava(destination) || ischasm(destination)) + return FALSE + if(artifact.anchored) + strain(artifact) + return FALSE + var/turf/start = artifact.loc + if(!artifact.Move(destination, get_dir(start, destination)) || artifact.loc == start) + return FALSE + playsound(artifact, pick('sound/effects/footstep/heavy1.ogg', 'sound/effects/footstep/heavy2.ogg'), 40, TRUE) + hop(artifact) + return TRUE + +/// Bolted down, it rocks against the bolts. +/datum/artifact_effect/animate/proc/strain(obj/machinery/artifact/artifact) + playsound(artifact, 'sound/effects/creak/creak1.ogg', 35, TRUE) + var/shove = pick(-3, 3) + animate(artifact, pixel_x = artifact.base_pixel_x + shove, time = 0.1 SECONDS, flags = ANIMATION_PARALLEL) + animate(pixel_x = artifact.base_pixel_x, time = 0.2 SECONDS) + if(COOLDOWN_FINISHED(src, message_cooldown)) + COOLDOWN_START(src, message_cooldown, 8 SECONDS) + artifact.visible_message(span_warning("[artifact] strains against its bolts.")) + +/// A heavy little hop. +/datum/artifact_effect/animate/proc/hop(obj/machinery/artifact/artifact) + animate(artifact, pixel_z = 4, time = 0.1 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(pixel_z = 0, time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) + +/** + * The person it follows: the last one while they stay close and awake, else the nearest + * conscious mob in range, preferring people with a mind over animals. + */ +/datum/artifact_effect/animate/proc/find_quarry() + var/turf/center = source_turf() + if(!center) + return null + var/mob/living/quarry = quarry_ref?.resolve() + if(quarry && quarry.stat == CONSCIOUS && quarry.z == center.z && get_dist(center, quarry) <= range) + return quarry + quarry = null + var/quarry_distance = INFINITY + var/quarry_has_mind = FALSE + for(var/mob/living/candidate in living_in_range()) + if(candidate.stat != CONSCIOUS) + continue + var/has_mind = !!candidate.mind + var/distance = get_dist(center, candidate) + if(quarry_has_mind && !has_mind) + continue + if((has_mind && !quarry_has_mind) || distance < quarry_distance) + quarry = candidate + quarry_distance = distance + quarry_has_mind = has_mind + quarry_ref = quarry ? WEAKREF(quarry) : null + return quarry diff --git a/voidcrew/modules/xenoarch/effects_polaris/berserk.dm b/voidcrew/modules/xenoarch/effects_polaris/berserk.dm index 5e21a5f6cd7..f0072e09cd5 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/berserk.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/berserk.dm @@ -1 +1,33 @@ // Artifact effect: berserk. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/berserk.dm, also in +// VOREStation (9335d66b9fdf). Voidcrew: a status effect instead of Polaris' /datum/modifier. Rage +// makes every punch land, weapons hit a quarter harder and swings come faster; when it ends the +// person is drained and slow for a while, and can't be enraged again until they recover. Polaris' +// brain damage per dose is dropped: the crash afterwards is the price. Works on people only. + +/datum/artifact_effect/bestow/berserk + label_name = "Rage" + log_name = "Berserk" + tier = ARTIFACT_TIER_UNCOMMON + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_ORGANIC) + status_type = /datum/status_effect/artifact/berserk + touch_duration = 30 SECONDS + pulse_duration = 25 SECONDS + aura_duration = 10 SECONDS + aura_chance = 6 + +/datum/artifact_effect/bestow/berserk/can_bestow(mob/living/target) + return iscarbon(target) && target.stat == CONSCIOUS + +/datum/artifact_effect/bestow/berserk/on_bestowed(mob/living/target) + target.visible_message( + span_warning("[target]'s face twists with sudden fury."), + span_userdanger("Heat floods your face. Your hands want something to break."), + ) + playsound(target, 'sound/effects/magic/demon_attack1.ogg', 15, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/artifact_effect/bestow/berserk/on_renewed(mob/living/target) + to_chat(target, span_warning("Your anger flares up again.")) + +/datum/artifact_effect/bestow/berserk/on_refused(mob/living/target) + to_chat(target, span_notice("A flash of anger comes and goes. You're too tired to hold onto it.")) diff --git a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm index 60e09910339..fb2b5ca82fd 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm @@ -1 +1,47 @@ // Artifact effect: disarm. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/disarm.dm. Voidcrew: +// pries open the hand people are using, so what they hold drops at their feet (never thrown, so +// nothing lands in a chasm or lava); items that can't be dropped stay put. Logged like a weapon. + +/datum/artifact_effect/disarm + label_name = "Makes people drop things" + log_name = "Disarm" + tier = ARTIFACT_TIER_UNCOMMON + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_BLUESPACE) + +/datum/artifact_effect/disarm/do_effect_touch(mob/living/user) + pry(user) + +/datum/artifact_effect/disarm/do_effect_aura(seconds_per_tick) + for(var/mob/living/target in living_in_range()) + if(SPT_PROB(6, seconds_per_tick)) + pry(target) + +/datum/artifact_effect/disarm/do_effect_pulse(power) + var/turf/center = source_turf() + if(center) + new /obj/effect/temp_visual/circle_wave/artifact_mist/pry(center, max(range * 0.6, 1.5)) + for(var/mob/living/target in living_in_range()) + pry(target) + +/// Makes `target` drop what is in their active hand. Returns TRUE if it fell. +/datum/artifact_effect/disarm/proc/pry(mob/living/target) + if(target.stat != CONSCIOUS || !prob(100 * artifact_exposure(target))) + return FALSE + var/obj/item/held = target.get_active_held_item() + if(!held || !target.dropItemToGround(held)) + return FALSE + target.visible_message( + span_warning("[target]'s hand jerks open and [held] falls to the floor."), + span_warning("Something pries your fingers open. [held] falls out of your grip!"), + ) + playsound(target, 'sound/items/weapons/thudswoosh.ogg', 40, TRUE) + new /obj/effect/temp_visual/telekinesis(get_turf(target)) + log_harm(target) + return TRUE + +/// A faint violet ripple for the burst. +/obj/effect/temp_visual/circle_wave/artifact_mist/pry + color = "#e17ceb" + max_alpha = 110 diff --git a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm index 870a293c5f0..4ca6380c00d 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm @@ -1 +1,95 @@ // Artifact effect: electric field. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/electric_field.dm, +// also in VOREStation (9335d66b9fdf). Voidcrew: arcs only find robots, prosthetic-bodied mobs and +// people holding something metal, so it can be read and avoided (empty hands, insulated gloves); +// Polaris shocked everyone in range for up to 40 burn. Field arcs never stun, bursts only knock +// down briefly, and every arc is logged. A hand on it grounds the toucher: the charge leaps to +// everyone else nearby instead, as in Polaris. Lights in range flicker as it charges. + +/// Lights one charge can flicker, so a room full of lamps costs no more than a few +#define ARTIFACT_FIELD_MAX_FLICKERS 6 + +/datum/artifact_effect/electric_field + label_name = "Shocks" + log_name = "Electric field" + tier = ARTIFACT_TIER_UNCOMMON + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC, ARTIFACT_ENERGY_CONCENTRATED) + +/datum/artifact_effect/electric_field/do_effect_touch(mob/living/user) + flicker_lights() + var/arcs = 0 + for(var/mob/living/target in living_in_range()) + if(target == user) + continue + if(arc_to(target, rand(10, 15), knockdown = FALSE)) + arcs++ + if(arcs) + to_chat(user, span_warning("A charge runs up your arm, through you, and leaps away.")) + playsound(source_turf(), 'sound/effects/magic/lightningshock.ogg', 35, TRUE) + else + to_chat(user, span_notice("The hair on your arm stands up, then settles.")) + +/datum/artifact_effect/electric_field/do_effect_aura(seconds_per_tick) + if(SPT_PROB(15, seconds_per_tick)) + flicker_lights(2) + for(var/mob/living/target in living_in_range()) + if(SPT_PROB(10, seconds_per_tick)) + arc_to(target, rand(3, 6), knockdown = FALSE) + +/datum/artifact_effect/electric_field/do_effect_pulse(power) + var/turf/center = source_turf() + if(!center) + return + flicker_lights() + playsound(center, 'sound/effects/magic/lightningshock.ogg', 50, TRUE) + new /obj/effect/temp_visual/electricity(center) + for(var/mob/living/target in living_in_range()) + arc_to(target, clamp(power * 0.4, 6, 18), knockdown = power >= 25) + +/** + * Arcs to `target` if it is a machine or holds something metal. Returns TRUE if the shock took. + * Insulated gloves and a suit's insulation work as they do against any shock. + */ +/datum/artifact_effect/electric_field/proc/arc_to(mob/living/target, damage, knockdown) + if(target.stat == DEAD || !conducts(target)) + return FALSE + damage *= artifact_exposure(target) + if(damage < 1) + return FALSE + var/flags = SHOCK_SUPPRESS_MESSAGE | (knockdown ? SHOCK_KNOCKDOWN : SHOCK_NOSTUN) + // Positional on purpose: only the carbon override has the jitter, stutter and stun arguments. + if(!target.electrocute_act(damage, holder, 1, flags, 4 SECONDS, 2 SECONDS, 1 SECONDS)) + return FALSE + var/atom/origin = get_atom_on_turf(holder) + origin?.Beam(target, icon_state = "lightning[rand(1, 12)]", time = 0.5 SECONDS) + if(issilicon(target) || (target.mob_biotypes & MOB_ROBOTIC)) + to_chat(target, span_danger("ERROR: Electrical fault detected!")) + target.adjust_stutter(3 SECONDS) + else + to_chat(target, span_danger("A spark leaps from [holder] to you!")) + log_harm(target) + return TRUE + +/// Robots and anything with metal in hand make a path for the charge. +/datum/artifact_effect/electric_field/proc/conducts(mob/living/target) + if(issilicon(target) || (target.mob_biotypes & MOB_ROBOTIC)) + return TRUE + for(var/obj/item/held in target.held_items) + if(held.obj_flags & CONDUCTS_ELECTRICITY) + return TRUE + return FALSE + +/// Makes a few lights in range stutter. +/datum/artifact_effect/electric_field/proc/flicker_lights(limit = ARTIFACT_FIELD_MAX_FLICKERS) + var/turf/center = source_turf() + if(!center) + return + var/flickered = 0 + for(var/obj/machinery/light/light in range(range, center)) + if(flickered >= limit) + return + if(light.flicker(rand(3, 6))) + flickered++ + +#undef ARTIFACT_FIELD_MAX_FLICKERS diff --git a/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm b/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm index f7405b2a680..66166f5a773 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm @@ -1 +1,109 @@ // Artifact effect: extinguisher. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/extinguisher.dm. +// Voidcrew: puts out burning people, burning objects and open flames in its range, and wets +// people a little so they are slow to catch again. Air is only cooled indoors; on planet ground +// it snuffs the flames and never touches the shared planet air. No wet floors (Polaris left +// slippery tiles); no destroy burst. + +/// Hot air on a fire tile is pulled down toward this +#define ARTIFACT_EXTINGUISH_COOL_TO (T20C + 30) +/// At most this many kelvin come off a fire tile's air per douse +#define ARTIFACT_EXTINGUISH_COOL_STEP 120 + +/datum/artifact_effect/extinguisher + label_name = "Puts out fires" + log_name = "Extinguisher" + tier = ARTIFACT_TIER_COMMON + minor = TRUE + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_SYNTHESIS, ARTIFACT_ENERGY_PARTICLE) + +/datum/artifact_effect/extinguisher/do_effect_touch(mob/living/user) + douse(user, 4) + to_chat(user, span_notice("Cold mist beads on your skin where you touched [holder].")) + playsound(source_turf(), 'sound/effects/extinguish.ogg', 25, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + +/datum/artifact_effect/extinguisher/do_effect_aura(seconds_per_tick) + var/turf/center = source_turf() + if(!center) + return + var/quenched = quench_area(center) + for(var/mob/living/target in living_in_range()) + if(target.on_fire) + douse(target, 2) + quenched = TRUE + if(quenched) + playsound(center, 'sound/effects/extinguish.ogg', 30, TRUE) + else if(SPT_PROB(15, seconds_per_tick)) + new /obj/effect/temp_visual/artifact_mist(center) + +/datum/artifact_effect/extinguisher/do_effect_pulse(power) + var/turf/center = source_turf() + if(!center) + return + new /obj/effect/temp_visual/circle_wave/artifact_mist(center, max(range * 0.6, 1.5)) + playsound(center, 'sound/effects/extinguish.ogg', 45, TRUE) + quench_area(center) + for(var/mob/living/target in living_in_range()) + douse(target, 3) + +/datum/artifact_effect/extinguisher/do_effect_destroy() + return + +/// Puts out `target` and leaves them damp. Tells them if they were burning. +/datum/artifact_effect/extinguisher/proc/douse(mob/living/target, wetness) + var/was_burning = target.on_fire + target.extinguish_mob() + target.adjust_wet_stacks(wetness * artifact_exposure(target)) + if(was_burning && !target.on_fire) + to_chat(target, span_notice("A cold mist rolls over you, and the flames on you gutter out.")) + +/// Snuffs open flames and burning objects in range. Returns TRUE if it put anything out. +/datum/artifact_effect/extinguisher/proc/quench_area(turf/center) + . = FALSE + for(var/obj/effect/hotspot/flame in range(range, center)) + var/turf/open/flame_turf = flame.loc + qdel(flame) + cool_air(flame_turf) + new /obj/effect/temp_visual/artifact_mist(flame_turf) + . = TRUE + for(var/obj/burning in range(range, center)) + if(burning.resistance_flags & ON_FIRE) + burning.extinguish() + . = TRUE + +/// Takes the heat out of an indoor fire tile so it doesn't flare straight back. Never planet air. +/datum/artifact_effect/extinguisher/proc/cool_air(turf/open/spot) + if(!istype(spot) || spot.planetary_atmos || isspaceturf(spot)) + return + var/datum/gas_mixture/air = spot.return_air() + if(!air || air.temperature <= ARTIFACT_EXTINGUISH_COOL_TO) + return + air.temperature = max(ARTIFACT_EXTINGUISH_COOL_TO, air.temperature - ARTIFACT_EXTINGUISH_COOL_STEP) + spot.air_update_turf(FALSE, FALSE) + +/// A puff of cold mist. +/obj/effect/temp_visual/artifact_mist + icon_state = "extinguish" + alpha = 200 + duration = 0.8 SECONDS + +/obj/effect/temp_visual/artifact_mist/Initialize(mapload) + . = ..() + pixel_x = rand(-6, 6) + pixel_y = rand(-4, 8) + animate(src, alpha = 0, pixel_y = pixel_y + 6, time = duration) + +/// A ring of mist rolling out from the artifact. +/obj/effect/temp_visual/circle_wave/artifact_mist + color = "#cfe9ff" + max_alpha = 170 + duration = 0.8 SECONDS + +/obj/effect/temp_visual/circle_wave/artifact_mist/Initialize(mapload, scale) + if(isnum(scale)) + amount_to_scale = scale + return ..() + +#undef ARTIFACT_EXTINGUISH_COOL_TO +#undef ARTIFACT_EXTINGUISH_COOL_STEP diff --git a/voidcrew/modules/xenoarch/effects_polaris/faraday.dm b/voidcrew/modules/xenoarch/effects_polaris/faraday.dm index 653c55103f6..117dfd06f43 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/faraday.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/faraday.dm @@ -1 +1,25 @@ // Artifact effect: faraday. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/faraday.dm. +// Voidcrew: a status effect that swallows EMPs for the wearer and everything they carry +// (Polaris used a /datum/modifier); doses never stack past two minutes. + +/datum/artifact_effect/bestow/faraday + label_name = "EMP shielding" + log_name = "Faraday" + tier = ARTIFACT_TIER_UNCOMMON + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ELECTROMAGNETIC, ARTIFACT_ENERGY_PARTICLE) + status_type = /datum/status_effect/artifact/faraday + touch_duration = 90 SECONDS + pulse_duration = 30 SECONDS + aura_duration = 10 SECONDS + +/datum/artifact_effect/bestow/faraday/on_bestowed(mob/living/target) + if(issilicon(target)) + to_chat(target, span_notice("Your sensors go quiet. Background interference: none.")) + else + to_chat(target, span_notice("The hair on your arms lies flat, and a cold hush settles over your skin.")) + playsound(target, 'sound/effects/magic/magic_block.ogg', 20, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/artifact_effect/bestow/faraday/on_renewed(mob/living/target) + to_chat(target, span_notice("The hush on your skin deepens.")) diff --git a/voidcrew/modules/xenoarch/effects_polaris/feysight.dm b/voidcrew/modules/xenoarch/effects_polaris/feysight.dm index 902c4ab4625..60c5fb1ca08 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/feysight.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/feysight.dm @@ -1 +1,33 @@ // Artifact effect: feysight. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/feysight.dm, also in +// VOREStation (9335d66b9fdf). Voidcrew: a status effect giving thermal sight (living things show +// through walls) with a faint green tint, instead of Polaris' /datum/modifier. The first dose +// still costs a pounding headache, as in Polaris. Doses never stack past 45 seconds. + +#define ARTIFACT_FEYSIGHT_HEADACHE 10 + +/datum/artifact_effect/bestow/feysight + label_name = "Second sight" + log_name = "Feysight" + tier = ARTIFACT_TIER_RARE + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC) + status_type = /datum/status_effect/artifact/feysight + touch_duration = 30 SECONDS + pulse_duration = 20 SECONDS + aura_duration = 8 SECONDS + aura_chance = 8 + +/datum/artifact_effect/bestow/feysight/can_bestow(mob/living/target) + return iscarbon(target) && target.stat != DEAD + +/datum/artifact_effect/bestow/feysight/on_bestowed(mob/living/target) + to_chat(target, span_hypnophrase("The walls go thin as paper, and through them, warm shapes move.")) + to_chat(target, span_warning("The inside of your head hurts...")) + target.adjustStaminaLoss(ARTIFACT_FEYSIGHT_HEADACHE) + playsound(target, 'sound/effects/magic/magic_block_mind.ogg', 20, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/artifact_effect/bestow/feysight/on_renewed(mob/living/target) + to_chat(target, span_hypnophrase("The shapes behind the walls sharpen.")) + +#undef ARTIFACT_FEYSIGHT_HEADACHE diff --git a/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm b/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm index 4ee85013446..b50008e930e 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm @@ -1 +1,129 @@ // Artifact effect: forcefield. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/forcefield.dm, also in +// VOREStation (9335d66b9fdf). Voidcrew: a ring of short-lived force walls two tiles out (three for a +// strong one) instead of Polaris' permanent sixteen-wall box. A burst or a touch raises it for a +// few seconds; a field holds it up only while the field runs and drops it the moment it rests. +// Walls skip tiles already blocked (walls, closed doors, machines) and fade in, so nobody is +// sealed into a wall. Everything it raised goes when it sleeps or is destroyed. Not harvestable. + +/// How long a field keeps its ring after its last run before letting it drop +#define ARTIFACT_FORCEFIELD_AURA_GRACE (3 SECONDS) + +/datum/artifact_effect/forcefield + label_name = "Force walls" + log_name = "Forcefield" + tier = ARTIFACT_TIER_UNCOMMON + harvestable = FALSE + possible_kinds = list(ARTIFACT_ENERGY_PARTICLE, ARTIFACT_ENERGY_BLUESPACE) + /// Tiles from the artifact to the ring + var/ring_radius = 2 + /// How long a burst or touch keeps the ring up + var/ring_time = 10 SECONDS + /// The walls it raised that still stand + var/list/obj/effect/forcefield/artifact/walls = list() + /// world.time a field last held the ring up + var/last_held = 0 + +/datum/artifact_effect/forcefield/setup() + switch(strength) + if(ARTIFACT_STRENGTH_LARGE) + ring_radius = 3 + ring_time = 15 SECONDS + if(ARTIFACT_STRENGTH_MEDIUM) + ring_time = 12 SECONDS + else + ring_time = 8 SECONDS + // A touch needs a good part of the store, so a hand can't keep the ring up for free. + touch_cost = max(touch_cost, round(maximum_charges / 2)) + +/datum/artifact_effect/forcefield/copy_settings_to(datum/artifact_effect/forcefield/copy) + copy.ring_radius = ring_radius + copy.ring_time = ring_time + +/datum/artifact_effect/forcefield/Destroy(force) + drop_ring() + return ..() + +/datum/artifact_effect/forcefield/on_deactivate() + drop_ring() + +/datum/artifact_effect/forcefield/tick(seconds_per_tick) + . = ..() + if(release_method == ARTIFACT_RELEASE_AURA && length(walls) && world.time > last_held + ARTIFACT_FORCEFIELD_AURA_GRACE) + drop_ring() + +/datum/artifact_effect/forcefield/do_effect_touch(mob/living/user) + to_chat(user, span_notice("The air around [holder] goes taut as a drumskin.")) + raise_ring(ring_time) + +/datum/artifact_effect/forcefield/do_effect_aura(seconds_per_tick) + last_held = world.time + if(!length(walls)) + // Long enough to outlast any one run; the field drops it itself when it rests. + raise_ring(2 MINUTES) + +/datum/artifact_effect/forcefield/do_effect_pulse(power) + raise_ring(ring_time) + +/datum/artifact_effect/forcefield/do_effect_destroy() + return + +/// Raises a fresh ring around the artifact. Returns how many walls went up. +/datum/artifact_effect/forcefield/proc/raise_ring(duration) + drop_ring() + var/turf/center = source_turf() + if(!center) + return 0 + var/tint = artifact_energy_kind(energy_kind).color + for(var/turf/open/spot in RANGE_TURFS(ring_radius, center)) + if(max(abs(spot.x - center.x), abs(spot.y - center.y)) != ring_radius) + continue + if(isspaceturf(spot) || isgroundlessturf(spot) || spot.is_blocked_turf(exclude_mobs = TRUE)) + continue + var/obj/effect/forcefield/artifact/wall = new(spot, duration, tint) + walls += wall + RegisterSignal(wall, COMSIG_QDELETING, PROC_REF(wall_gone)) + if(length(walls)) + playsound(center, 'sound/effects/magic/forcewall.ogg', 45, TRUE) + center.visible_message(span_warning("Walls of rippling light spring up around [holder]!")) + return length(walls) + +/// Takes down every wall it raised. +/datum/artifact_effect/forcefield/proc/drop_ring() + if(!length(walls)) + return + for(var/obj/effect/forcefield/artifact/wall as anything in walls) + UnregisterSignal(wall, COMSIG_QDELETING) + wall.fade_out() + walls.Cut() + +/// A wall timed out or was deleted: forget it. +/datum/artifact_effect/forcefield/proc/wall_gone(datum/source) + SIGNAL_HANDLER + walls -= source + +/// One pane of the ring. +/obj/effect/forcefield/artifact + name = "shimmering wall" + desc = "A pane of rippling light, hard as glass." + icon_state = "shield-greyscale" + alpha = 0 + initial_duration = 10 SECONDS + +/obj/effect/forcefield/artifact/Initialize(mapload, duration, tint) + if(isnum(duration)) + initial_duration = duration + . = ..() + if(tint) + color = tint + animate(src, alpha = 190, time = 0.4 SECONDS) + +/// Fades and goes, rather than blinking out. +/obj/effect/forcefield/artifact/proc/fade_out() + if(QDELETED(src)) + return + set_density(FALSE) + animate(src, alpha = 0, time = 0.3 SECONDS) + QDEL_IN(src, 0.3 SECONDS) + +#undef ARTIFACT_FORCEFIELD_AURA_GRACE diff --git a/voidcrew/modules/xenoarch/effects_polaris/fortify.dm b/voidcrew/modules/xenoarch/effects_polaris/fortify.dm index fb611a3afe2..c89b845939a 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/fortify.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/fortify.dm @@ -1 +1,25 @@ // Artifact effect: fortify. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/fortify.dm, also in +// VOREStation (9335d66b9fdf). Voidcrew: a short status effect taking 15% off brute and burn +// (Polaris used a /datum/modifier); doses never stack past a minute and a half. + +/datum/artifact_effect/bestow/fortify + label_name = "Toughens skin" + log_name = "Fortify" + tier = ARTIFACT_TIER_UNCOMMON + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_CONCENTRATED, ARTIFACT_ENERGY_ORGANIC) + status_type = /datum/status_effect/artifact/fortify + touch_duration = 60 SECONDS + pulse_duration = 30 SECONDS + aura_duration = 10 SECONDS + +/datum/artifact_effect/bestow/fortify/on_bestowed(mob/living/target) + if(issilicon(target)) + to_chat(target, span_notice("Your plating hums and stiffens.")) + else + to_chat(target, span_notice("Your skin tightens and goes tough as old leather.")) + playsound(target, 'sound/effects/magic/fleshtostone.ogg', 20, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/artifact_effect/bestow/fortify/on_renewed(mob/living/target) + to_chat(target, span_notice("Your skin tightens again.")) diff --git a/voidcrew/modules/xenoarch/effects_polaris/gaia.dm b/voidcrew/modules/xenoarch/effects_polaris/gaia.dm index d3a5bd5022e..0e8583eb7be 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/gaia.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/gaia.dm @@ -1 +1,118 @@ // Artifact effect: gaia. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/gaia.dm, also in +// VOREStation (9335d66b9fdf). Voidcrew: tends living plants in hydroponics trays nearby (water, +// health, weeds, pests and toxins) but never speeds their growth or revives dead ones, so it +// saves a gardener's chores without making more crop. Plant people nearby feel it too, inside the +// shared artifact healing cap. Polaris' glitterflies become a few short-lived butterflies, at +// most three at a time, that fade away on their own. + +/// Butterflies alive at once, per artifact +#define ARTIFACT_GAIA_MAX_BUTTERFLIES 3 + +/datum/artifact_effect/gaia + label_name = "Feeds plants" + log_name = "Gaia" + tier = ARTIFACT_TIER_UNCOMMON + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC, ARTIFACT_ENERGY_SYNTHESIS) + /// Weakrefs to the butterflies it has drawn + var/list/datum/weakref/butterflies = list() + +/datum/artifact_effect/gaia/Destroy(force) + for(var/datum/weakref/butterfly_ref as anything in butterflies) + var/mob/living/basic/butterfly/lavaland/temporary/butterfly = butterfly_ref.resolve() + if(butterfly) + butterfly.fadeout() + butterflies = null + return ..() + +/datum/artifact_effect/gaia/do_effect_touch(mob/living/user) + to_chat(user, span_notice("You feel the presence of something long forgotten, and green.")) + var/tended = tend_all(range + 2, 3, 30) + if(tended) + to_chat(user, span_notice("Somewhere nearby, leaves rustle.")) + +/datum/artifact_effect/gaia/do_effect_aura(seconds_per_tick) + tend_all(range, 0.5 * seconds_per_tick, 2) + for(var/mob/living/carbon/human/sprout in living_in_range()) + if(SPT_PROB(10, seconds_per_tick)) + nourish(sprout, 2) + +/datum/artifact_effect/gaia/do_effect_pulse(power) + var/turf/center = source_turf() + if(!center) + return + new /obj/effect/temp_visual/circle_wave/artifact_mist/gaia(center, max(range * 0.6, 1.5)) + playsound(center, 'sound/effects/rustle/rustle1.ogg', 40, TRUE) + tend_all(range, clamp(power / 5, 2, 8), 10) + for(var/mob/living/carbon/human/sprout in living_in_range()) + nourish(sprout, clamp(power / 4, 2, 8)) + +/** + * Tends every planted tray in `reach` by `amount`, with `butterfly_chance` per tray of drawing a + * butterfly. Returns how many it tended. + */ +/datum/artifact_effect/gaia/proc/tend_all(reach, amount, butterfly_chance) + . = 0 + var/turf/center = source_turf() + if(!center) + return + for(var/obj/machinery/hydroponics/tray in range(reach, center)) + if(!tend(tray, amount)) + continue + .++ + if(prob(butterfly_chance)) + draw_butterfly(get_turf(tray)) + +/// Waters, weeds and soothes one tray's living plant. Never ages it. Returns TRUE if it had one. +/datum/artifact_effect/gaia/proc/tend(obj/machinery/hydroponics/tray, amount) + if(!tray.myseed || tray.plant_status == HYDROTRAY_PLANT_DEAD) + return FALSE + tray.adjust_waterlevel(amount * 3) + tray.adjust_plant_health(amount) + tray.adjust_weedlevel(-1) + tray.adjust_pestlevel(-1) + tray.adjust_toxic(-amount) + tray.update_appearance() + if(prob(40)) + new /obj/effect/temp_visual/artifact_gaia_bloom(get_turf(tray)) + return TRUE + +/// Plant people take a little warmth from it, inside the shared healing cap. +/datum/artifact_effect/gaia/proc/nourish(mob/living/carbon/human/sprout, amount) + if(sprout.stat == DEAD || !(sprout.mob_biotypes & MOB_PLANT)) + return + if(artifact_capped_heal(sprout, amount * artifact_exposure(sprout)) > 0 && prob(30)) + to_chat(sprout, span_notice("A warm, green feeling seeps into your leaves.")) + +/// A butterfly comes to the plants for a while. +/datum/artifact_effect/gaia/proc/draw_butterfly(turf/spot) + for(var/datum/weakref/butterfly_ref as anything in butterflies) + if(!butterfly_ref.resolve()) + butterflies -= butterfly_ref + if(length(butterflies) >= ARTIFACT_GAIA_MAX_BUTTERFLIES || !istype(spot)) + return + var/mob/living/basic/butterfly/lavaland/temporary/butterfly = new(spot) + butterfly.source = get_atom_on_turf(holder) + butterfly.max_distance = range + 3 + butterflies += WEAKREF(butterfly) + addtimer(CALLBACK(butterfly, TYPE_PROC_REF(/mob/living/basic/butterfly/lavaland/temporary, fadeout)), rand(60, 120) SECONDS, TIMER_DELETE_ME) + +/// A green shimmer over a tended tray. +/obj/effect/temp_visual/artifact_gaia_bloom + icon = 'icons/obj/service/hydroponics/equipment.dmi' + icon_state = "gaia_blessing" + duration = 1.5 SECONDS + +/obj/effect/temp_visual/artifact_gaia_bloom/Initialize(mapload) + . = ..() + alpha = 0 + animate(src, alpha = 255, time = 0.4 SECONDS) + animate(alpha = 0, time = 1.1 SECONDS) + +/// A soft green ring for the burst. +/obj/effect/temp_visual/circle_wave/artifact_mist/gaia + color = "#8cd448" + max_alpha = 140 + +#undef ARTIFACT_GAIA_MAX_BUTTERFLIES diff --git a/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm b/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm index 5cf9fe346fc..cd95625b693 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm @@ -1 +1,122 @@ // Artifact effect: gravity wave. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/gravitational_waves.dm, +// also in VOREStation (9335d66b9fdf). Voidcrew: one huge knockback ring instead of Polaris' +// singularity pull. It draws the air in for a moment first (a shimmer closing on it, a rising +// hum), so anyone watching has time to brace or back off; then everything loose in reach is +// flung outward and people are knocked down, hardest near the middle. Anchored things stay put. +// Smashing it lets the wave go at once. Not harvestable; logged. + +/// How long it gathers before the wave +#define ARTIFACT_WAVE_WINDUP (1.5 SECONDS) +/// Furthest it ever reaches +#define ARTIFACT_WAVE_MAX_RANGE 7 + +/datum/artifact_effect/gravity_wave + label_name = "Shockwave" + log_name = "Gravity wave" + tier = ARTIFACT_TIER_EXTREME + harvestable = FALSE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_BLUESPACE, ARTIFACT_ENERGY_CONCENTRATED, ARTIFACT_ENERGY_PSIONIC) + allowed_release_methods = list(ARTIFACT_RELEASE_TOUCH, ARTIFACT_RELEASE_PULSE) + /// A wave is gathering + var/gathering = FALSE + +/datum/artifact_effect/gravity_wave/setup() + range = clamp(range, 3, ARTIFACT_WAVE_MAX_RANGE) + // A wave needs a good part of the store; a hand can't set one off every few seconds. + touch_cost = max(touch_cost, round(maximum_charges / 2)) + +/datum/artifact_effect/gravity_wave/do_effect_touch(mob/living/user) + to_chat(user, span_warning("[holder] hums under your hand, louder and louder.")) + gather() + +/datum/artifact_effect/gravity_wave/do_effect_pulse(power) + gather() + +/datum/artifact_effect/gravity_wave/do_effect_destroy() + release_wave() + +/// The warning: the air draws in toward it, then the wave goes. +/datum/artifact_effect/gravity_wave/proc/gather() + var/turf/center = source_turf() + if(gathering || !center) + return FALSE + gathering = TRUE + center.visible_message(span_danger("The air around [holder] bends and draws in toward it.")) + playsound(center, 'sound/effects/magic/cosmic_expansion.ogg', 50, TRUE) + new /obj/effect/temp_visual/artifact_wave_gather(center, range) + addtimer(CALLBACK(src, PROC_REF(release_wave)), ARTIFACT_WAVE_WINDUP, TIMER_DELETE_ME) + return TRUE + +/// Flings everything loose in reach outward. Does nothing if it was crated or put to sleep meanwhile. +/datum/artifact_effect/gravity_wave/proc/release_wave() + gathering = FALSE + var/obj/machinery/artifact/artifact = holder + if(istype(artifact) && artifact.suppressed) + return + var/turf/center = source_turf() + if(!center) + return + new /obj/effect/temp_visual/circle_wave/artifact_mist/gravity_wave(center, range) + playsound(center, 'sound/effects/gravhit.ogg', 70, TRUE, extrarange = 4) + playsound(center, 'sound/effects/magic/repulse.ogg', 60, TRUE) + for(var/atom/movable/thing in range(range, center)) + if(thing == holder || thing == get_atom_on_turf(holder) || !can_fling(thing)) + continue + fling(thing, center) + +/// Loose items, unanchored structures and the living; nothing anchored or immovable. +/datum/artifact_effect/gravity_wave/proc/can_fling(atom/movable/thing) + if(thing.anchored || thing.move_resist > MOVE_FORCE_STRONG || !isturf(thing.loc)) + return FALSE + if(isliving(thing)) + var/mob/living/victim = thing + return !victim.buckled + return isitem(thing) || isstructure(thing) + +/// Throws `thing` directly away from `center`, harder the closer it stood. +/datum/artifact_effect/gravity_wave/proc/fling(atom/movable/thing, turf/center) + var/distance = get_dist(center, thing) + var/exposure = isliving(thing) ? artifact_exposure(thing) : 1 + var/throw_range = round(clamp(range - distance + 2, 2, 6) * exposure) + if(throw_range < 1) + return + var/direction = (distance > 0) ? get_dir(center, thing) : pick(GLOB.alldirs) + var/turf/landing = get_ranged_target_turf(thing, direction, throw_range) + if(!landing) + return + if(isliving(thing)) + var/mob/living/victim = thing + shake_camera(victim, 3, 2) + victim.Knockdown(2 SECONDS * exposure) + to_chat(victim, span_userdanger("A wall of force hits you and lifts you off your feet!")) + log_harm(victim) + // Positional: some throw_at() overrides rename their arguments. + thing.throw_at(landing, throw_range, 2) + +/// A shimmer closing in on the artifact before the wave. +/obj/effect/temp_visual/artifact_wave_gather + icon = 'icons/effects/64x64.dmi' + icon_state = "circle_wave" + pixel_x = -16 + pixel_y = -16 + color = "#d8c3ff" + alpha = 0 + duration = ARTIFACT_WAVE_WINDUP + +/obj/effect/temp_visual/artifact_wave_gather/Initialize(mapload, reach = 4) + . = ..() + transform = matrix().Scale(max(reach, 1)) + animate(src, transform = matrix().Scale(0.1), time = duration, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + animate(src, alpha = 160, time = duration * 0.5, flags = ANIMATION_PARALLEL) + animate(alpha = 220, time = duration * 0.5) + +/// The wave itself. +/obj/effect/temp_visual/circle_wave/artifact_mist/gravity_wave + color = "#d8c3ff" + max_alpha = 230 + duration = 0.6 SECONDS + +#undef ARTIFACT_WAVE_WINDUP +#undef ARTIFACT_WAVE_MAX_RANGE diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm index 6fc56609d67..c23a4099252 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm @@ -1 +1,102 @@ // Artifact effect: life drain. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/lifedrain.dm. Voidcrew: +// a sickly beam hooks into someone nearby; if they are still in reach a few seconds later, it +// tears a little life out of them (bruising, breathlessness and a spell of weakness). Stepping out +// of reach snaps the beam, so it can always be escaped. One beam per person at a time, people in +// a good biosuit are ignored (as in Polaris), and every drain is logged. Polaris healed the +// toucher with what it took; that is the extreme Life steal effect, so this only takes. + +/// Seconds between the hook and the drain +#define ARTIFACT_DRAIN_WINDUP (3 SECONDS) + +/datum/artifact_effect/life_drain + label_name = "Drains life" + log_name = "Life drain" + tier = ARTIFACT_TIER_RARE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC, ARTIFACT_ENERGY_CONCENTRATED) + /// REF(victim) -> TRUE while a beam is hooked into them + var/list/hooked = list() + +/datum/artifact_effect/life_drain/Destroy(force) + hooked = null + return ..() + +/datum/artifact_effect/life_drain/do_effect_touch(mob/living/user) + hook(user, 10) + +/datum/artifact_effect/life_drain/do_effect_aura(seconds_per_tick) + for(var/mob/living/target in living_in_range()) + if(SPT_PROB(6, seconds_per_tick)) + hook(target, 5) + +/datum/artifact_effect/life_drain/do_effect_pulse(power) + var/list/victims = living_in_range() + for(var/i in 1 to 2) + if(!length(victims)) + return + hook(pick_n_take(victims), clamp(power * 0.3, 4, 12)) + +/// Smashed, it lashes everyone in reach at once, with no warning. +/datum/artifact_effect/life_drain/do_effect_destroy() + for(var/mob/living/target in living_in_range()) + if(can_hook(target)) + siphon(target, 6) + +/// Whether `target` is somebody it can feed on. +/datum/artifact_effect/life_drain/proc/can_hook(mob/living/target) + if(target.stat == DEAD || hooked?[REF(target)]) + return FALSE + if(target == get_atom_on_turf(holder)) + return FALSE + return artifact_exposure(target) >= 0.5 + +/// Hooks a beam into `target`; the drain lands after the windup if they stay. Returns TRUE if it hooked. +/datum/artifact_effect/life_drain/proc/hook(mob/living/target, damage) + if(!can_hook(target)) + return FALSE + var/atom/origin = get_atom_on_turf(holder) + if(!origin) + return FALSE + var/datum/beam/drain_beam = origin.Beam(target, icon_state = "drain_life", time = ARTIFACT_DRAIN_WINDUP + 0.5 SECONDS, maxdistance = range + 1) + var/key = REF(target) + hooked[key] = TRUE + target.visible_message( + span_danger("A sickly beam lashes out from [holder] and hooks into [target]!"), + span_userdanger("A sickly beam lashes out from [holder] and hooks into you. Something cold starts to pull."), + ) + playsound(target, 'sound/effects/magic/enter_blood.ogg', 35, TRUE) + addtimer(CALLBACK(src, PROC_REF(finish_hook), WEAKREF(target), WEAKREF(drain_beam), key, damage), ARTIFACT_DRAIN_WINDUP, TIMER_DELETE_ME) + return TRUE + +/// The windup ended: drains `target` if the beam still holds them. +/datum/artifact_effect/life_drain/proc/finish_hook(datum/weakref/target_ref, datum/weakref/beam_ref, key, damage) + hooked -= key + var/mob/living/target = target_ref?.resolve() + var/datum/beam/drain_beam = beam_ref?.resolve() + if(!target || target.stat == DEAD) + qdel(drain_beam) + return + if(QDELETED(drain_beam)) + to_chat(target, span_notice("The cold hook slips out of you.")) + return + qdel(drain_beam) + siphon(target, damage) + +/// Tears `damage` worth of life out of `target`. +/datum/artifact_effect/life_drain/proc/siphon(mob/living/target, damage) + damage *= artifact_exposure(target) + if(damage <= 0) + return + target.take_overall_damage(damage) + target.adjustStaminaLoss(damage * 2) + target.apply_status_effect(/datum/status_effect/artifact/exhausted, 10 SECONDS) + new /obj/effect/temp_visual/heal(get_turf(target), "#7a1d3a") + target.visible_message( + span_danger("[holder] pulls something pale and shining out of [target]."), + span_userdanger("Something is torn out of you. Your legs go weak."), + ) + playsound(target, 'sound/effects/magic/exit_blood.ogg', 40, TRUE) + log_harm(target) + +#undef ARTIFACT_DRAIN_WINDUP diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm b/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm index 9af86bcbc54..70d0b1ff0cc 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm @@ -1 +1,82 @@ // Artifact effect: life steal. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/lifesteal.dm. +// Voidcrew: heals one person by taking the same amount from everyone else in reach, split between +// them. It only takes what it gives, so nothing happens unless the person it feeds is hurt, and +// what it gives goes through the shared artifact healing cap: it is a slow trickle, never a +// medbay and never a weapon. Who it feeds: the hand on it, the person carrying it, or for a burst +// the nearest hurt person. Every theft is logged. + +/datum/artifact_effect/life_steal + label_name = "Steals life" + log_name = "Life steal" + tier = ARTIFACT_TIER_EXTREME + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC, ARTIFACT_ENERGY_CONCENTRATED) + allowed_release_methods = list(ARTIFACT_RELEASE_TOUCH, ARTIFACT_RELEASE_PULSE) + +/datum/artifact_effect/life_steal/do_effect_touch(mob/living/user) + if(!steal_for(user, 10)) + to_chat(user, span_notice("Something in [holder] reaches for you, then lets go.")) + +/datum/artifact_effect/life_steal/do_effect_pulse(power) + steal_for(pick_beneficiary(), clamp(power * 0.4, 5, 15)) + +/// Whoever a burst feeds: the person carrying it, else the nearest hurt person in reach. +/datum/artifact_effect/life_steal/proc/pick_beneficiary() + var/atom/carrier = get_atom_on_turf(holder) + if(isliving(carrier)) + return carrier + var/turf/center = source_turf() + if(!center) + return null + var/mob/living/best + var/best_distance = INFINITY + for(var/mob/living/candidate in living_in_range()) + if(candidate.stat == DEAD || candidate.getBruteLoss() + candidate.getFireLoss() <= 0) + continue + var/distance = get_dist(center, candidate) + if(distance < best_distance) + best = candidate + best_distance = distance + return best + +/** + * Heals `beneficiary` by up to `amount`, taken from everyone else in reach. + * Returns the amount healed. + */ +/datum/artifact_effect/life_steal/proc/steal_for(mob/living/beneficiary, amount) + if(!beneficiary || beneficiary.stat == DEAD) + return 0 + var/wanted = min(amount, beneficiary.getBruteLoss() + beneficiary.getFireLoss(), artifact_heal_allowance(beneficiary)) + if(wanted <= 0) + return 0 + var/list/victims = list() + for(var/mob/living/victim in living_in_range()) + if(victim == beneficiary || victim.stat == DEAD || artifact_exposure(victim) <= 0) + continue + victims += victim + if(!length(victims)) + return 0 + // What each victim can give: an even share, less for those in protective gear. + var/list/shares = list() + var/offered = 0 + for(var/mob/living/victim as anything in victims) + var/share = wanted / length(victims) * artifact_exposure(victim) + shares[victim] = share + offered += share + if(offered <= 0) + return 0 + var/healed = artifact_capped_heal(beneficiary, offered) + if(healed <= 0) + return 0 + // Everyone gives up exactly their part of what was healed. + var/scale = healed / offered + for(var/mob/living/victim as anything in shares) + victim.take_overall_damage(shares[victim] * scale) + victim.Beam(beneficiary, icon_state = "lichbeam", time = 1.5 SECONDS) + to_chat(victim, span_danger("Warmth bleeds out of you and runs toward [beneficiary].")) + log_harm(victim) + new /obj/effect/temp_visual/heal(get_turf(beneficiary), "#b0306a") + to_chat(beneficiary, span_notice("Stolen warmth floods into you, and your wounds pull shut.")) + playsound(beneficiary, 'sound/effects/magic/exit_blood.ogg', 35, TRUE) + return healed diff --git a/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm b/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm index 6a82f0b756b..302bab411dd 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm @@ -1 +1,116 @@ // Artifact effect: plasma vent. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/gasphoron.dm. +// Voidcrew: vents plasma (Polaris' "phoron") indoors only: never into planet air, never into space. +// Each vent stops at a modest pressure on its tile, and holds a finite reserve, less than one gas +// contract asks for, so it can never become a free plasma well. Plasma air never wakes it, or it +// would feed itself. Everyone breathing near it is logged. Not harvestable. +// +// Also the shared parent for gas vents with a reserve (sleeping gas uses it). + +/datum/artifact_effect/gas_vent + harvestable = FALSE + harmful = TRUE + /// The /datum/gas it vents + var/gas_type + /// Gas puff colour + var/puff_color = "#ffffff" + /// Moles left in it; it runs dry for good when this hits zero + var/reserve = 0 + var/reserve_low = 100 + var/reserve_high = 200 + /// It stops venting once its tile reaches this pressure (kPa) + var/max_pressure = ONE_ATMOSPHERE + /// Said it was dry + var/announced_dry = FALSE + +/datum/artifact_effect/gas_vent/setup() + reserve = rand(reserve_low, reserve_high) + max_pressure = rand(110, 140) + +/datum/artifact_effect/gas_vent/copy_settings_to(datum/artifact_effect/gas_vent/copy) + copy.reserve = reserve + copy.max_pressure = max_pressure + +/datum/artifact_effect/gas_vent/do_effect_touch(mob/living/user) + if(vent(rand(3, 8))) + to_chat(user, span_warning("Gas hisses out of [holder] around your hand.")) + +/datum/artifact_effect/gas_vent/do_effect_aura(seconds_per_tick) + if(SPT_PROB(50, seconds_per_tick)) + vent(rand(1, 3)) + +/datum/artifact_effect/gas_vent/do_effect_pulse(power) + vent(clamp(power / 2, 3, 20)) + +/** + * Lets up to `moles` of its gas out onto its tile, from its reserve. Indoors only: never a planet + * turf (whose air is shared), never space. Returns the moles that came out. + */ +/datum/artifact_effect/gas_vent/proc/vent(moles) + if(moles <= 0 || !gas_type) + return 0 + var/turf/open/here = source_turf() + if(!istype(here) || here.planetary_atmos || isspaceturf(here)) + return 0 + if(reserve <= 0) + run_dry(here) + return 0 + var/datum/gas_mixture/air = here.return_air() + if(!air || air.return_pressure() >= max_pressure) + return 0 + moles = min(moles, reserve) + reserve -= moles + var/datum/gas_mixture/puff = new + puff.assert_gas(gas_type) + puff.gases[gas_type][MOLES] = moles + puff.temperature = T20C + here.assume_air(puff) + var/obj/effect/temp_visual/artifact_gas_puff/cloud = new(here) + cloud.color = puff_color + playsound(here, 'sound/effects/spray2.ogg', 20, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + for(var/mob/living/breather in living_in_range()) + log_harm(breather) + return moles + +/// Its reserve is spent: one last wheeze, then nothing ever again. +/datum/artifact_effect/gas_vent/proc/run_dry(turf/here) + if(announced_dry) + return + announced_dry = TRUE + here.visible_message(span_notice("[holder] wheezes, and nothing more comes out.")) + playsound(here, 'sound/effects/spray3.ogg', 25, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) + +/datum/artifact_effect/gas_vent/plasma + label_name = "Vents plasma" + log_name = "Plasma vent" + tier = ARTIFACT_TIER_UNCOMMON + possible_kinds = list(ARTIFACT_ENERGY_BLUESPACE, ARTIFACT_ENERGY_SYNTHESIS) + // Every trigger but plasma air, which it would make itself. + allowed_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_CO2, + ARTIFACT_TRIGGER_PROXIMITY, + ) + gas_type = /datum/gas/plasma + puff_color = "#c95ad8" + reserve_low = 150 + reserve_high = 300 + +/// A small cloud of vented gas. +/obj/effect/temp_visual/artifact_gas_puff + icon_state = "smoke" + alpha = 120 + duration = 1.2 SECONDS + +/obj/effect/temp_visual/artifact_gas_puff/Initialize(mapload) + . = ..() + pixel_x = rand(-4, 4) + animate(src, alpha = 0, pixel_y = 10, time = duration) diff --git a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm index cb67d56ee74..af1c9c251c9 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm @@ -1 +1,106 @@ // Artifact effect: poltergeist. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/poltergeist.dm, also in +// VOREStation (9335d66b9fdf). Voidcrew: picks a loose object lying near the artifact, lifts it for a +// moment (time to duck) and hurls it at the nearest person. Only small and medium items lying on +// the floor fly; nothing is pulled from hands, bags or tables' contents, and a thrown object only +// hits as hard as its own weight allows. Logged like a weapon. + +/// How long an object hangs in the air before it flies +#define ARTIFACT_POLTERGEIST_WINDUP (0.8 SECONDS) + +/datum/artifact_effect/poltergeist + label_name = "Throws things" + log_name = "Poltergeist" + tier = ARTIFACT_TIER_RARE + harmful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_BLUESPACE) + +/datum/artifact_effect/poltergeist/do_effect_touch(mob/living/user) + lift_and_throw(user) + +/datum/artifact_effect/poltergeist/do_effect_aura(seconds_per_tick) + if(SPT_PROB(15, seconds_per_tick)) + lift_and_throw(nearest_target()) + +/datum/artifact_effect/poltergeist/do_effect_pulse(power) + var/mob/living/target = nearest_target() + if(!target) + return + var/throws = power >= 30 ? 2 : 1 + for(var/i in 1 to throws) + lift_and_throw(target) + +/// Smashed, it flings what is near it at once, with no warning. +/datum/artifact_effect/poltergeist/do_effect_destroy() + var/mob/living/target = nearest_target() + var/obj/item/missile = find_missile(target) + if(target && missile) + hurl(WEAKREF(missile), WEAKREF(target)) + +/// The closest conscious mob in range, preferring people with a mind over animals. +/datum/artifact_effect/poltergeist/proc/nearest_target() + var/turf/center = source_turf() + if(!center) + return null + var/mob/living/best + var/best_distance = INFINITY + var/best_has_mind = FALSE + for(var/mob/living/candidate in living_in_range()) + if(candidate.stat != CONSCIOUS || candidate == get_atom_on_turf(holder)) + continue + var/has_mind = !!candidate.mind + var/distance = get_dist(center, candidate) + if(best_has_mind && !has_mind) + continue + if((has_mind && !best_has_mind) || distance < best_distance) + best = candidate + best_distance = distance + best_has_mind = has_mind + return best + +/// A loose item on the floor within its reach, not right under `target`'s feet. +/datum/artifact_effect/poltergeist/proc/find_missile(mob/living/target) + var/turf/center = source_turf() + if(!center || !target) + return null + var/list/candidates = list() + for(var/obj/item/thing in range(range, center)) + if(!isturf(thing.loc) || thing.anchored || thing.w_class > WEIGHT_CLASS_BULKY) + continue + if((thing.item_flags & ABSTRACT) || HAS_TRAIT(thing, TRAIT_NODROP) || thing.throwforce <= 0) + continue + if(get_dist(thing, target) < 2) + continue + candidates += thing + return length(candidates) ? pick(candidates) : null + +/// Lifts something toward `target`; it flies after a short windup. Returns TRUE if it lifted one. +/datum/artifact_effect/poltergeist/proc/lift_and_throw(mob/living/target) + if(!target || target.stat == DEAD || !prob(100 * artifact_exposure(target))) + return FALSE + var/obj/item/missile = find_missile(target) + if(!missile) + return FALSE + missile.visible_message(span_warning("[missile] lifts off the floor and turns toward [target]!")) + playsound(missile, 'sound/effects/magic/summonitems_generic.ogg', 30, TRUE) + animate(missile, pixel_z = 10, time = ARTIFACT_POLTERGEIST_WINDUP, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + var/atom/origin = get_atom_on_turf(holder) + origin?.Beam(missile, icon_state = "kinesis", time = ARTIFACT_POLTERGEIST_WINDUP) + addtimer(CALLBACK(src, PROC_REF(hurl), WEAKREF(missile), WEAKREF(target)), ARTIFACT_POLTERGEIST_WINDUP, TIMER_DELETE_ME) + return TRUE + +/// Throws the lifted item, if it is still lying where it was lifted and the target still stands. +/datum/artifact_effect/poltergeist/proc/hurl(datum/weakref/missile_ref, datum/weakref/target_ref) + var/obj/item/missile = missile_ref?.resolve() + var/mob/living/target = target_ref?.resolve() + if(!missile) + return + animate(missile, pixel_z = 0, time = 0.1 SECONDS, flags = ANIMATION_PARALLEL) + if(!isturf(missile.loc) || !target || target.stat == DEAD || missile.z != target.z) + return + playsound(missile, 'sound/effects/magic/repulse.ogg', 30, TRUE) + // Positional: some item overrides of throw_at() rename their arguments. + missile.throw_at(target, range * 2, 3) + log_harm(target) + +#undef ARTIFACT_POLTERGEIST_WINDUP diff --git a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm index d61062271d4..a2e7887495f 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm @@ -1 +1,191 @@ // Artifact effect: resurrect. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/resurrect.dm and the +// VOREStation (9335d66b9fdf) "vitality swap" version. Voidcrew (owner decision 4): brings back one +// person lying beside it who died less than five minutes ago, three times in all, then it cracks +// and goes dull for good. It is never a free revive loop: +// - three lives per artifact, stored on the effect; not harvestable, and artifacts are never +// saved with a ship, so the count can't be copied or reset; +// - only a body whose own player is still there to come back: a ghost that can re-enter, or a +// player still in the body. Do-not-resuscitate, logged-out players, and a mind that already moved +// on (a clone, a new body) are refused; +// - the same checks as a defibrillator: suicides, husks, failing or missing hearts and brains, and +// bodies too broken to hold life; +// - its healing goes through the shared artifact cap (30 brute and burn); it clears the choking +// and some poison, but a body torn apart past that stays dead and costs no use. +// Polaris drained the living nearby to fuel it; that is dropped. The price is the use, and the +// person comes back weak. Every revival is logged. + +/// Lives it can give back before it is spent +#define ARTIFACT_RESURRECT_USES 3 +/// How long after death a body can still be raised +#define ARTIFACT_RESURRECT_WINDOW (5 MINUTES) +/// How long the light works on a body before it wakes +#define ARTIFACT_RESURRECT_RITE (4 SECONDS) +/// Poison it clears from a body +#define ARTIFACT_RESURRECT_TOX_MEND 40 + +/datum/artifact_effect/resurrect + label_name = "Raises the dead" + log_name = "Resurrect" + tier = ARTIFACT_TIER_EXTREME + harvestable = FALSE + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC) + /// Lives it can still give back. At zero it is spent for good. + var/uses_left = ARTIFACT_RESURRECT_USES + /// The body it is working on, if any + var/datum/weakref/working_on + /// REF(body) -> world.time it last showed it could not help them + var/list/refused_at + +/datum/artifact_effect/resurrect/Destroy(force) + working_on = null + refused_at = null + return ..() + +/datum/artifact_effect/resurrect/copy_settings_to(datum/artifact_effect/resurrect/copy) + copy.uses_left = uses_left + +/datum/artifact_effect/resurrect/do_effect_touch(mob/living/user) + seek() + +/datum/artifact_effect/resurrect/do_effect_aura(seconds_per_tick) + seek() + +/datum/artifact_effect/resurrect/do_effect_pulse(power) + seek() + +/// Spent, it only gives the faintest light when woken. +/datum/artifact_effect/resurrect/light_override() + if(uses_left <= 0) + return list(1, 0.3, "#5a1a1a") + return null + +/// Looks for a body laid beside it and starts on the first one that can come back. +/datum/artifact_effect/resurrect/proc/seek() + if(uses_left <= 0 || working_on?.resolve()) + return + var/turf/center = source_turf() + if(!center) + return + for(var/mob/living/carbon/human/body in range(1, center)) + if(body.stat != DEAD) + continue + var/refusal = raise_refusal(body) + if(refusal) + show_refusal(body, refusal) + continue + begin_rite(body) + return + +/** + * Why `body` can't be raised, as a short reason, or null if it can. + * "spent", "not here", "too late", "gone" (nobody to come back) or "ruined" (the body can't hold life). + */ +/datum/artifact_effect/resurrect/proc/raise_refusal(mob/living/carbon/human/body) + if(uses_left <= 0) + return "spent" + if(!istype(body) || QDELETED(body) || body.stat != DEAD || !isturf(body.loc)) + return "not here" + if(world.time - body.timeofdeath > ARTIFACT_RESURRECT_WINDOW) + return "too late" + if(!body.mind || body.mind.current != body) + return "gone" + if(body.can_defib() != DEFIB_POSSIBLE) + return "ruined" + if(projected_health(body) <= HEALTH_THRESHOLD_DEAD) + return "ruined" + if(!body.client && !body.get_ghost(FALSE, TRUE)) + return "gone" + return null + +/// What `body`'s health would be after the mending it gives. +/datum/artifact_effect/resurrect/proc/projected_health(mob/living/carbon/human/body) + var/mended = min(artifact_heal_allowance(body), body.getBruteLoss() + body.getFireLoss()) + mended += body.getOxyLoss() + min(body.getToxLoss(), ARTIFACT_RESURRECT_TOX_MEND) + return body.health + mended + +/// Shows, now and then, that it reached for a body and could not take hold. +/datum/artifact_effect/resurrect/proc/show_refusal(mob/living/carbon/human/body, refusal) + if(refusal == "spent" || refusal == "not here") + return + var/key = REF(body) + if(LAZYACCESS(refused_at, key) > world.time - 30 SECONDS) + return + LAZYSET(refused_at, key, world.time) + if(refusal == "gone") + body.visible_message(span_notice("A thread of red light reaches from [holder] to [body], finds no one there, and fades.")) + else + body.visible_message(span_notice("A thread of red light touches [body], slides off, and fades.")) + +/// The light goes to work on `body`; it wakes after the rite if nothing has changed. +/datum/artifact_effect/resurrect/proc/begin_rite(mob/living/carbon/human/body) + working_on = WEAKREF(body) + var/atom/origin = get_atom_on_turf(holder) + origin?.Beam(body, icon_state = "lichbeam", time = ARTIFACT_RESURRECT_RITE) + body.visible_message(span_warning("Threads of red light reach out of [holder] and sink into [body]. The body twitches.")) + playsound(body, 'sound/effects/magic/enter_blood.ogg', 45, TRUE) + playsound(body, 'sound/effects/singlebeat.ogg', 60, TRUE) + body.do_jitter_animation(100) + body.notify_revival("Something is dragging you back toward your body!", source = holder) + addtimer(CALLBACK(src, PROC_REF(finish_rite), WEAKREF(body)), ARTIFACT_RESURRECT_RITE, TIMER_DELETE_ME) + +/// The rite ends: raises the body if it still can be. +/datum/artifact_effect/resurrect/proc/finish_rite(datum/weakref/body_ref) + working_on = null + var/mob/living/carbon/human/body = body_ref?.resolve() + if(!body) + return + var/obj/machinery/artifact/artifact = holder + if((istype(artifact) && artifact.suppressed) || get_dist(source_turf(), body) > 1 || raise_refusal(body)) + body.visible_message(span_notice("The red light drains out of [body] and is gone.")) + return + raise(body) + +/** + * Brings `body` back to life and spends one use. Returns TRUE if they live. + * Checks nothing about whether they should: raise_refusal() does that. + */ +/datum/artifact_effect/resurrect/proc/raise(mob/living/carbon/human/body) + if(uses_left <= 0 || QDELETED(body) || body.stat != DEAD) + return FALSE + artifact_capped_heal(body, artifact_heal_allowance(body)) + body.adjustOxyLoss(-body.getOxyLoss(), updating_health = FALSE) + body.adjustToxLoss(-min(body.getToxLoss(), ARTIFACT_RESURRECT_TOX_MEND), updating_health = FALSE, forced = TRUE) + body.updatehealth() + body.set_heartattack(FALSE) + if(body.health <= HEALTH_THRESHOLD_DEAD) + return FALSE + body.grab_ghost() + if(!body.revive_from("artifact")) + return FALSE + uses_left-- + body.emote("gasp") + body.set_jitter_if_lower(20 SECONDS) + body.apply_status_effect(/datum/status_effect/artifact/exhausted, 60 SECONDS) + body.visible_message( + span_warning("[body]'s eyes snap open in a flash of red light!"), + span_userdanger("You are dragged back into your body, gasping. Everything aches."), + ) + playsound(body, 'sound/effects/magic/staff_healing.ogg', 50, TRUE) + var/mob/responsible = get_responsible() + log_combat(responsible || holder, body, "revived", holder, "with [log_name] ([uses_left] uses left)") + if(uses_left <= 0) + go_dull() + return TRUE + +/// The last life is given: it cracks and the light inside goes out. +/datum/artifact_effect/resurrect/proc/go_dull() + var/turf/here = source_turf() + if(!here) + return + here.visible_message(span_warning("[holder] cracks with a sound like a knuckle, and the light deep inside it goes out.")) + playsound(here, 'sound/effects/wounds/crack1.ogg', 50, TRUE) + var/obj/machinery/artifact/artifact = holder + if(istype(artifact)) + artifact.update_activity_visuals() + +#undef ARTIFACT_RESURRECT_USES +#undef ARTIFACT_RESURRECT_WINDOW +#undef ARTIFACT_RESURRECT_RITE +#undef ARTIFACT_RESURRECT_TOX_MEND diff --git a/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm b/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm index 7278bea4aa6..66b00d1fd5b 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/sleeping_gas.dm @@ -1 +1,15 @@ // Artifact effect: sleeping gas. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/gassleeping.dm. +// Voidcrew: vents nitrous oxide indoors only, from a finite reserve, and stops at a modest +// pressure on its tile (see the gas_vent parent in plasma_vent.dm). Everyone breathing near it is +// logged. Not harvestable. + +/datum/artifact_effect/gas_vent/sleeping + label_name = "Vents sleeping gas" + log_name = "Sleeping gas" + tier = ARTIFACT_TIER_RARE + possible_kinds = list(ARTIFACT_ENERGY_BLUESPACE, ARTIFACT_ENERGY_SYNTHESIS) + gas_type = /datum/gas/nitrous_oxide + puff_color = "#f2f2ff" + reserve_low = 100 + reserve_high = 200 diff --git a/voidcrew/modules/xenoarch/effects_polaris/spines.dm b/voidcrew/modules/xenoarch/effects_polaris/spines.dm index d391810f000..78fd5cf321e 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/spines.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/spines.dm @@ -1 +1,99 @@ // Artifact effect: spines. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/indiscriminate_spines.dm. +// Voidcrew: it bristles for a second (a rattle and a visible shiver, time to duck behind cover), +// then looses a ring of bone spines evenly around itself, eight or twelve by strength. A hand on +// it is spared: no spine flies toward the person touching it, as in Polaris. Half as likely in the +// green zone (design catalogue). Polaris' spore variant is not ported. Not harvestable; logged. + +/// How long it bristles before it fires +#define ARTIFACT_SPINES_WINDUP (1 SECONDS) + +/datum/artifact_effect/spines + label_name = "Fires spines" + log_name = "Spines" + tier = ARTIFACT_TIER_EXTREME + harvestable = FALSE + harmful = TRUE + green_weight = 0.5 + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC) + allowed_release_methods = list(ARTIFACT_RELEASE_TOUCH, ARTIFACT_RELEASE_PULSE) + /// Spines in one volley + var/volley = 8 + /// A volley is being readied + var/bristling = FALSE + +/datum/artifact_effect/spines/setup() + volley = (strength == ARTIFACT_STRENGTH_LARGE) ? 12 : 8 + // A volley needs a good part of the store; a hand can't set one off every few seconds. + touch_cost = max(touch_cost, round(maximum_charges / 2)) + +/datum/artifact_effect/spines/copy_settings_to(datum/artifact_effect/spines/copy) + copy.volley = volley + +/datum/artifact_effect/spines/do_effect_touch(mob/living/user) + bristle(user) + +/datum/artifact_effect/spines/do_effect_pulse(power) + bristle() + +/datum/artifact_effect/spines/do_effect_destroy() + fire_volley() + +/// The warning: it shivers and rattles, then fires. `spared` is the person touching it. +/datum/artifact_effect/spines/proc/bristle(mob/living/spared) + var/turf/center = source_turf() + if(bristling || !center) + return FALSE + bristling = TRUE + center.visible_message(span_danger("[holder] bristles with sharp bone!")) + playsound(center, 'sound/effects/blob/blobattack.ogg', 50, TRUE) + var/atom/movable/shaker = get_atom_on_turf(holder) + if(shaker) + shaker.Shake(1, 1, ARTIFACT_SPINES_WINDUP) + addtimer(CALLBACK(src, PROC_REF(fire_volley), spared ? WEAKREF(spared) : null), ARTIFACT_SPINES_WINDUP, TIMER_DELETE_ME) + return TRUE + +/// Looses one ring of spines. Skips the direction of whoever `spared_ref` points to. +/datum/artifact_effect/spines/proc/fire_volley(datum/weakref/spared_ref) + bristling = FALSE + var/obj/machinery/artifact/artifact = holder + if(istype(artifact) && artifact.suppressed) + return 0 + var/turf/center = source_turf() + if(!center) + return 0 + var/mob/living/spared = spared_ref?.resolve() + var/spared_angle = (spared && get_dist(center, spared) <= 1 && spared.loc != center) ? get_angle(center, spared) : null + var/atom/movable/launcher = get_atom_on_turf(holder) + var/step = 360 / volley + var/offset = rand(0, step - 1) + var/fired = 0 + for(var/i in 0 to volley - 1) + var/angle = (offset + i * step) % 360 + if(!isnull(spared_angle) && abs(closer_angle_difference(angle, spared_angle)) < step * 0.75) + continue + var/obj/projectile/artifact_spine/spine = new(center) + spine.firer = launcher + spine.fired_from = launcher + spine.range = range + 2 + spine.fire(angle) + fired++ + playsound(center, 'sound/items/weapons/pierce.ogg', 60, TRUE) + for(var/mob/living/target in living_in_range(2)) + if(target != spared) + log_harm(target) + return fired + +/// A bone spine. +/obj/projectile/artifact_spine + name = "bone spine" + icon_state = "magspear" + color = "#efe2c4" + damage = 8 + damage_type = BRUTE + armor_flag = BULLET + sharpness = SHARP_POINTY + hit_prone_targets = TRUE + hitsound = 'sound/items/weapons/pierce.ogg' + +#undef ARTIFACT_SPINES_WINDUP diff --git a/voidcrew/modules/xenoarch/effects_polaris/sprint.dm b/voidcrew/modules/xenoarch/effects_polaris/sprint.dm index 541f6ed659d..efac54d7df7 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/sprint.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/sprint.dm @@ -1 +1,25 @@ // Artifact effect: sprint. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/sprinting.dm. +// Voidcrew: uncommon, not common (design catalogue); a short movement-speed status effect with a +// fading afterimage, instead of Polaris' /datum/modifier; doses never stack past a minute. + +/datum/artifact_effect/bestow/sprint + label_name = "Speeds people up" + log_name = "Sprint" + tier = ARTIFACT_TIER_UNCOMMON + helpful = TRUE + possible_kinds = list(ARTIFACT_ENERGY_CONCENTRATED, ARTIFACT_ENERGY_PSIONIC) + status_type = /datum/status_effect/artifact/sprint + touch_duration = 45 SECONDS + pulse_duration = 25 SECONDS + aura_duration = 8 SECONDS + +/datum/artifact_effect/bestow/sprint/on_bestowed(mob/living/target) + if(issilicon(target)) + to_chat(target, span_notice("Your actuators whine up to a higher pitch.")) + else + to_chat(target, span_notice("Your legs fill with a restless spring.")) + playsound(target, 'sound/effects/magic/tail_swing.ogg', 20, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/artifact_effect/bestow/sprint/on_renewed(mob/living/target) + to_chat(target, span_notice("Your legs itch to run.")) diff --git a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm index d40b809507a..b451b89d0ed 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm @@ -1 +1,308 @@ // Status effects applied by artifact effects. +// +// Rewritten for tg from the Polaris (6cd19fe0dec6) and VOREStation (9335d66b9fdf) /datum/modifier +// types the xenoarchaeology effects used (fortify, faraday, sprinting, berserk and its exhaustion, +// feysight). tg has no modifiers, so each is a status effect here. +// +// None of them stack. A fresh dose only tops the time up to that dose's own length, never past +// max_duration, so standing in an aura keeps a buff going but never banks a long one. +// Also here: the shared parent for effects that hand one of these out. + +/// Artifact status effects: refresh to the longer of what is left and the new dose. +/datum/status_effect/artifact + id = "artifact" + status_type = STATUS_EFFECT_REFRESH + duration = 30 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + alert_type = null + /// Longest one dose can last, in deciseconds + var/max_duration = 2 MINUTES + +/datum/status_effect/artifact/on_creation(mob/living/new_owner, set_duration) + if(isnum(set_duration)) + duration = clamp(set_duration, 1 SECONDS, max_duration) + return ..() + +/datum/status_effect/artifact/refresh(mob/living/new_owner, set_duration) + var/dose = isnum(set_duration) ? clamp(set_duration, 1 SECONDS, max_duration) : initial(duration) + duration = max(duration, world.time + dose) + +// ===== Fortify ===== + +#define ARTIFACT_FORTIFY_DAMAGE_MULT 0.85 + +/datum/status_effect/artifact/fortify + id = "artifact_fortify" + max_duration = 90 SECONDS + alert_type = /atom/movable/screen/alert/status_effect/artifact_fortify + +/datum/status_effect/artifact/fortify/on_apply() + RegisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(soften_blow)) + owner.add_filter("artifact_fortify", 2, outline_filter(1, "#a7b89a80")) + return TRUE + +/datum/status_effect/artifact/fortify/on_remove() + UnregisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS) + owner.remove_filter("artifact_fortify") + to_chat(owner, span_notice("Your skin softens and feels like your own again.")) + +/datum/status_effect/artifact/fortify/proc/soften_blow(mob/living/source, list/damage_mods, damage, damagetype) + SIGNAL_HANDLER + if(damagetype == BRUTE || damagetype == BURN) + damage_mods += ARTIFACT_FORTIFY_DAMAGE_MULT + +/atom/movable/screen/alert/status_effect/artifact_fortify + name = "Hardened Skin" + desc = "Your skin is tight and tough, like old leather." + icon_state = "slime_stoneskin" + +#undef ARTIFACT_FORTIFY_DAMAGE_MULT + +// ===== Faraday ===== + +/datum/status_effect/artifact/faraday + id = "artifact_faraday" + max_duration = 2 MINUTES + alert_type = /atom/movable/screen/alert/status_effect/artifact_faraday + /// Throttles the "it went nowhere" message + COOLDOWN_DECLARE(grounded_message) + +/datum/status_effect/artifact/faraday/on_apply() + RegisterSignal(owner, COMSIG_ATOM_PRE_EMP_ACT, PROC_REF(ground_pulse)) + owner.add_filter("artifact_faraday", 2, outline_filter(1, "#5fd3ff60")) + return TRUE + +/datum/status_effect/artifact/faraday/on_remove() + UnregisterSignal(owner, COMSIG_ATOM_PRE_EMP_ACT) + owner.remove_filter("artifact_faraday") + to_chat(owner, span_notice("The hush around you lifts, and the static comes back.")) + +/// Swallows an EMP whole: the mob, everything it carries, and its wiring. +/datum/status_effect/artifact/faraday/proc/ground_pulse(atom/source, severity) + SIGNAL_HANDLER + if(COOLDOWN_FINISHED(src, grounded_message)) + COOLDOWN_START(src, grounded_message, 5 SECONDS) + to_chat(owner, span_notice("A pulse crackles over your skin and goes nowhere.")) + new /obj/effect/temp_visual/emp(get_turf(owner)) + return EMP_PROTECT_SELF | EMP_PROTECT_CONTENTS | EMP_PROTECT_WIRES + +/atom/movable/screen/alert/status_effect/artifact_faraday + name = "Grounded" + desc = "There is a cold, still hush on your skin. Even your radio sounds far away." + icon_state = "shock_immune" + +// ===== Sprint ===== + +/datum/movespeed_modifier/status_effect/artifact_sprint + multiplicative_slowdown = -0.25 + blacklisted_movetypes = (FLYING|FLOATING) + +/datum/status_effect/artifact/sprint + id = "artifact_sprint" + max_duration = 60 SECONDS + alert_type = /atom/movable/screen/alert/status_effect/artifact_sprint + +/datum/status_effect/artifact/sprint/on_apply() + owner.add_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_sprint) + RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(leave_afterimage)) + return TRUE + +/datum/status_effect/artifact/sprint/on_remove() + owner.remove_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_sprint) + UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) + to_chat(owner, span_notice("Your legs feel heavy again.")) + +/// A fading copy of them where they just were. +/datum/status_effect/artifact/sprint/proc/leave_afterimage(atom/movable/source, atom/old_loc, movement_dir, forced) + SIGNAL_HANDLER + if(forced || !isturf(old_loc) || !prob(45)) + return + var/obj/effect/temp_visual/decoy/fading/halfsecond/afterimage = new(old_loc, owner) + afterimage.alpha = 110 + animate(afterimage, alpha = 0, time = 0.5 SECONDS) + +/atom/movable/screen/alert/status_effect/artifact_sprint + name = "Light on Your Feet" + desc = "Your legs feel springy and tireless. The floor seems to rush by." + icon_state = "realignment" + +// ===== Berserk and exhaustion ===== + +#define ARTIFACT_BERSERK_FORCE_MULT 1.25 +#define ARTIFACT_BERSERK_SWING_MULT 0.8 +#define ARTIFACT_EXHAUSTED_SWING_MULT 1.25 + +/datum/mood_event/artifact_berserk + description = "Everything makes me so ANGRY." + mood_change = -3 + timeout = 2 MINUTES + +/datum/status_effect/artifact/berserk + id = "artifact_berserk" + max_duration = 45 SECONDS + alert_type = /atom/movable/screen/alert/status_effect/artifact_berserk + +/datum/status_effect/artifact/berserk/on_apply() + // The fury needs something to burn. A spent body can't hold it. + if(owner.stat == DEAD || owner.has_status_effect(/datum/status_effect/artifact/exhausted)) + return FALSE + ADD_TRAIT(owner, TRAIT_PERFECT_ATTACKER, TRAIT_STATUS_EFFECT(id)) + RegisterSignal(owner, COMSIG_MOB_ITEM_ATTACK, PROC_REF(swing_harder)) + owner.add_filter("artifact_berserk", 2, outline_filter(1, "#c0202080")) + owner.add_mood_event("artifact_berserk", /datum/mood_event/artifact_berserk) + return TRUE + +/datum/status_effect/artifact/berserk/on_remove() + REMOVE_TRAIT(owner, TRAIT_PERFECT_ATTACKER, TRAIT_STATUS_EFFECT(id)) + UnregisterSignal(owner, COMSIG_MOB_ITEM_ATTACK) + owner.remove_filter("artifact_berserk") + if(QDELETED(owner) || owner.stat == DEAD) + return + to_chat(owner, span_warning("The anger drains out of you, and takes your strength with it.")) + owner.apply_status_effect(/datum/status_effect/artifact/exhausted, 25 SECONDS) + +/datum/status_effect/artifact/berserk/nextmove_modifier() + return ARTIFACT_BERSERK_SWING_MULT + +/datum/status_effect/artifact/berserk/proc/swing_harder(mob/living/source, mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) + SIGNAL_HANDLER + MODIFY_ATTACK_FORCE_MULTIPLIER(attack_modifiers, ARTIFACT_BERSERK_FORCE_MULT) + +/atom/movable/screen/alert/status_effect/artifact_berserk + name = "Seeing Red" + desc = "Your blood is up. Every swing lands, and lands hard." + icon_state = "blooddrunk" + +/datum/movespeed_modifier/status_effect/artifact_exhausted + multiplicative_slowdown = 0.6 + blacklisted_movetypes = (FLYING|FLOATING) + +/// Worn out: after a rage, a life drain, or being dragged back from death. +/datum/status_effect/artifact/exhausted + id = "artifact_exhausted" + max_duration = 60 SECONDS + alert_type = /atom/movable/screen/alert/status_effect/artifact_exhausted + +/datum/status_effect/artifact/exhausted/on_apply() + owner.add_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_exhausted) + return TRUE + +/datum/status_effect/artifact/exhausted/on_remove() + owner.remove_movespeed_modifier(/datum/movespeed_modifier/status_effect/artifact_exhausted) + +/datum/status_effect/artifact/exhausted/nextmove_modifier() + return ARTIFACT_EXHAUSTED_SWING_MULT + +/atom/movable/screen/alert/status_effect/artifact_exhausted + name = "Drained" + desc = "You feel wrung out. Every step is an effort." + icon_state = "weaken" + +#undef ARTIFACT_BERSERK_FORCE_MULT +#undef ARTIFACT_BERSERK_SWING_MULT +#undef ARTIFACT_EXHAUSTED_SWING_MULT + +// ===== Feysight ===== + +/datum/client_colour/artifact_feysight + priority = CLIENT_COLOR_TEMPORARY_PRIORITY + color = "#d4ffdc" + fade_in = 1 SECONDS + fade_out = 1 SECONDS + +/datum/status_effect/artifact/feysight + id = "artifact_feysight" + max_duration = 45 SECONDS + alert_type = /atom/movable/screen/alert/status_effect/artifact_feysight + +/datum/status_effect/artifact/feysight/on_apply() + ADD_TRAIT(owner, TRAIT_THERMAL_VISION, TRAIT_STATUS_EFFECT(id)) + RegisterSignal(owner, COMSIG_MOB_UPDATE_SIGHT, PROC_REF(tint_sight)) + owner.update_sight() + owner.add_client_colour(/datum/client_colour/artifact_feysight, REF(src)) + return TRUE + +/datum/status_effect/artifact/feysight/on_remove() + REMOVE_TRAIT(owner, TRAIT_THERMAL_VISION, TRAIT_STATUS_EFFECT(id)) + UnregisterSignal(owner, COMSIG_MOB_UPDATE_SIGHT) + owner.update_sight() + owner.remove_client_colour(REF(src)) + to_chat(owner, span_notice("You blink, and the walls are only walls again.")) + +/// A faint green to the dark while the sight lasts. +/datum/status_effect/artifact/feysight/proc/tint_sight(mob/source) + SIGNAL_HANDLER + source.lighting_color_cutoffs = blend_cutoff_colors(source.lighting_color_cutoffs, list(5, 22, 10)) + +/atom/movable/screen/alert/status_effect/artifact_feysight + name = "Second Sight" + desc = "Living things glow through the walls like coals through paper." + icon_state = "moon_hide" + +// ===== Effects that hand one out ===== + +/** + * Shared parent for effects that give people near them a status effect: a touch gives the + * longest dose, a burst a medium one to everyone in range, and a field brushes people now and + * then with a short one. Protective gear shortens every dose. Not rollable itself. + */ +/datum/artifact_effect/bestow + /// The /datum/status_effect/artifact it hands out + var/datum/status_effect/artifact/status_type + var/touch_duration = 60 SECONDS + var/pulse_duration = 30 SECONDS + var/aura_duration = 10 SECONDS + /// Chance per second that the field reaches each person in range + var/aura_chance = 10 + +/datum/artifact_effect/bestow/do_effect_touch(mob/living/user) + bestow(user, touch_duration) + +/datum/artifact_effect/bestow/do_effect_aura(seconds_per_tick) + for(var/mob/living/target in living_in_range()) + if(SPT_PROB(aura_chance, seconds_per_tick)) + bestow(target, aura_duration, quiet = TRUE) + +/datum/artifact_effect/bestow/do_effect_pulse(power) + var/dose = pulse_duration * clamp(power / 25, 0.5, 1.5) + for(var/mob/living/target in living_in_range()) + bestow(target, dose) + +/** + * Gives `target` a dose lasting `duration` (deciseconds), shortened by protective gear. + * `quiet` keeps a field's top-ups from repeating the renewal message. + * Returns TRUE if they have it afterwards. + */ +/datum/artifact_effect/bestow/proc/bestow(mob/living/target, duration, quiet = FALSE) + if(!status_type || !can_bestow(target)) + return FALSE + duration *= artifact_exposure(target) + if(duration < 1 SECONDS) + return FALSE + var/had_it = !!target.has_status_effect(status_type) + target.apply_status_effect(status_type, duration) + if(!target.has_status_effect(status_type)) + on_refused(target) + return FALSE + if(!had_it) + on_bestowed(target) + else if(!quiet) + on_renewed(target) + log_harm(target) + return TRUE + +/// Who it works on. +/datum/artifact_effect/bestow/proc/can_bestow(mob/living/target) + return target.stat != DEAD + +/// Hook: `target` just got their first dose. +/datum/artifact_effect/bestow/proc/on_bestowed(mob/living/target) + return + +/// Hook: `target` already had it and got topped up by a touch or burst. +/datum/artifact_effect/bestow/proc/on_renewed(mob/living/target) + return + +/// Hook: `target` could not take it (the status effect refused). +/datum/artifact_effect/bestow/proc/on_refused(mob/living/target) + return diff --git a/voidcrew/modules/xenoarch/effects_polaris/sweat.dm b/voidcrew/modules/xenoarch/effects_polaris/sweat.dm index d3ee47a123f..9fbc0c6d2ca 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/sweat.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/sweat.dm @@ -1 +1,92 @@ // Artifact effect: sweat. +// Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/sweating_reagents.dm. +// Voidcrew: Polaris could weep any reagent in the game (and blood); this weeps one mild, +// everyday liquid picked from a short list, so it is never a free source of anything worth +// having. It pools into a small puddle that can be mopped or scooped up, and a puddle stops +// growing once it holds a cupful. + +/// A puddle stops growing once it holds this many units +#define ARTIFACT_SWEAT_PUDDLE_CAP 30 + +/datum/artifact_effect/sweat + label_name = "Weeps fluid" + log_name = "Sweat" + tier = ARTIFACT_TIER_COMMON + minor = TRUE + possible_kinds = list(ARTIFACT_ENERGY_ORGANIC, ARTIFACT_ENERGY_SYNTHESIS) + /// What it weeps + var/datum/reagent/reagent_type = /datum/reagent/water + +/datum/artifact_effect/sweat/setup() + var/static/list/weepings = list( + /datum/reagent/water, + /datum/reagent/water/salt, + /datum/reagent/consumable/sugar, + /datum/reagent/consumable/nutriment/fat/oil, + /datum/reagent/consumable/vinegar, + ) + reagent_type = pick(weepings) + +/datum/artifact_effect/sweat/copy_settings_to(datum/artifact_effect/sweat/copy) + copy.reagent_type = reagent_type + +/datum/artifact_effect/sweat/do_effect_touch(mob/living/user) + var/turf/user_turf = get_turf(user) + if(prob(25) && weep(user_turf, 6)) + user.visible_message( + span_warning("[holder] shudders at [user]'s touch and spits a slick of fluid across [user.p_their()] feet!"), + span_warning("[holder] shudders at your touch and spits a slick of fluid across your feet!"), + ) + playsound(user_turf, 'sound/effects/splat.ogg', 35, TRUE) + return + weep(source_turf(), 2) + to_chat(user, span_notice("Your palm comes away slick. It is beaded with moisture where you touched it.")) + +/datum/artifact_effect/sweat/do_effect_aura(seconds_per_tick) + if(SPT_PROB(25, seconds_per_tick) && weep(source_turf(), 2)) + playsound(source_turf(), 'sound/effects/bubbles/bubbles.ogg', 15, TRUE, SILENCED_SOUND_EXTRARANGE) + +/datum/artifact_effect/sweat/do_effect_pulse(power) + var/turf/center = source_turf() + if(!center) + return + var/spilled = weep(center, clamp(power / 4, 2, 10)) + var/list/neighbours = list() + for(var/turf/open/spot in orange(1, center)) + neighbours += spot + for(var/i in 1 to min(3, length(neighbours))) + spilled = weep(pick_n_take(neighbours), clamp(power / 8, 1, 5)) || spilled + if(spilled) + center.visible_message(span_warning("[holder] shudders and spits fluid across the floor.")) + playsound(center, 'sound/effects/splat.ogg', 40, TRUE) + +/// Adds `amount` of its liquid to a puddle on `spot`. Returns TRUE if any went in. +/datum/artifact_effect/sweat/proc/weep(turf/open/spot, amount) + if(!istype(spot) || amount <= 0 || isspaceturf(spot) || isgroundlessturf(spot) || islava(spot)) + return FALSE + var/obj/effect/decal/cleanable/artifact_ooze/puddle = locate() in spot + if(!puddle) + puddle = new(spot) + if(QDELETED(puddle)) + return FALSE + if(!puddle.reagents) + puddle.create_reagents(ARTIFACT_SWEAT_PUDDLE_CAP) + var/room = ARTIFACT_SWEAT_PUDDLE_CAP - puddle.reagents.total_volume + if(room <= 0) + return FALSE + puddle.reagents.add_reagent(reagent_type, min(amount, room)) + puddle.color = initial(reagent_type.color) + return TRUE + +/// A puddle of whatever an artifact wept. Scoop it up with a container, or mop it. +/obj/effect/decal/cleanable/artifact_ooze + name = "slick" + desc = "A small, faintly warm puddle of something thin." + icon = 'icons/effects/blood.dmi' + icon_state = "floor1" + random_icon_states = list("floor1", "floor2", "floor3", "floor4", "floor5", "floor6", "floor7") + alpha = 200 + beauty = -25 + mergeable_decal = FALSE + +#undef ARTIFACT_SWEAT_PUDDLE_CAP From 1da2395544b6b456e7253e77c5ba9e7252b79426 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:55:31 -0800 Subject: [PATCH 27/72] Have Sarge send dig finds round to Boffin Sarge buys none, but now says where to take a find hauled up to her counter. Reword the loaded specimen crate's examine. --- .../unit_tests/voidcrew_xenoarch_trade.dm | 4 +++ voidcrew/modules/xenoarch/trade/buyers.dm | 25 +++++++++++++++++-- .../modules/xenoarch/trade/specimen_crate.dm | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm index 35bd1273292..8bba8720162 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm @@ -63,6 +63,10 @@ find_row.demand = 0 shop.convoy_restock() TEST_ASSERT(find_row.demand > 0, "[shop_type]'s find demand never comes back") + // Sarge buys nothing but points the way + var/datum/outpost_shop/outfitter/depot = allocate(/datum/outpost_shop/outfitter) + TEST_ASSERT_NOTNULL(depot.xenoarch_buyer, "Sarge has nothing to say about a find") + TEST_ASSERT(depot.xenoarch_buyer.line("notice"), "Sarge has nothing to say about a find") TEST_ASSERT_EQUAL(xenoarch_credits_text(1125), "1,125 cr", "credit formatting") TEST_ASSERT_EQUAL(xenoarch_credits_text(250), "250 cr", "credit formatting") TEST_ASSERT_EQUAL(xenoarch_credits_text(1000000), "1,000,000 cr", "credit formatting") diff --git a/voidcrew/modules/xenoarch/trade/buyers.dm b/voidcrew/modules/xenoarch/trade/buyers.dm index 5fb61281f84..02ff921e467 100644 --- a/voidcrew/modules/xenoarch/trade/buyers.dm +++ b/voidcrew/modules/xenoarch/trade/buyers.dm @@ -17,7 +17,8 @@ * xenoarch_mark_curio(); Barnaby and Boffin take them, and only Boffin takes strange objects. * - Each buyer takes XENOARCH_FIND_DEMAND large finds and XENOARCH_CURIO_DEMAND curios at a time, * topped up by the convoy like any other ledger row (shop.dm convoy_restock()). - * - A buyer notices a find being hauled up to the counter and says so, once per find. + * - A buyer notices a find being hauled up to the counter and says so, once per find. Sarge, who + * buys none, points the way to Boffin's stall instead. * - Selling earns no reputation; that comes from jobs (trader_favor.dm). */ @@ -118,6 +119,8 @@ var/datum/outpost_shop/shop /// Vouchers paid on top of credits for a find whose main effect is extreme var/extreme_vouchers = 0 + /// Buys large finds at all (a trader who only points the way to the buyer next door doesn't) + var/buys_finds = TRUE /// Buys curios at all var/buys_curios = TRUE /// Takes tg's strange objects among the curios @@ -165,7 +168,9 @@ /// Ledger rows this buyer adds to their shop. /datum/xenoarch_buyer/proc/ledger_types() - . = list(/datum/shop_buyback/xenoarch_find) + . = list() + if(buys_finds) + . += /datum/shop_buyback/xenoarch_find if(buys_curios) . += /datum/shop_buyback/xenoarch_curio @@ -514,6 +519,19 @@ parts += "
[receipt_signoff]" return parts.Join() +// ----- Sarge: Quartermain Depot's main counter, who sends finds round to Boffin ----- + +/datum/xenoarch_buyer/sarge + buys_finds = FALSE + buys_curios = FALSE + lines = list( + XENOARCH_LINE_NOTICE = list( + "That's not armor. Boffin buys rocks. Skunkworks, round the back.", + "Dig piece? Not my department. Boffin's stall.", + "Don't park that at my counter. Boffin's the one who wants it.", + ), + ) + // ========================================================================= // SHOPS AND TRADERS // ========================================================================= @@ -533,6 +551,9 @@ /datum/outpost_shop/black_market xenoarch_buyer_type = /datum/xenoarch_buyer/vex +/datum/outpost_shop/outfitter + xenoarch_buyer_type = /datum/xenoarch_buyer/sarge + // The buyer's ledger rows go in before the base New() makes the buybacks, so they share the // shop's demand and convoy restock like any other row. /datum/outpost_shop/New(obj/structure/overmap/trader_outpost/outpost) diff --git a/voidcrew/modules/xenoarch/trade/specimen_crate.dm b/voidcrew/modules/xenoarch/trade/specimen_crate.dm index 3bcd9203c23..cecd2daa49f 100644 --- a/voidcrew/modules/xenoarch/trade/specimen_crate.dm +++ b/voidcrew/modules/xenoarch/trade/specimen_crate.dm @@ -40,6 +40,6 @@ var/obj/machinery/artifact/find = locate() in contents if(!find) return - . += "It sits heavy on its straps." + . += "Something heavy is strapped in." if(find.is_dug()) . += "A field tag clipped to the lid reads [find.serial]." From e56c336166660eb497b0c5c03875b6c0613528ed Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:01:54 -0800 Subject: [PATCH 28/72] Add the artifact harvester, exotic battery and utilizer The harvester draws one awake effect off an artifact beside it into an exotic battery. Each find gives three harvests; one is spent when the first charge reaches the battery, and the find stays locked for the draw and is released on finish, halt and deletion. A stream of motes in the energy's colour runs from the find to the collector dome while it hums, and its window is a phosphor scope over a glass charge tube. The utilizer runs the battery's effect from the hand: a field or bursts for about six and a half minutes, or ten contact uses. It hums, glows and sounds in the energy's own colour and voice, logs whoever switched it on, and goes dark inside trader outposts. Its window is a single power button ringed by the charge. Batteries and utilizers are items, so ship saves delete them; a saved harvester comes back empty. Both are rated yellow in the gear catalog. Art is Nova's harvester, battery and utilizer sprites with tint masks drawn for the energy colours. --- .../interfaces/AnomalyUtilizer.tsx | 112 ++++ .../interfaces/ArtifactHarvester.tsx | 266 ++++++++++ tools/gear_catalog/categories.py | 2 + tools/gear_catalog/ratings/relics.json | 18 + voidcrew/_DEFINES/xenoarch.dm | 10 + .../unit_tests/voidcrew_xenoarch_use.dm | 249 +++++++++ voidcrew/modules/xenoarch/icons/use.dmi | Bin 0 -> 8602 bytes voidcrew/modules/xenoarch/use/battery.dm | 204 +++++++- voidcrew/modules/xenoarch/use/harvester.dm | 485 +++++++++++++++++- voidcrew/modules/xenoarch/use/utilizer.dm | 367 ++++++++++++- 10 files changed, 1710 insertions(+), 3 deletions(-) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx create mode 100644 voidcrew/modules/xenoarch/icons/use.dmi diff --git a/tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx b/tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx new file mode 100644 index 00000000000..628f049a942 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx @@ -0,0 +1,112 @@ +/** + * Exotic utilizer: one power button ringed by the battery's charge, glowing in the colour of the + * energy it holds. DM side: voidcrew/modules/xenoarch/use/utilizer.dm. + */ +import { Box, Button, Stack } from 'tgui-core/components'; + +import { useBackend } from '../../tgui/backend'; +import { Window } from '../../tgui/layouts'; + +type Cell = { + fill: number; + color: string | null; + serial: string | null; + mode: string | null; +}; + +type Data = { + emitting: number; + cell: Cell | null; +}; + +const RING_R = 52; +const RING_C = 2 * Math.PI * RING_R; + +export const AnomalyUtilizer = (props) => { + const { act, data } = useBackend(); + const { emitting, cell } = data; + const on = !!emitting; + const color = cell?.color || '#4b5563'; + const fill = cell ? Math.max(0, Math.min(1, cell.fill)) : 0; + return ( + + + + + + + {on ? ( + + ) : null} + + act('power')} + /> + + {on ? 'EMIT' : 'OFF'} + + + + + + {cell?.serial ? cell.serial.toUpperCase() : 'NO CELL'} + {cell?.mode ? ` · ${cell.mode.toUpperCase()}` : ''} + + + + + + + + ); +}; diff --git a/tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx b/tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx new file mode 100644 index 00000000000..39c43f0dccd --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx @@ -0,0 +1,266 @@ +/** + * Exotic particle harvester: a phosphor scope traces the signal of the find beside the machine, + * in its energy's colour, and a glass tube fills with the battery's colour as it draws. + * DM side: voidcrew/modules/xenoarch/use/harvester.dm. + */ +import { Box, Button, Stack } from 'tgui-core/components'; + +import { useBackend } from '../../tgui/backend'; +import { Window } from '../../tgui/layouts'; + +type Source = { + serial: string; + state: string; + color: string | null; +}; + +type Cell = { + fill: number; + color: string | null; + kind: string | null; + serial: string | null; +}; + +type Data = { + powered: number; + drawing: number; + source: Source | null; + cell: Cell | null; +}; + +const WORDS: Record = { + ready: 'SIGNAL', + drawing: 'DRAWING', + quiet: 'NO SIGNAL', + tangled: 'CROSSED', + unfit: 'WILL NOT HOLD', + spent: 'SPENT', + busy: 'ENGAGED', + mismatch: 'CELL MISMATCH', +}; + +const SCREEN_W = 240; +const SCREEN_H = 90; +const PHOSPHOR = '#59d48a'; +/** One period of the base wave in pixels; animated traces only use whole harmonics of it. */ +const PERIOD = 2 * Math.PI * 20; + +/** The trace for a state, twice the screen wide so it can scroll seamlessly. */ +function tracePath(state: string): string { + const points: string[] = []; + for (let x = 0; x <= SCREEN_W * 2; x += 3) { + const t = x / 20; + let y: number; + switch (state) { + case 'ready': + case 'drawing': + y = Math.sin(t) * 22; + break; + case 'tangled': + y = Math.sin(t) * 15 + Math.sin(t * 3 + 1) * 13; + break; + case 'unfit': + y = Math.sin(t * 3) * 26 * Math.exp(-((x % 60) / 16)); + break; + case 'mismatch': + y = Math.sin(t) * 12 + Math.sin(t * 5) * 6; + break; + case 'busy': + y = Math.sign(Math.sin(t * 0.8)) * 14; + break; + default: + y = Math.sin(t * 7) * 1.2; + } + points.push(`${x},${(SCREEN_H / 2 - y).toFixed(1)}`); + } + return `M ${points.join(' L ')}`; +} + +const MOVING = ['ready', 'drawing', 'tangled']; + +function Scope(props: { state: string; color: string; lit: boolean }) { + const { state, color, lit } = props; + return ( + + + {[...Array(9)].map((_, i) => ( + + ))} + {[...Array(3)].map((_, i) => ( + + ))} + {lit ? ( + + {MOVING.includes(state) ? ( + + ) : null} + + ) : null} + + ); +} + +function Tube(props: { fill: number; color: string | null; live: boolean }) { + const { fill, color, live } = props; + const height = 104; + const level = Math.round(height * Math.max(0, Math.min(1, fill))); + return ( + + + + + ); +} + +const etched = { + fontFamily: 'monospace', + fontSize: '10px', + letterSpacing: '2px', + color: '#8f98a6', +}; + +export const ArtifactHarvester = (props) => { + const { act, data } = useBackend(); + const { powered, drawing, source, cell } = data; + const state = source ? source.state : 'none'; + const lit = !!powered; + const traceColor = + (state === 'drawing' || state === 'ready') && source?.color + ? source.color + : PHOSPHOR; + const canDraw = + lit && !drawing && !!cell && cell.fill < 1 && state === 'ready'; + return ( + + + + + + + + + + {source ? source.serial.toUpperCase() : 'NO SOURCE'} + + + {source ? WORDS[state] || '' : ''} + + + + + + + CELL + + + + + + {drawing ? ( + + ) : ( + + )} + + + + + + + + + + + ); +}; diff --git a/tools/gear_catalog/categories.py b/tools/gear_catalog/categories.py index 861e0dde54f..13a7ff2bc18 100644 --- a/tools/gear_catalog/categories.py +++ b/tools/gear_catalog/categories.py @@ -191,8 +191,10 @@ ('/obj/item/access_key', 'tools', ITEM), ('/obj/item/airlock_painter', 'tools', ITEM), ('/obj/item/analyzer', 'tools', ITEM), + ('/obj/item/anomaly_battery', 'relics', ITEM), ('/obj/item/anomaly_neutralizer', 'tools', ITEM), ('/obj/item/anomaly_releaser', 'tools', ITEM), + ('/obj/item/anomaly_utilizer', 'relics', ITEM), ('/obj/item/beacon', 'tools', ITEM), ('/obj/item/binoculars', 'tools', ITEM), ('/obj/item/boxcutter', 'tools', ITEM), diff --git a/tools/gear_catalog/ratings/relics.json b/tools/gear_catalog/ratings/relics.json index 66247cc16a8..78f546b4ffb 100644 --- a/tools/gear_catalog/ratings/relics.json +++ b/tools/gear_catalog/ratings/relics.json @@ -9,6 +9,24 @@ "harmless" ], "items": { + "/obj/item/anomaly_battery": { + "confidence": "high", + "group": "parts and upgrades", + "hash": "", + "name": "exotic battery", + "rank": 2, + "reason": "Holds one effect drawn off an artifact for the exotic utilizer and does nothing alone. Rated as what it powers.", + "tier": "yellow" + }, + "/obj/item/anomaly_utilizer": { + "confidence": "medium", + "group": "parts and upgrades", + "hash": "", + "name": "exotic utilizer", + "rank": 2, + "reason": "Runs one harvested artifact effect from an exotic battery: a field or bursts for about 6.5 minutes, or 10 contact uses. Power is the artifact's own; the worst cases are stun or teleport bursts every 10 to 50 s over 2 to 8 tiles, which hit the holder too unless bio-suited. Healing shares the per-person artifact cap. Needs a dug artifact and a harvester to fill.", + "tier": "yellow" + }, "/obj/item/book_of_babel": { "confidence": "high", "group": "harmless", diff --git a/voidcrew/_DEFINES/xenoarch.dm b/voidcrew/_DEFINES/xenoarch.dm index 0981e3337d9..1a0f92e2e57 100644 --- a/voidcrew/_DEFINES/xenoarch.dm +++ b/voidcrew/_DEFINES/xenoarch.dm @@ -132,6 +132,16 @@ // ===== Harvesting (P4 owns the machines) ===== #define ARTIFACT_MAX_HARVESTS 3 +/// Charge an exotic battery holds. One harvest fills one battery. +#define ARTIFACT_BATTERY_CAPACITY 200 +/// Charge per second a harvester draws on tier-1 capacitors: a full battery in 80 seconds. +#define ARTIFACT_HARVEST_DRAW_RATE 2.5 +/// Charge per second a utilizer spends running a field or bursts: a full battery lasts 400 seconds. +#define ARTIFACT_UTILIZER_DRAIN 0.5 +/// Charge per second a utilizer feeds a burst effect's store, twice what an artifact refills. +#define ARTIFACT_UTILIZER_PULSE_FEED 1 +/// Charge one contact use costs: ten from a full battery. +#define ARTIFACT_UTILIZER_TOUCH_COST 20 // ===== Pricing (core function; buyers are P5) ===== /// Base price of a large artifact by stamped band (green, yellow, red). diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm index 33c4631511f..cf88b2b70ab 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_use.dm @@ -1 +1,250 @@ // Xenoarchaeology tests: use. +// +// Test files compile before voidcrew/_DEFINES, so the values below are literals. They mirror +// voidcrew/_DEFINES/xenoarch.dm and the harvester's readout keys: +// ARTIFACT_MAX_HARVESTS 3, ARTIFACT_BATTERY_CAPACITY 200, ARTIFACT_HARVEST_DRAW_RATE 2.5, +// ARTIFACT_UTILIZER_DRAIN 0.5, ARTIFACT_UTILIZER_PULSE_FEED 1, ARTIFACT_UTILIZER_TOUCH_COST 20, +// ARTIFACT_AURA_COST_PER_SECOND 1.5, COMSIG_ARTIFACT_HARVESTED "artifact_harvested", +// release methods "touch", "aura", "pulse"; bands green 1, yellow 2, red 3. +// readout: "ready", "quiet", "tangled", "unfit", "spent", "busy", "mismatch". + +/// A battery fills only from a harvest, only from one source at a time, and a copy of it is empty. +/datum/unit_test/voidcrew_xenoarch_battery/Run() + var/obj/item/anomaly_battery/battery = allocate(/obj/item/anomaly_battery) + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/heal, /datum/artifact_effect/light, 2) + var/datum/artifact_effect/heal = find.main_effect + var/datum/artifact_effect/light = find.second_effect + + TEST_ASSERT_EQUAL(battery.stored_charge, 0, "a new battery came charged") + TEST_ASSERT_EQUAL(battery.add_charge(50), 0, "a battery holding nothing took charge") + TEST_ASSERT(battery.can_take(heal), "an empty battery refused an effect") + + battery.load_from(heal, find.serial) + TEST_ASSERT(istype(battery.held_effect, /datum/artifact_effect/heal), "the battery does not hold a copy of the effect") + TEST_ASSERT(battery.held_effect != heal, "the battery holds the artifact's own effect, not a copy") + TEST_ASSERT_EQUAL(battery.held_effect.holder, battery, "the copy's holder is not the battery") + TEST_ASSERT_EQUAL(battery.source_serial, find.serial, "the battery was not stamped with the find's serial") + TEST_ASSERT_EQUAL(battery.add_charge(250), 200, "a battery took more than its capacity") + TEST_ASSERT(battery.is_full(), "a battery at capacity is not full") + TEST_ASSERT_EQUAL(battery.charge_level(), 100, "a full battery's gauge") + TEST_ASSERT(!battery.can_take(heal), "a full battery took more") + battery.use_charge(199) + TEST_ASSERT_EQUAL(battery.charge_level(), 25, "any charge at all should show a quarter on the gauge") + TEST_ASSERT(battery.can_take(heal), "a part-charged battery refused a top-up from its own source") + TEST_ASSERT(!battery.can_take(light), "a part-charged battery took a second effect") + battery.use_charge(5) + TEST_ASSERT_EQUAL(battery.stored_charge, 0, "spending more than it held left charge behind") + TEST_ASSERT(battery.can_take(light), "an empty battery refused a new effect") + + battery.add_charge(80) + battery.purge() + TEST_ASSERT_EQUAL(battery.stored_charge, 0, "a purged battery kept charge") + TEST_ASSERT_NULL(battery.held_effect, "a purged battery kept its effect") + TEST_ASSERT_NULL(battery.source_serial, "a purged battery kept its stamp") + + // Machines that copy an item (the experimentor) make a new one from its type: always empty. + battery.load_from(heal, find.serial) + battery.add_charge(200) + var/obj/item/anomaly_battery/copy = allocate(battery.type) + TEST_ASSERT_EQUAL(copy.stored_charge, 0, "a battery built from its type came charged") + TEST_ASSERT_NULL(copy.held_effect, "a battery built from its type came holding an effect") + +/// The harvester: the harvest cap, when a harvest is spent, the lock, refusals and cleanup. +/datum/unit_test/voidcrew_xenoarch_harvest + var/harvest_signals = 0 + +/datum/unit_test/voidcrew_xenoarch_harvest/proc/on_harvested(datum/source, datum/artifact_effect/effect, mob/user) + SIGNAL_HANDLER + harvest_signals++ + +/datum/unit_test/voidcrew_xenoarch_harvest/Run() + var/obj/machinery/artifact_harvester/harvester = allocate(/obj/machinery/artifact_harvester) + harvester.set_machine_stat(NONE) + TEST_ASSERT(harvester.is_operational, "the harvester is not operational in the test room") + var/turf/beside = locate(harvester.x + 1, harvester.y, harvester.z) + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, beside, 2) + find.replace_effects(/datum/artifact_effect/heal, null, 2) + RegisterSignal(find, "artifact_harvested", PROC_REF(on_harvested)) + var/price_before = artifact_sale_value(find) + + TEST_ASSERT(!harvester.start_draw(), "it drew with no battery") + var/obj/item/anomaly_battery/battery = allocate(/obj/item/anomaly_battery) + battery.forceMove(harvester) + harvester.battery = battery + + TEST_ASSERT_EQUAL(harvester.find_source(), find, "it did not find the artifact beside it") + TEST_ASSERT_EQUAL(harvester.source_state(find), "quiet", "a sleeping find did not read as quiet") + TEST_ASSERT(!harvester.start_draw(), "it drew from a sleeping find") + + find.main_effect.wake(10 MINUTES) + TEST_ASSERT_EQUAL(harvester.source_state(find), "ready", "an awake, harvestable find did not read as ready") + TEST_ASSERT(harvester.start_draw(), "it would not draw from a ready find") + TEST_ASSERT(harvester.drawing, "it is not drawing") + TEST_ASSERT_EQUAL(find.locked_by?.resolve(), harvester, "it did not lock the find") + TEST_ASSERT_EQUAL(find.harvests_left, 3, "a harvest was spent before any charge arrived") + + harvester.process(2) + TEST_ASSERT_EQUAL(battery.stored_charge, 5, "two seconds on tier-1 parts should draw 5 charge") + TEST_ASSERT_EQUAL(find.harvests_left, 2, "the first charge did not spend a harvest") + TEST_ASSERT_EQUAL(harvest_signals, 1, "the harvest signal was not sent once") + harvester.process(2) + TEST_ASSERT_EQUAL(find.harvests_left, 2, "one draw spent two harvests") + for(var/i in 1 to 50) + if(!harvester.drawing) + break + harvester.process(2) + TEST_ASSERT(!harvester.drawing, "it never finished filling the battery") + TEST_ASSERT(battery.is_full(), "it stopped before the battery was full") + TEST_ASSERT(!find.is_being_used(), "it kept the find locked after finishing") + TEST_ASSERT(istype(battery.held_effect, /datum/artifact_effect/heal), "the battery does not hold the drawn effect") + TEST_ASSERT(artifact_sale_value(find) < price_before, "a harvest did not lower the find's price") + TEST_ASSERT_EQUAL(harvest_signals, 1, "one full draw sent more than one harvest signal") + TEST_ASSERT(!harvester.start_draw(), "it drew into a full battery") + + // Stopping before any charge arrives costs nothing; stopping after costs the harvest. + harvester.purge_battery() + TEST_ASSERT_EQUAL(battery.stored_charge, 0, "purging left charge in the battery") + TEST_ASSERT(harvester.start_draw(), "it would not draw into a purged battery") + harvester.stop_draw() + TEST_ASSERT_EQUAL(find.harvests_left, 2, "a draw stopped before any charge spent a harvest") + TEST_ASSERT(!find.is_being_used(), "a stopped draw kept the find locked") + TEST_ASSERT(harvester.start_draw(), "it would not start again") + harvester.process(2) + harvester.stop_draw() + TEST_ASSERT_EQUAL(find.harvests_left, 1, "a draw stopped after charge arrived did not spend its harvest") + TEST_ASSERT_EQUAL(battery.stored_charge, 5, "a stopped draw lost the charge it had drawn") + + // A part-charged battery only tops up from the effect it holds. + find.main_effect.deactivate(quiet = TRUE) + find.replace_effects(/datum/artifact_effect/heal, /datum/artifact_effect/light, 2) + find.second_effect.wake(10 MINUTES) + TEST_ASSERT_EQUAL(harvester.source_state(find), "mismatch", "a battery holding another effect read as ready") + TEST_ASSERT(!harvester.start_draw(), "a part-charged battery took a second effect") + find.main_effect.wake(10 MINUTES) + TEST_ASSERT_EQUAL(harvester.source_state(find), "tangled", "two awake effects did not read as tangled") + TEST_ASSERT(!harvester.start_draw(), "it drew with two effects awake") + find.second_effect.deactivate(quiet = TRUE) + find.main_effect.harvestable = FALSE + TEST_ASSERT_EQUAL(harvester.source_state(find), "unfit", "an unharvestable effect did not read as unfit") + TEST_ASSERT(!harvester.start_draw(), "it drew an unharvestable effect") + find.main_effect.harvestable = TRUE + harvester.purge_battery() + + // Someone else's lock, and a spent find. + var/datum/other_locker = new + TEST_ASSERT(find.try_lock(other_locker), "could not lock the find for the test") + TEST_ASSERT_EQUAL(harvester.source_state(find), "busy", "a find locked by another machine did not read as busy") + TEST_ASSERT(!harvester.start_draw(), "it drew from a find another machine held") + find.release_lock(other_locker) + qdel(other_locker) + find.harvests_left = 0 + TEST_ASSERT_EQUAL(harvester.source_state(find), "spent", "a find with no harvests left did not read as spent") + TEST_ASSERT(!harvester.start_draw(), "it drew past the harvest cap") + find.harvests_left = 1 + + // Hauling the find away mid-draw stops it and lets go. + TEST_ASSERT(harvester.start_draw(), "it would not draw from the last harvest") + find.forceMove(run_loc_floor_top_right) + harvester.process(2) + TEST_ASSERT(!harvester.drawing, "it kept drawing from a find out of reach") + TEST_ASSERT(!find.is_being_used(), "it kept a find out of reach locked") + TEST_ASSERT_EQUAL(find.harvests_left, 1, "a draw that never reached charge spent a harvest") + + // Deleting it mid-draw releases the find; ejecting hands the battery out. + find.forceMove(beside) + TEST_ASSERT(harvester.start_draw(), "it would not draw again") + harvester.process(2) + TEST_ASSERT_EQUAL(find.harvests_left, 0, "the last harvest was not spent") + harvester.stop_draw() + TEST_ASSERT(harvester.eject_battery(), "it would not eject its battery") + TEST_ASSERT_NULL(harvester.battery, "it kept a reference to the ejected battery") + TEST_ASSERT(!(battery in harvester.component_parts), "the battery counts as a part of the machine, so ship saves would keep it") + battery.forceMove(harvester) + harvester.battery = battery + find.harvests_left = 1 + harvester.purge_battery() + TEST_ASSERT(harvester.start_draw(), "it would not draw before deletion") + qdel(harvester) + TEST_ASSERT(!find.is_being_used(), "a deleted harvester kept the find locked") + TEST_ASSERT(QDELETED(battery), "a deleted harvester left its battery in nullspace") + UnregisterSignal(find, "artifact_harvested") + +/// The utilizer: field drain, healing through the shared cap, bursts, contact uses and cleanup. +/datum/unit_test/voidcrew_xenoarch_utilizer/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/heal, null, 2) + find.main_effect.release_method = "aura" + find.main_effect.range = 3 + var/obj/item/anomaly_battery/battery = allocate(/obj/item/anomaly_battery) + battery.load_from(find.main_effect, find.serial) + battery.add_charge(200) + find.moveToNullspace() + + var/obj/item/anomaly_utilizer/utilizer = allocate(/obj/item/anomaly_utilizer) + TEST_ASSERT(!utilizer.switch_on(), "it switched on with no battery") + battery.forceMove(utilizer) + utilizer.battery = battery + var/datum/artifact_effect/effect = battery.held_effect + + var/mob/living/carbon/human/consistent/patient = allocate(/mob/living/carbon/human/consistent) + patient.adjustBruteLoss(50) + var/damage_before = patient.getBruteLoss() + patient.getFireLoss() + + TEST_ASSERT(utilizer.switch_on(), "it would not switch on") + TEST_ASSERT(effect.activated, "switching on did not wake the battery's effect") + utilizer.process(2) + TEST_ASSERT_EQUAL(battery.stored_charge, 199, "a field should cost 0.5 charge a second") + for(var/i in 1 to 60) + utilizer.process(2) + var/healed = damage_before - (patient.getBruteLoss() + patient.getFireLoss()) + TEST_ASSERT(healed > 0, "a heal field healed nobody") + TEST_ASSERT(healed <= 30.1, "a heal field healed [healed], past the shared cap") + TEST_ASSERT(effect.activated, "the field lapsed while switched on") + + // Running dry switches it off. + battery.stored_charge = 0.5 + utilizer.process(2) + TEST_ASSERT(!utilizer.emitting, "it kept running on an empty battery") + TEST_ASSERT(!effect.activated, "its effect stayed awake after it went dark") + TEST_ASSERT(!utilizer.switch_on(), "it switched on with an empty battery") + + // Bursts build up at the utilizer's own rate and let go when full. + battery.stored_charge = 200 + effect.release_method = "pulse" + effect.maximum_charges = 6 + effect.current_charge = 0 + TEST_ASSERT(utilizer.switch_on(), "it would not switch on for bursts") + utilizer.process(2) + TEST_ASSERT_EQUAL(effect.current_charge, 2, "bursts should build at 1 charge a second") + utilizer.process(2) + utilizer.process(2) + TEST_ASSERT_EQUAL(effect.current_charge, 0, "a full store did not let go") + utilizer.switch_off() + + // Contact: one use per press, paid from the battery, and nothing while switched off. + var/mob/living/carbon/human/consistent/holder = allocate(/mob/living/carbon/human/consistent) + effect.release_method = "touch" + TEST_ASSERT_EQUAL(utilizer.interact_with_atom(patient, holder), NONE, "a switched-off utilizer acted on contact") + TEST_ASSERT(utilizer.switch_on(holder), "it would not switch on for contact") + var/charge_before = battery.stored_charge + utilizer.process(2) + TEST_ASSERT_EQUAL(battery.stored_charge, charge_before, "a contact effect drained while waiting") + TEST_ASSERT_EQUAL(utilizer.interact_with_atom(patient, holder), ITEM_INTERACT_SUCCESS, "pressing it to someone did nothing") + TEST_ASSERT_EQUAL(battery.stored_charge, charge_before - 20, "a contact use should cost 20 charge") + TEST_ASSERT_EQUAL(effect.get_responsible(), holder, "the person pressing it does not answer for it") + TEST_ASSERT_EQUAL(utilizer.interact_with_atom(find, holder), NONE, "it acted on contact with an object") + + // Ejecting while running switches it off; deleting it takes the battery with it. + TEST_ASSERT(utilizer.eject_battery(holder), "it would not eject its battery") + TEST_ASSERT(!utilizer.emitting, "it kept running with its battery out") + TEST_ASSERT(!effect.activated, "the battery's effect stayed awake out of the utilizer") + TEST_ASSERT_NULL(utilizer.battery, "it kept a reference to the ejected battery") + if(battery.loc == holder) + holder.transferItemToLoc(battery, utilizer, force = TRUE) + else + battery.forceMove(utilizer) + utilizer.battery = battery + TEST_ASSERT(utilizer.switch_on(holder), "it would not switch on again") + qdel(utilizer) + TEST_ASSERT(QDELETED(battery), "a deleted utilizer left its battery behind") diff --git a/voidcrew/modules/xenoarch/icons/use.dmi b/voidcrew/modules/xenoarch/icons/use.dmi new file mode 100644 index 0000000000000000000000000000000000000000..a49c5e20d60972d7310e43291d3a899282ea9f37 GIT binary patch literal 8602 zcmcI|XH-*d*XBtG5PCom5s`%cCxMYH-cTpd>Ob~JLq!B&S-H(f5r-Zn#Jr>#qY-oUhW_dg0OE_knINJ zUJuH4stBd3-(5I^v5Bo;_mnzv%Yx_VJFok ztJZUl)?+dW=Y2V<6DA*B=6M`rX0@01;PANJa^hQAj8AXh$+tsK`^iksVqkW77<*`AgZ7yi8R()n}Erf-+(c!2kIO;YpZ zTSc4_#>M2_)bpmx1xJA+O}v94-(b+ggFTY1R$$Mu+zo1KVH0EgNmx1c`VoyB<9H%r zdx}_>IkV!>M3TN$HY@y*<&zmBfuFPn6C&n(+HRJ+K>a1}pr0ezb#76egxEpj{@CuC zbrQ?C>UCw_g^}V(_4R$3DdD`39zJNpD6z3LMMCb6OIvqpGM#{2!Hvk+ST$B*Bki{3 z`~+1)LnTW3qxg$2W*I#W3CGqIo_lsX4P(xI(DUcYcc(0%e;hiyZ>$5^Kbp7W4{KJe zuCBh?=7Ytm_A@l9Gz>y5Ir)tKV5y6{k(8nVop2q^$UL?@eEYk5fzElMP&oYz{^-|T zB?;C)qY4aPla!T!Fb>P)hJ5LRzThjBHakn1zx6)iCe~!G=A04MuL=rUg~x0_4i|hb z_`J|Q7G{P>Zv+|#o|pSpqIBbI-dpkvX}RleFSmzR~1g;hAY=E%%{dg@?# zoN?#!!=|jvLbMR8pg_>jLmlz_e>V6TKkdWl1XS6IjO3LR*TtgJ&sVKhjs+d~{dy;K z^0jn#H3KybHrl znm62fnMnzyh#*qjZkR=CRjwe{hoRz4pUi_6i-Bj@&AVK+vIm^alTfRZ4C>lvoQLPs zy*0wpnZzc&)cot$!$eb~PCVWV@K-_`|GwY$Q=hCp7f(M1NVA7%As>$|q34%3$p(EQ zg81exK>jR;mpEN5q@X%I*RaN@d-zPYXfI_{>>Rz2P%6>HglIeULS-&(qe2DvV`JF_ zGl`}HvSL`SLnpS-opyH1MgL}}rAkH&Gq0@{Nfqk!_c*sV&ZLB!R4oB79FwI(#Wbsv z3fS;AFyPPS&+QxE0yOZ_@`GQC`U%4q$2mgQgQ+KL5iXoIZ-(&iZs6x6WT}VassBLS z?Dk$w5d34ttz|pCjMtt5)aa&`^0+Zmz9Ftbtg4r!77?D=SNi zag@GzE2=^Ez*uv&YD>doZ`>`llwDp`wK=MRFt?R9t*vQq& z<$N}dBi5SzSE~&`*=RMfJ6W-So}T{72GNT@Cp$Z>6C!9-wLiPCV5#M97%PEu8>t*P zGJW(n9g9ZCy!Wvb68$SSc-CTaSNQSb(APuVLj!AJ{oRg}laakSEXH%hZSaRP=_I%i zd+fnG){_x4f|b!?{#~7&ogH#~d~7@yxy-|O_T$s$P?xf@vcrnm1A_^ZS zx=;IlpoQsxwysSpRR%qod%fymazhu)OQi6i>*krf;V?0Yl!)CgYk52J?pA?o!VTXh z9zuqOQzZ`W5*BfYX;JXbFIO4a?-`J{L}U{ef z75pVwqHJ=@(tO4B)s%b(rd~gP5gIbSW`HOGX_wGLH>jds^#T4gdQ9@Qm zzmo2=puQ_gxvd&J)X_;ZG81=QIhQeN)J!Pif^d@yEL{KsU5hqPVJX*GVgKZbAL!hE zYGeC?oSgfk!3#n;H1|OAm)`8-ma9%X+CKZM{lcgaFIsC5($cO;52kr$i-#H-8a_5Q zIhDPkYWYr{lJI*kG9iiEmTPvvKkr16_u3-m*2g67v|LJqF<_SnVpj(78({t%Ff7` zhsPQWs^R+Kzo37%xlLTm^Sid_P}F(ASojN$+7Ec`#U)u_v7Jl}@T1UJSEtS5R8k)$ zN+~@>AS!I)OAw&k4p}c!pS6fhz;hhiduQ;c(D|Z&VewsMm?ux3h=__7zY4MrVS1^D zH_N}N2wi9Vvk?_>v9FeBoLx{b>G1o22tNW4Ol>-vw^XIqwN+=du^;o4wrw6G~i;L^o&VK)R5t>E~Wbml`>*zviwZ9!h8V|@a`dSb9n|1l9x@}3SEI9TE z{6*rT`VUQeU1nu1{LM~o6MR>!mbT-wi1%F?oi4C3yFK|6DN%CEQaI0b%0gD+Vg!D| zAH;#hV`b&WrTqN-$L#FvqV-L`d|$tQO+R5@3Ur+H6>sl6#fA@!Q&m;fkcDCP%D$nmI`P!ZPL z&3pGQBOl{Zuz;v7H7bS*l}$lGv9EPox2}?_byuc2jMFD4IeDr*-|*`dhgKImu>(;! zDuz?WV|&4-pnA6_NyhX0sC|u4`K)Bw7M5G?CF05BXU_<#wqh6Zout+n@*jxSmtK>W z_9`zeecv8LeRd}rb^ii}aAq%e`~05A69c45`*NAt|E0Y#D2 zW*mi2fV+7enR%xldpUw1F)s<+A-VvIP#7EvgTyBQpWXrQ>A;uSGCf$@!n@R}ii(Pn z<(<_yH9N;}qxV;-SU$|?{Rn<;#!&Y?jW7TzvL%GAmzM#D-@BL?YsEq`6)Z*4jFpXz z=gUA0IG0heFfuYSbaQi4m5!65Xlv{HePd(1B&5-$;ceqSt-8AUxcLx878zf%+wDaE z2Uhm>a&4Vmpl2fCFnAhuz4^mBoq)hV27el0@HQ6$sf4bThG;uQm6en@fBEvo5TxO2 z1Vx+(jEt{AW3s=$M80&(rWA!f=VwCS(u4;8XyCanLI+2{GKI$r{GLlbL47}ag-#U8 z1O*SgB&v-&B{$-n-@$>$4j);~Bqy=kjS7v^W`*A~J}@GG+C2LCX`rQ*l@=XnR9plA zgMWuY`z<~IR460biAU$S^nA3MOsF80KN-&Xed4RHe^@tN!DgB6?_YH7(}dX7tMa38MwPz#tLj8!-pYH2ZA5r^H@COR(XU({x9bU1tj(|P!?tdro{*U|zn z*t2txOuWGpf%#q*85G)bF!4ZMD|#G1d@#B*=e`;@!pHa0f;1_lO3ciOdNX-?f4grnuk zpNEJicA8^*85w_{>3I>{$pBG+`J>LUu|gV;(cvHa)SQkSkbiud*%>Z@G{nPAO}Uid zhycv}TAq?fFP`uc0ER!PL?Pf~Yg@##ckkY%H8(fk?7L+{*Zx{^#=;j^q?yAB>yPf=zptjGO>XJn;2@91VjroX&AI*c zOq+PF&+Hvmd$F|tb{zL2ZvO7G8O$H=Ot}^LZ7;$88}aMP%1Ss85*oTIceLJ6w%o(e zN)P$TKuc@N!peHz%*-tF`Q$h6BLhc`lCq>A+A{#xG&v&7xZ2~!Wnp1q16)zX`0|dU zpR6bh&KTZ_p|>nR#ZGaU^Yh(e0^uew{r>xf@G3Y`=vpcd6&O*bR**Y-KzKYA6!fe< zmPvA00+~$#&sSj$#5#o0VbA%ll|p^Z_H{CxnOU zC!__k3>1B)M^fuiVm5APy4OkKs`V%l5=kb4aEVgk;<@S4&V(&MzT95}@=Q50h}NNfH~ zxoQxa2qgM*C<7ePaS2^1d*)vHmgZ+F$kK{|#IXN zR8S04&l9{Ugej`@;ok19Y=>gu#>R6O_=87}vgzvR&k@=D5pAO*BNGctOO-do#KgF5 znMCesYximy8J%QKHK1KjWIT5>vw|yTD@~f7cm;rY)K1j)Wj|^my!Y9^$y?mYR<|(! zR*`qt#!U01wWXzaF^D4?1INVCPur@Vo`=KhN3#a}!Dt*h8)?gjzSPjrKv_?Y1LYwc z!G**m0iS!iO8(#IiVAg+Nx^oiSA-jhLZT5LWx2^MZEbVEf!yCeI7oBl80#Z*n*thL ziN+oD?p-=3H4(}orKG0z#u}%V-}V*^CgfRg)(q?xe1i+W(?IppIQqGs0xo}tEC7=g zT)3`V=x#?5zbNltyW2f)@{9b;S%uG(b4f#cm*(>`QORvqUy@tq1fsT zfvfTt&^QDH1-%wjoI<+ja3R@M(F#EwqJpr<%@sL8 zrTRO!Fk=clxn%D-z!!zIXo_XvEjdj@AtJcfg_q|iq@DgOmkCPSyYfn7lO#^R_RcLO zv{ausv|bV?UMpB0Vo~>3_55kTX%x}}%RsLd_>5KsgU?3<#;wTxF^!1|j!Lq%vpbpU z?!NcH&hE;q-+ccokWz zsH{ODa=UucYpY`tEU*FtFsZ4j#|$8u5j?gPixhD?WMpLejg5^u`uc3vV`cW7^ZZ@Y z$4aK}0R*EQd}<@-VS)MY-@jkc+`C!kSv_%+oA8u(cKf7O+tx-I9cqeQahmUB*>aVa zm;WxCkC$|LO+iUHa7+P()N8KFFm=3I`AJ#=0QJr(%+#~Y(-{TSY_J4Mo z(?osYLFU!81ZiXYKZQnhlRq~O7}N#YDNZSzFM0kf z53~POu4^Z;z_l5MozU^>T#xQ3_3|e-{!26Io>JA$B|R&vELWBlTm)Vo{<$hIk3jd0 z7N3y7{+#VwT3T8vs9&`j3ci2dS{p7_RabB49i$UIJ0p7i#toMQ6ZSNE`M_wq`|!_q z;aWFDL~8dIACfP><{90)AcsB53kV1h{pw1ntOZ++hIf2Q%F6I`U7DT$)>Y87Qx%Cc z0LnF(PD2DbM-_f>cQYm?Ccm__w9SBT({|Go2bjPq1V6k-GmM^Ih6ttU)4zRp{yhbl z()wdpAJ*+({jLxsR0ktRzQO_WxP!OMNR7L-RPxKq%LCs(Vry!Wc*r1k43TK>MNgKu z9QMtj94VhZDc=AMGgE;gkO&(1J^XX1|MnOH8UF3`M#O6=9zT9;EF>VHH@!vFc%ZBM zj5hsp4D|BE!otG%hpV4zYVbwH#gnbAt@=W3vVjI*s3^xDZ_Rh@fhC(zv>CqH3v6N} zT)K2=VWB5=``1f`Z(x6f_VOf6kq-%5B+Ax$Lgwn~dT?@bBHZSRZ6dhZ%et=Uxp{hi z#e=+AQscfc9rbxhrLU)FLqJN71`%rzYh`@qBiOC@ZIDh7bdOJIBh<=g#4hbmmA*n8 zxv#Jo6_kzALL{9Rx^rH>eEA5*V~A5xRee*xY)%#afIp?fTnT;JS9ygn{UY%FZtwI9 z5Z&-_eW)or1m1a2z>~sVw3+gl93JjX-f|XN8#@K6#edb?Q-So?L3wr_RK%9%Nc0@l zy%8EfYN9hyY7p$*M8qUI@2!q|O>QWHRWgL zS`MK9Mg06|R|GDu7t!{khaOUopnYFXkNI~Y-TewIq@`L>X0V`C&MOrc-{R6f5ZTY4 z??(+Kx=q|RuvQd_RJw=54s>^~Kf6Nk623L@Gfz|&vn4fIp{3Vxk_-JP@FNa1KV)WR zre+mGI?>TH)Iv`Gl9x z5`@g;RzKCQIJ{1|@NV&F3aVphc>M0ij~~roiWeLW!mY@_`hFF!{ych8d8~GsZ#hhy z%ZPTZt$KqpfR_Ai5q`TG&~kjo4pu{og@4_WVFXR(b)LB(H7)g6Vq=x3sv|U`F1cKH zMatRn;6Ie?7H#VJV#S~2?*w3Gxn&MaL|~#W@SAk9eG?i{7EjEF)!JIJcmIO`mc{&c z0sPbnLftbkaJ!jw$gn-NCP({ad~@;ff(5%zx}DtCsKtWLAVR+tTf_(y)Os8(4dm#* zc=3WcCP6S^wA#pi)Rx#91%Y0#mP0_;+Uh^iWDImA$=K*0wo=y~`1<$@NyfW|8oJOaKVM>G#(?S}H_-uv?iOrD z*`K$S8HaD_)0$HH-e*a6fzK^*>2iT375T>A#PQ>W<-Ge20_g_rV)0z?J>_REGjU zk~CL=MPFGs(80uY$~E$V@DUk~>bCODDvn`Y0=D|?BwLc&6lip=Z@r$wl>bhXBVw&Iz-9jfnGSgdyL#t z4puu+F@>upx2)+PuZ^J3ibJBae8=Z-y}oIjaKG8;TN-hfYKiKI)AGU90AtAfHO#l& zyd(Zb`bhJdy}h$H9}=j2ga&}Zs|WzFrG)_iI~fF^(%O6_9Ar6KggX{q=p`Y5a74k; zcvq@j8+6wvUyY>r&xE(!J@|EA?^fH^ZKTM24eu`xjPi#QRYMlep)^Up1sS8SBWq z)jok$*Gtz5J+aO?!(wDfW~&~@E{zL&?MfzYVurLi#oVw1*);Z-JAK}q+II41wDjuR z2fwanT=6hu*oyAl6NG23kHt?|Qi`q+4^%S~Ff#P;OcPUGmv8uZXjpn19p?8X->85vv)D^XIWWrc1?wFu9+;sGehm}B%k&1 zWZ7Sv_B)?_p078Q7tLspatjNV$>e+glB;APB#=w(s*}H3+U5Ffx3sX3R4VgpS!`Q0 z?Xjzoni-`=^U;FGkn!#qxr^t@&NW4X=xCWetEMc`H1lM$O}OvArVQ_m>HV2ChDg1V zAbR{%y94ux(az#m+OkBu;TJoFq_|Gutly>DRIT#&wIHm9q+v{6JM*Djh4^29@%1su ztWMeM*O{ry@ZP)niwz-kVRFeaX4mztM;|d`cot(4A2J+nAGgkg-D!1)bU*gpF4UAh z(kLX$HTUUDS5K*|l+>}f>OS>~H7ytj+*b)Cy8h_2saUD?xQtloqI2C{hkpc%jR=X| znw_65!@F6QT0l+4<|>mRVT)$>;lc=s{_EwB11oKg2U+X-cn*;yk;x!9WcQG6(MS4B zw`uO~-P#(TCr$zfnTLxE(QzYhp}S(sBcyURczf`xMm4694A#OLrNNUx04GsrVp&2K zGFtKi%I$lsV0g$01(>*wkkQZQ0==IefN!`EUjXO7KOu+J4K5X;86st0Ic9(WPJY5y zm8E`_t2aSWLu-RyNYB~8on6@wX{cgJqBf7uov+>Ba&HN4xwHzMexs;b&g&|8BblTpC@`2yM;CnP78 zLVt}c4CWW+k5*0qa)0WFD}hk!0%I{Rl@ZOq@xq@B4%7toKX mZNkw1%@MUt|IkLuw= capacity + +/// The gauge on its side: 0, 25, 50, 75 or 100. Any charge at all shows a quarter. +/obj/item/anomaly_battery/proc/charge_level() + if(stored_charge <= 0) + return 0 + return clamp(round(charge_fraction() * 100, 25), 25, 100) + +/// The energy kind its core glows with, or null when it holds nothing. +/obj/item/anomaly_battery/proc/held_kind() + if(!held_effect) + return null + return artifact_energy_kind(held_effect.energy_kind) + +/// The kind to show: null while it holds no charge, so an empty core stays dark. +/obj/item/anomaly_battery/proc/lit_kind() + return stored_charge > 0 ? held_kind() : null + +/// Whether it still holds the effect drawn from `source`. +/obj/item/anomaly_battery/proc/holds_from(datum/artifact_effect/source) + return held_effect && source && source_ref?.resolve() == source + +/// Whether a harvester may pour `source`'s energy in: it is empty, or it already holds that same effect. +/obj/item/anomaly_battery/proc/can_take(datum/artifact_effect/source) + if(!source || is_full()) + return FALSE + return stored_charge <= 0 || holds_from(source) + +/// Readies it to take `source`'s energy, copying the effect unless it already holds it. +/obj/item/anomaly_battery/proc/load_from(datum/artifact_effect/source, serial) + if(holds_from(source)) + return + discard_effect() + held_effect = source.make_harvest_copy(src) + source_ref = WEAKREF(source) + source_serial = serial + stored_charge = 0 + update_appearance() + +/// Adds charge, up to capacity. Returns how much went in. +/obj/item/anomaly_battery/proc/add_charge(amount) + if(!held_effect || amount <= 0) + return 0 + var/added = min(amount, capacity - stored_charge) + if(added <= 0) + return 0 + var/old_level = charge_level() + stored_charge += added + if(charge_level() != old_level) + update_appearance() + return added + +/// Spends charge. Returns how much came out. +/obj/item/anomaly_battery/proc/use_charge(amount) + if(amount <= 0) + return 0 + var/used = min(amount, stored_charge) + var/old_level = charge_level() + stored_charge -= used + if(charge_level() != old_level) + update_appearance() + return used + +/// Empties it and lets go of the effect it held. +/obj/item/anomaly_battery/proc/purge() + discard_effect() + source_serial = null + stored_charge = 0 + update_appearance() + +/obj/item/anomaly_battery/proc/discard_effect() + if(held_effect?.activated) + held_effect.deactivate() + QDEL_NULL(held_effect) + source_ref = null + +/obj/item/anomaly_battery/update_icon_state() + icon_state = "[base_icon_state][charge_level()]" + return ..() + +/obj/item/anomaly_battery/update_overlays() + . = ..() + . += anomaly_core_overlays(src, "battery_core", lit_kind(), charge_fraction()) + +/obj/item/anomaly_battery/examine(mob/user) + . = ..() + if(source_serial) + . += "A strip on its side is stamped [source_serial]." + . += anomaly_core_description(lit_kind(), charge_fraction()) + +// ===== Shared with the utilizer and harvester ===== + +/** + * The glowing core of a battery as overlays: the tinted mask and its emissive, brighter with + * more charge. `state` is the mask's icon state on `owner`'s icon. Empty while the core is dark. + */ +/proc/anomaly_core_overlays(atom/owner, state, datum/artifact_energy_kind/kind, fraction) + . = list() + if(!kind || fraction <= 0) + return + var/core_alpha = round(110 + 145 * clamp(fraction, 0, 1)) + var/mutable_appearance/core = mutable_appearance(owner.icon, state, alpha = core_alpha) + core.color = kind.color + . += core + . += emissive_appearance(owner.icon, state, owner, alpha = round(core_alpha * 0.7)) + +/// "Its core glows a steady amber." or "Its core is dark." +/proc/anomaly_core_description(datum/artifact_energy_kind/kind, fraction) + if(!kind || fraction <= 0) + return "Its core is dark." + var/strength = "bright" + if(fraction < 0.34) + strength = "dim" + else if(fraction < 0.67) + strength = "steady" + return "Its core glows a [strength] [anomaly_glow_word(kind.key)]." + +/// A plain colour word for an energy kind's glow. +/proc/anomaly_glow_word(kind_key) + switch(kind_key) + if(ARTIFACT_ENERGY_CONCENTRATED) + return "amber" + if(ARTIFACT_ENERGY_PSIONIC) + return "violet" + if(ARTIFACT_ENERGY_ELECTROMAGNETIC) + return "pale blue" + if(ARTIFACT_ENERGY_PARTICLE) + return "white" + if(ARTIFACT_ENERGY_ORGANIC) + return "rose red" + if(ARTIFACT_ENERGY_BLUESPACE) + return "deep blue" + if(ARTIFACT_ENERGY_SYNTHESIS) + return "green" + return "strange light" + +/datum/design/anomaly_battery + name = "Exotic Battery" + desc = "A cell built to hold what a harvester draws off an alien find." + id = "xenoarch_battery" + build_type = PROTOLATHE | AWAY_LATHE + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT, + /datum/material/plasma = SHEET_MATERIAL_AMOUNT, + /datum/material/gold = HALF_SHEET_MATERIAL_AMOUNT, + ) + build_path = /obj/item/anomaly_battery + category = list( + RND_CATEGORY_EQUIPMENT + RND_SUBCATEGORY_EQUIPMENT_SCIENCE + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE diff --git a/voidcrew/modules/xenoarch/use/harvester.dm b/voidcrew/modules/xenoarch/use/harvester.dm index 0709c090d39..8de9efea73c 100644 --- a/voidcrew/modules/xenoarch/use/harvester.dm +++ b/voidcrew/modules/xenoarch/use/harvester.dm @@ -1 +1,484 @@ -// The harvester: drains one active effect from a find into a battery. +/** + * # Exotic particle harvester + * + * Draws one awake effect off an alien artifact standing next to it and packs it into an exotic + * battery, which a utilizer can then run. Each artifact gives ARTIFACT_MAX_HARVESTS harvests in + * all, and every harvest taken lowers its sale price (core pricing reads harvests_taken()). + * + * Rewritten from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/machines/artifact_harvester.dm, + * with its HTML window replaced by tgui (ArtifactHarvester.tsx). Voidcrew changes: + * - A harvest is spent the moment the first charge reaches the battery, so stopping early or + * losing power partway never refunds it, and a draw that never started costs nothing. + * Nova copied the effect for free, forever. + * - The find's lock (try_lock()/release_lock()) is held for the whole draw and released on + * finish, on halt and in Destroy(). Nova anchored the find and never let go of it on halt. + * - It reads any find on the eight tiles around it, so it works beside a scan pad or without one. + * - Effects marked unharvestable won't come off; two effects awake at once can't be separated. + * - A battery can be topped up only from the effect it already holds; an empty one takes anything. + * - While it draws, a stream of motes in the energy's colour runs from the find to the dome, the + * find throbs and crackles with its own sound, and the console hums. + * + * Art: Nova's machinery.dmi harvester states (CC BY-SA 3.0, pixel-checked clean against Goon), + * a dome mask drawn for Voidcrew, and tg's drain_life beam made greyscale so it can be tinted. + * + * Ship saves: the harvester is rebuilt from its board with an empty slot (its battery is an item + * and clear_stock() deletes it) and no draw in progress, so a save never copies a charge. + */ + +/// What the readout says about the find beside it. +#define HARVEST_SOURCE_READY "ready" +#define HARVEST_SOURCE_QUIET "quiet" +#define HARVEST_SOURCE_TANGLED "tangled" +#define HARVEST_SOURCE_UNFIT "unfit" +#define HARVEST_SOURCE_SPENT "spent" +#define HARVEST_SOURCE_BUSY "busy" +#define HARVEST_SOURCE_MISMATCH "mismatch" +#define HARVEST_SOURCE_DRAWING "drawing" + +/obj/machinery/artifact_harvester + name = "exotic particle harvester" + desc = "A console wired to a collector dome. It pulls the energy off an alien find and packs it into a battery." + icon = 'voidcrew/modules/xenoarch/icons/use.dmi' + icon_state = "harvester" + base_icon_state = "harvester" + density = TRUE + anchored = TRUE + circuit = /obj/item/circuitboard/machine/artifact_harvester + idle_power_usage = BASE_MACHINE_IDLE_CONSUMPTION + active_power_usage = BASE_MACHINE_ACTIVE_CONSUMPTION * 2 + processing_flags = START_PROCESSING_MANUALLY + // Its window opens unpowered too (dark scope, no draw), so a battery is never stuck inside. + interaction_flags_machine = INTERACT_MACHINE_WIRES_IF_OPEN | INTERACT_MACHINE_ALLOW_SILICON | INTERACT_MACHINE_OPEN_SILICON | INTERACT_MACHINE_OFFLINE + /// The battery in its slot + var/obj/item/anomaly_battery/battery + /// Drawing right now + var/drawing = FALSE + /// The find it draws from, while it draws + var/datum/weakref/source_ref + /// The effect it draws, while it draws + var/datum/weakref/drawn_ref + /// Whoever started the draw, for the harvest record + var/datum/weakref/operator_ref + /// This draw has already taken one of the find's harvests + var/draw_counted = FALSE + /// Charge per second, from its capacitors + var/draw_rate = ARTIFACT_HARVEST_DRAW_RATE + /// The stream of motes between the find and the dome + var/datum/beam/draw_beam + /// The hum while it draws + var/datum/looping_sound/artifact_harvester/soundloop + /// world.time of the next crackle off the find + var/next_crackle = 0 + +/obj/machinery/artifact_harvester/Initialize(mapload) + . = ..() + soundloop = new(src) + +/obj/machinery/artifact_harvester/Destroy() + stop_draw() + QDEL_NULL(soundloop) + QDEL_NULL(battery) + return ..() + +/obj/machinery/artifact_harvester/RefreshParts() + . = ..() + var/capacitor_tier = max(total_part_rating(/datum/stock_part/capacitor) / 2, 1) + // Tier-1 capacitors fill a battery in 80 seconds, tier-4 in 40. + draw_rate = ARTIFACT_HARVEST_DRAW_RATE * (1 + (capacitor_tier - 1) / 3) + +/obj/machinery/artifact_harvester/examine(mob/user) + . = ..() + if(battery) + . += "An exotic battery is seated under the dome." + if(drawing) + . += span_notice("Its dome hums, and a thin stream of light runs into it.") + +// ===== Battery slot ===== + +/obj/machinery/artifact_harvester/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!istype(tool, /obj/item/anomaly_battery)) + return NONE + if(battery) + balloon_alert(user, "slot's taken!") + return ITEM_INTERACT_BLOCKING + if(!user.transferItemToLoc(tool, src)) + return ITEM_INTERACT_BLOCKING + battery = tool + user.visible_message( + span_notice("[user] seats [tool] under the dome of [src]."), + span_notice("You seat [tool] under the dome of [src]."), + ) + playsound(src, 'sound/machines/terminal/terminal_insert_disc.ogg', 40, TRUE) + update_appearance() + SStgui.update_uis(src) + return ITEM_INTERACT_SUCCESS + +/obj/machinery/artifact_harvester/Exited(atom/movable/gone, direction) + . = ..() + if(gone != battery) + return + battery = null + stop_draw() + if(!QDELETED(src)) + update_appearance() + +/// Hands the battery back, or drops it in front of the harvester. +/obj/machinery/artifact_harvester/proc/eject_battery(mob/living/user) + if(!battery) + return FALSE + if(drawing) + balloon_alert(user, "it's drawing!") + return FALSE + var/obj/item/anomaly_battery/ejected = battery + ejected.forceMove(drop_location()) + if(user && Adjacent(user)) + user.put_in_hands(ejected) + playsound(src, 'sound/machines/terminal/terminal_eject.ogg', 40, TRUE) + return TRUE + +/// Vents the battery: its charge and the effect it held are gone, and it can take anything again. +/obj/machinery/artifact_harvester/proc/purge_battery(mob/living/user) + if(!battery || drawing || !battery.held_effect) + return FALSE + var/datum/artifact_energy_kind/kind = battery.lit_kind() + battery.purge() + if(kind) + visible_message(span_notice("[src]'s dome flares [anomaly_glow_word(kind.key)] and goes dark with a hiss.")) + flick_dome(kind.color) + playsound(src, 'sound/machines/steam_hiss.ogg', 40, TRUE) + update_appearance() + return TRUE + +// ===== Reading the find ===== + +/// The find it would draw from: an artifact on a tile around it, preferring one that is awake. +/obj/machinery/artifact_harvester/proc/find_source() + var/obj/machinery/artifact/fallback + for(var/obj/machinery/artifact/candidate in orange(1, src)) + if(QDELETED(candidate) || !isturf(candidate.loc)) + continue + if(candidate.is_active()) + return candidate + fallback ||= candidate + return fallback + +/// Whether a find is still close enough to draw from. +/obj/machinery/artifact_harvester/proc/in_reach(obj/machinery/artifact/source) + return !QDELETED(source) && isturf(source.loc) && source.z == z && get_dist(src, source) <= 1 + +/// The one awake effect a draw would take, or null when none or both are awake. +/obj/machinery/artifact_harvester/proc/awake_effect(obj/machinery/artifact/source) + var/datum/artifact_effect/found + for(var/datum/artifact_effect/effect as anything in source.effects()) + if(!effect.activated) + continue + if(found) + return null + found = effect + return found + +/// What the readout says about `source` right now (a HARVEST_SOURCE_* key). +/obj/machinery/artifact_harvester/proc/source_state(obj/machinery/artifact/source) + if(source.harvests_left <= 0) + return HARVEST_SOURCE_SPENT + if(source.is_being_used() && source.locked_by?.resolve() != src) + return HARVEST_SOURCE_BUSY + if(source.suppressed) + return HARVEST_SOURCE_QUIET + var/awake = 0 + for(var/datum/artifact_effect/effect as anything in source.effects()) + if(effect.activated) + awake++ + if(awake > 1) + return HARVEST_SOURCE_TANGLED + var/datum/artifact_effect/effect = awake_effect(source) + if(!effect) + return HARVEST_SOURCE_QUIET + if(!effect.harvestable) + return HARVEST_SOURCE_UNFIT + if(battery && !battery.can_take(effect) && !battery.is_full()) + return HARVEST_SOURCE_MISMATCH + return HARVEST_SOURCE_READY + +// ===== Drawing ===== + +/// Starts a draw from the find beside it. Returns TRUE if it started. +/obj/machinery/artifact_harvester/proc/start_draw(mob/living/user) + if(drawing) + return FALSE + if(!is_operational) + return refuse(user, "no power!") + if(!battery) + return refuse(user, "no battery!") + if(battery.is_full()) + return refuse(user, "battery's full!") + var/obj/machinery/artifact/source = find_source() + if(!source) + return refuse(user, "nothing to draw from!") + if(source_state(source) != HARVEST_SOURCE_READY) + return refuse(user, "no clean signal!") + var/datum/artifact_effect/effect = awake_effect(source) + if(!effect || !source.try_lock(src)) + return refuse(user, "no clean signal!") + + battery.load_from(effect, source.serial) + drawing = TRUE + draw_counted = FALSE + source_ref = WEAKREF(source) + drawn_ref = WEAKREF(effect) + operator_ref = user ? WEAKREF(user) : null + next_crackle = world.time + 3 SECONDS + + var/datum/artifact_energy_kind/kind = artifact_energy_kind(effect.energy_kind) + QDEL_NULL(draw_beam) + // The motes run from the find into the collector dome on the right of the sprite. + draw_beam = Beam(source, icon_state = "harvest_draw", icon = 'voidcrew/modules/xenoarch/icons/use.dmi', beam_color = kind.color, override_origin_pixel_x = 7, override_origin_pixel_y = 10) + soundloop.start() + playsound(src, 'sound/machines/terminal/terminal_prompt_confirm.ogg', 40, TRUE) + visible_message(span_notice("[src] hums, and a thin stream of [anomaly_glow_word(kind.key)] light reaches out to [source].")) + update_use_power(ACTIVE_POWER_USE) + begin_processing() + update_appearance() + return TRUE + +/// Buzzes and says why it won't start. Always FALSE. +/obj/machinery/artifact_harvester/proc/refuse(mob/living/user, reason) + playsound(src, 'sound/machines/buzz/buzz-two.ogg', 30, TRUE) + if(user) + balloon_alert(user, reason) + return FALSE + +/obj/machinery/artifact_harvester/process(seconds_per_tick) + if(!drawing) + return PROCESS_KILL + var/obj/machinery/artifact/source = source_ref?.resolve() + var/datum/artifact_effect/effect = drawn_ref?.resolve() + if(!is_operational) + stop_draw("[src]'s dome flickers and goes dark.") + return + if(!battery || QDELETED(effect) || !in_reach(source) || !source.try_lock(src)) + stop_draw("[src] whines down as the stream of light snaps.") + return + var/added = battery.add_charge(draw_rate * seconds_per_tick) + if(added > 0 && !draw_counted) + count_harvest(source, effect) + if(world.time >= next_crackle) + next_crackle = world.time + rand(5 SECONDS, 8 SECONDS) + var/datum/artifact_energy_kind/kind = artifact_energy_kind(effect.energy_kind) + playsound(source, kind.pulse_sound, 25, TRUE) + source.throb(1.06) + flick_dome(kind.color) + if(battery.is_full()) + finish_draw() + +/// Takes one of the find's harvests. Runs once per draw, when the first charge arrives. +/obj/machinery/artifact_harvester/proc/count_harvest(obj/machinery/artifact/source, datum/artifact_effect/effect) + draw_counted = TRUE + source.harvests_left = max(source.harvests_left - 1, 0) + var/mob/user = operator_ref?.resolve() + SEND_SIGNAL(source, COMSIG_ARTIFACT_HARVESTED, effect, user) + record_metric( + category = METRIC_RESEARCH, + event = XENOARCH_METRIC_HARVESTED, + zone = xenoarch_band_name(source.origin_band), + subject = "[effect.type]", + details = list( + "serial" = source.serial, + "harvests_taken" = source.harvests_taken(), + "kind" = effect.energy_kind, + "release" = effect.release_method, + "strength" = effect.strength, + ), + actor = user, + location = src, + ) + log_game("[user ? key_name(user) : "Nobody"] drew [effect.log_name] off artifact [source.serial] with [src] at [AREACOORD(src)]; [source.harvests_left] harvests left.") + +/obj/machinery/artifact_harvester/proc/finish_draw() + var/datum/artifact_energy_kind/kind = battery?.held_kind() + stop_draw() + playsound(src, 'sound/machines/chime.ogg', 40, FALSE) + if(kind) + visible_message(span_notice("[src] chimes, and its dome settles to a steady [anomaly_glow_word(kind.key)] glow.")) + else + visible_message(span_notice("[src] chimes.")) + +/** + * Ends a draw: lets go of the find, cuts the stream and the hum. Safe to call when idle and from + * Destroy(). `message` is shown to anyone watching. + */ +/obj/machinery/artifact_harvester/proc/stop_draw(message) + if(!drawing) + return + drawing = FALSE + var/obj/machinery/artifact/source = source_ref?.resolve() + source?.release_lock(src) + source_ref = null + drawn_ref = null + operator_ref = null + draw_counted = FALSE + QDEL_NULL(draw_beam) + soundloop?.stop() + end_processing() + if(QDELETED(src)) + return + update_use_power(IDLE_POWER_USE) + update_appearance() + SStgui.update_uis(src) + if(message) + visible_message(span_warning(message)) + playsound(src, 'sound/machines/terminal/terminal_off.ogg', 40, TRUE) + +// ===== Looks ===== + +/obj/machinery/artifact_harvester/update_icon_state() + if(drawing) + icon_state = "[base_icon_state]_on" + else if(battery) + icon_state = "[base_icon_state]_loaded" + else + icon_state = base_icon_state + return ..() + +/obj/machinery/artifact_harvester/update_overlays() + . = ..() + if(!battery) + return + var/datum/artifact_energy_kind/kind + var/fraction = battery.charge_fraction() + if(drawing) + var/datum/artifact_effect/effect = drawn_ref?.resolve() + kind = effect ? artifact_energy_kind(effect.energy_kind) : battery.held_kind() + // The dome is lit the whole draw, even while the battery is still nearly empty. + fraction = max(fraction, 0.6) + else + kind = battery.lit_kind() + . += anomaly_core_overlays(src, "harvester_dome", kind, fraction) + +/// A brief flare of the dome in `color` that fades back to whatever it shows. +/obj/machinery/artifact_harvester/proc/flick_dome(color) + var/mutable_appearance/flare = mutable_appearance(icon, "harvester_dome") + flare.color = color + var/atom/movable/flare_visual = flick_overlay_view(flare, 0.8 SECONDS) + if(flare_visual) + animate(flare_visual, alpha = 0, time = 0.8 SECONDS, easing = SINE_EASING | EASE_IN) + +// ===== Tools ===== + +/obj/machinery/artifact_harvester/screwdriver_act(mob/living/user, obj/item/tool) + if(drawing) + balloon_alert(user, "it's drawing!") + return ITEM_INTERACT_BLOCKING + if(default_deconstruction_screwdriver(user, icon_state, icon_state, tool)) + update_appearance() + return ITEM_INTERACT_SUCCESS + return NONE + +/obj/machinery/artifact_harvester/crowbar_act(mob/living/user, obj/item/tool) + if(drawing && panel_open) + balloon_alert(user, "it's drawing!") + return ITEM_INTERACT_BLOCKING + if(default_deconstruction_crowbar(tool)) + return ITEM_INTERACT_SUCCESS + return NONE + +// ===== Window ===== + +/obj/machinery/artifact_harvester/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "ArtifactHarvester", name) + ui.open() + +/obj/machinery/artifact_harvester/ui_data(mob/user) + var/list/data = list() + data["powered"] = is_operational + data["drawing"] = drawing + var/obj/machinery/artifact/source = drawing ? source_ref?.resolve() : find_source() + if(source) + var/datum/artifact_effect/effect = drawing ? drawn_ref?.resolve() : awake_effect(source) + var/datum/artifact_energy_kind/kind = effect ? artifact_energy_kind(effect.energy_kind) : null + data["source"] = list( + "serial" = source.serial || "untagged", + "state" = drawing ? HARVEST_SOURCE_DRAWING : source_state(source), + "color" = kind?.color, + ) + else + data["source"] = null + if(battery) + var/datum/artifact_energy_kind/kind = battery.lit_kind() + data["cell"] = list( + "fill" = round(battery.charge_fraction(), 0.01), + "color" = kind?.color, + "kind" = kind?.report_name, + "serial" = battery.source_serial, + ) + else + data["cell"] = null + return data + +/obj/machinery/artifact_harvester/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/living/user = ui.user + switch(action) + if("draw") + start_draw(user) + return TRUE + if("halt") + if(drawing) + stop_draw("[src] whines down.") + return TRUE + if("eject") + eject_battery(user) + return TRUE + if("purge") + purge_battery(user) + return TRUE + +// ===== Board, design and sound ===== + +/obj/item/circuitboard/machine/artifact_harvester + name = "Exotic Particle Harvester" + greyscale_colors = CIRCUIT_COLOR_SCIENCE + build_path = /obj/machinery/artifact_harvester + req_components = list( + /datum/stock_part/capacitor = 2, + /datum/stock_part/scanning_module = 1, + /datum/stock_part/micro_laser = 1, + ) + +/datum/design/board/artifact_harvester + name = "Exotic Particle Harvester Board" + desc = "The circuit board for an exotic particle harvester." + id = "xenoarch_harvester" + build_path = /obj/item/circuitboard/machine/artifact_harvester + category = list( + RND_CATEGORY_MACHINE + RND_SUBCATEGORY_MACHINE_RESEARCH + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE + +/datum/looping_sound/artifact_harvester + start_sound = 'sound/machines/gravgen/grav_gen_start.ogg' + start_length = 1 SECONDS + mid_sounds = list( + 'sound/machines/gravgen/grav_gen_mid1.ogg' = 12, + 'sound/machines/gravgen/grav_gen_mid2.ogg' = 1, + ) + mid_length = 1.1 SECONDS + end_sound = 'sound/machines/gravgen/grav_gen_end.ogg' + volume = 30 + extra_range = -4 + falloff_distance = 2 + falloff_exponent = 5 + vary = TRUE + +#undef HARVEST_SOURCE_READY +#undef HARVEST_SOURCE_QUIET +#undef HARVEST_SOURCE_TANGLED +#undef HARVEST_SOURCE_UNFIT +#undef HARVEST_SOURCE_SPENT +#undef HARVEST_SOURCE_BUSY +#undef HARVEST_SOURCE_MISMATCH +#undef HARVEST_SOURCE_DRAWING diff --git a/voidcrew/modules/xenoarch/use/utilizer.dm b/voidcrew/modules/xenoarch/use/utilizer.dm index 9962c143f12..27b7ab9f86f 100644 --- a/voidcrew/modules/xenoarch/use/utilizer.dm +++ b/voidcrew/modules/xenoarch/use/utilizer.dm @@ -1 +1,366 @@ -// The handheld utilizer that runs a harvested effect from its battery. +/** + * # Exotic utilizer + * + * A handheld emitter that runs whatever effect its exotic battery holds. Switched on from its + * own small window (attack_self), never from an action button. + * + * - A field effect runs without a break; a burst effect builds up and lets go on its own rhythm, + * at twice the rate the artifact itself refilled. Either costs ARTIFACT_UTILIZER_DRAIN charge a + * second, so a full battery lasts about six and a half minutes. + * - A contact effect waits until the holder presses the prongs to someone (themselves included), + * at ARTIFACT_UTILIZER_TOUCH_COST a use: ten uses from a full battery. + * - Whoever switched it on answers for the harm it does (set_responsible), and every contact use + * is logged like a weapon hit. Its holder and anyone else in reach are affected alike. + * - It goes dark inside a trader outpost, as artifacts do. + * - Healing, cell charge and air effects run through the effect's own code, so the shared heal + * cap, the cell rules and the planet-air guard all still apply. It makes no charge of its own. + * + * Rewritten from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/obj/particle_battery.dm + * (/obj/item/xenoarch/xenoarch_utilizer), with its HTML window replaced by tgui (AnomalyUtilizer.tsx) + * and its timer mode dropped. Art: Nova's machinery.dmi utilizer states (CC BY-SA 3.0, pixel-checked + * clean against Goon), with core and prong masks drawn for Voidcrew. Sounds and hums are the + * energy kind's own (core/artifact_energy_kinds.dm). + * + * Ship saves delete it like any item, battery and all, so it can never come back charged. + */ + +/// How long each process keeps the battery's effect awake: well past the next SSobj tick, so it +/// never lapses while on, even when the server lags. Switching off puts it to sleep at once. +#define UTILIZER_WAKE_MARGIN (10 SECONDS) + +/obj/item/anomaly_utilizer + name = "exotic utilizer" + desc = "A pronged emitter on a squat grip, with a slot for an exotic battery." + icon = 'voidcrew/modules/xenoarch/icons/use.dmi' + icon_state = "utilizer" + base_icon_state = "utilizer" + inhand_icon_state = "electronic" + lefthand_file = 'icons/mob/inhands/items/devices_lefthand.dmi' + righthand_file = 'icons/mob/inhands/items/devices_righthand.dmi' + w_class = WEIGHT_CLASS_SMALL + item_flags = NOBLUDGEON + light_system = OVERLAY_LIGHT + light_range = 2 + light_power = 0.8 + light_on = FALSE + custom_materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 2, + /datum/material/glass = SHEET_MATERIAL_AMOUNT, + /datum/material/gold = SHEET_MATERIAL_AMOUNT, + /datum/material/plasma = SHEET_MATERIAL_AMOUNT, + ) + /// The battery in its slot + var/obj/item/anomaly_battery/battery + /// Switched on and giving off its effect + var/emitting = FALSE + /// The energy kind's hum while it runs + var/datum/looping_sound/hum + +/obj/item/anomaly_utilizer/Destroy(force) + switch_off(quiet = TRUE) + QDEL_NULL(battery) + QDEL_NULL(hum) + return ..() + +/// The effect it would run, or null with no battery or an empty one. +/obj/item/anomaly_utilizer/proc/loaded_effect() + return battery?.held_effect + +/// Whether it may run here: never inside a trader outpost. +/obj/item/anomaly_utilizer/proc/is_damped() + return is_trader_outpost_protected(src) + +// ===== Battery slot ===== + +/obj/item/anomaly_utilizer/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!istype(tool, /obj/item/anomaly_battery)) + return NONE + if(battery) + balloon_alert(user, "slot's taken!") + return ITEM_INTERACT_BLOCKING + if(!user.transferItemToLoc(tool, src)) + return ITEM_INTERACT_BLOCKING + battery = tool + to_chat(user, span_notice("You slide [tool] into [src] until it clicks.")) + playsound(src, 'sound/items/weapons/gun/general/magazine_insert_full.ogg', 30, TRUE) + update_appearance() + SStgui.update_uis(src) + return ITEM_INTERACT_SUCCESS + +/obj/item/anomaly_utilizer/Exited(atom/movable/gone, direction) + . = ..() + if(gone != battery) + return + switch_off(quiet = QDELETED(src)) + battery = null + if(!QDELETED(src)) + update_appearance() + SStgui.update_uis(src) + +/obj/item/anomaly_utilizer/proc/eject_battery(mob/living/user) + if(!battery) + return FALSE + var/obj/item/anomaly_battery/ejected = battery + switch_off() + ejected.forceMove(drop_location()) + if(user) + user.put_in_hands(ejected) + playsound(src, 'sound/items/weapons/gun/general/magazine_remove_full.ogg', 30, TRUE) + return TRUE + +// ===== Switching ===== + +/// Switches it on. Returns TRUE if it started. +/obj/item/anomaly_utilizer/proc/switch_on(mob/living/user) + if(emitting) + return FALSE + var/datum/artifact_effect/effect = loaded_effect() + if(!effect || battery.stored_charge <= 0) + playsound(src, 'sound/machines/click.ogg', 30, TRUE) + if(user) + balloon_alert(user, "nothing happens") + return FALSE + if(is_damped()) + playsound(src, 'sound/machines/click.ogg', 30, TRUE) + if(user) + to_chat(user, span_warning("[src] clicks, but its prongs stay dark.")) + return FALSE + emitting = TRUE + effect.set_responsible(user) + effect.wake(UTILIZER_WAKE_MARGIN) + START_PROCESSING(SSobj, src) + + var/datum/artifact_energy_kind/kind = artifact_energy_kind(effect.energy_kind) + playsound(src, 'sound/machines/click.ogg', 30, TRUE) + playsound(src, kind.wake_sound, 40, TRUE) + QDEL_NULL(hum) + if(kind.hum_type) + hum = new kind.hum_type(src) + hum.start() + visible_message(span_notice("[src] hums to life, its prongs glowing [anomaly_glow_word(kind.key)].")) + if(user) + user.log_message("switched on [src] holding [effect.log_name] ([effect.release_method], [effect.strength]).", effect.harmful ? LOG_ATTACK : LOG_GAME) + update_glow() + update_appearance() + SStgui.update_uis(src) + return TRUE + +/// Switches it off. `message` is shown to anyone watching; `quiet` skips sound and updates (deletion). +/obj/item/anomaly_utilizer/proc/switch_off(message, quiet = FALSE) + if(!emitting) + return + emitting = FALSE + STOP_PROCESSING(SSobj, src) + var/datum/artifact_effect/effect = loaded_effect() + effect?.deactivate() + hum?.stop() + if(quiet) + return + if(message) + visible_message(span_notice(message)) + playsound(src, 'sound/effects/stealthoff.ogg', 30, TRUE) + update_glow() + update_appearance() + SStgui.update_uis(src) + +/obj/item/anomaly_utilizer/process(seconds_per_tick) + var/datum/artifact_effect/effect = loaded_effect() + if(!emitting || !effect) + switch_off() + return PROCESS_KILL + if(is_damped()) + switch_off("[src] crackles and goes dark.") + return PROCESS_KILL + var/old_level = battery.charge_level() + switch(effect.release_method) + if(ARTIFACT_RELEASE_AURA) + battery.use_charge(ARTIFACT_UTILIZER_DRAIN * seconds_per_tick) + // The battery pays for a field that never rests. + effect.current_charge = effect.maximum_charges + if(ARTIFACT_RELEASE_PULSE) + battery.use_charge(ARTIFACT_UTILIZER_DRAIN * seconds_per_tick) + effect.current_charge = min(effect.maximum_charges, effect.current_charge + ARTIFACT_UTILIZER_PULSE_FEED * seconds_per_tick) + var/store_before = effect.current_charge + effect.wake(UTILIZER_WAKE_MARGIN) + effect.tick(seconds_per_tick) + if(QDELETED(src)) + return PROCESS_KILL + if(effect.release_method == ARTIFACT_RELEASE_PULSE && effect.current_charge < store_before) + burst_visuals(artifact_energy_kind(effect.energy_kind)) + if(battery.stored_charge <= 0) + switch_off("[src] sputters and goes dark.") + return PROCESS_KILL + if(battery.charge_level() != old_level) + update_appearance() + +// ===== Contact ===== + +/obj/item/anomaly_utilizer/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers) + if(!isliving(interacting_with)) + return NONE + var/datum/artifact_effect/effect = loaded_effect() + if(!emitting || effect?.release_method != ARTIFACT_RELEASE_TOUCH) + return NONE + return touch_with(interacting_with, user) + +/// Presses the prongs to `target`, letting the battery's contact effect act on them. +/obj/item/anomaly_utilizer/proc/touch_with(mob/living/target, mob/living/user) + var/datum/artifact_effect/effect = loaded_effect() + var/datum/artifact_energy_kind/kind = artifact_energy_kind(effect.energy_kind) + user.changeNext_move(CLICK_CD_MELEE) + user.do_attack_animation(target) + effect.set_responsible(user) + effect.current_charge = max(effect.current_charge, effect.touch_cost) + if(target == user) + user.visible_message( + span_warning("[user] presses [src] to [user.p_them()]self. Its prongs flare [anomaly_glow_word(kind.key)]."), + span_warning("You press [src] to yourself. Its prongs flare [anomaly_glow_word(kind.key)]."), + ) + else + user.visible_message( + span_warning("[user] presses [src] against [target]. Its prongs flare [anomaly_glow_word(kind.key)]."), + span_warning("You press [src] against [target]. Its prongs flare [anomaly_glow_word(kind.key)]."), + ignored_mobs = target, + ) + to_chat(target, span_userdanger("[user] presses [src] against you!")) + log_combat(user, target, "touched", src, "([effect.log_name])") + effect.touch(target) + battery?.use_charge(ARTIFACT_UTILIZER_TOUCH_COST) + burst_visuals(kind) + if(!battery || battery.stored_charge <= 0) + switch_off("[src] sputters and goes dark.") + else + update_appearance() + SStgui.update_uis(src) + return ITEM_INTERACT_SUCCESS + +// ===== Looks ===== + +/// The swell and flash of a burst or a contact. +/obj/item/anomaly_utilizer/proc/burst_visuals(datum/artifact_energy_kind/kind) + if(!kind) + return + playsound(src, kind.pulse_sound, 45, TRUE) + var/matrix/swell = matrix() + swell.Scale(1.2) + animate(src, transform = swell, time = 0.12 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(transform = matrix(), time = 0.35 SECONDS, easing = SINE_EASING | EASE_IN) + var/mutable_appearance/flare = mutable_appearance(icon, "utilizer_tips") + flare.color = kind.color + var/atom/movable/flare_visual = flick_overlay_view(flare, 0.6 SECONDS) + if(flare_visual) + flare_visual.add_filter("flare_glow", 1, drop_shadow_filter(0, 0, 3, 0, "[kind.color]cc")) + animate(flare_visual, alpha = 0, time = 0.6 SECONDS, easing = SINE_EASING | EASE_IN) + +/// The breathing glow and light while it runs. +/obj/item/anomaly_utilizer/proc/update_glow() + var/datum/artifact_effect/effect = loaded_effect() + if(!emitting || !effect) + remove_filter("utilizer_glow") + set_light_on(FALSE) + return + var/datum/artifact_energy_kind/kind = artifact_energy_kind(effect.energy_kind) + add_filter("utilizer_glow", 2, drop_shadow_filter(0, 0, 1.5, 0, "[kind.color]40")) + transition_filter_chain("utilizer_glow", -1, \ + FilterChainStep(list("color" = "[kind.color]b0", "size" = 2), 0.8 SECONDS, SINE_EASING), \ + FilterChainStep(list("color" = "[kind.color]40", "size" = 1.5), 0.8 SECONDS, SINE_EASING)) + // A light or darkness effect lights its battery itself; the utilizer stays out of its way. + if(effect.light_override()) + set_light_on(FALSE) + return + set_light_color(kind.color) + set_light_on(TRUE) + +/obj/item/anomaly_utilizer/update_icon_state() + icon_state = battery ? "[base_icon_state][battery.charge_level()]" : base_icon_state + return ..() + +/obj/item/anomaly_utilizer/update_overlays() + . = ..() + if(!battery) + return + var/datum/artifact_energy_kind/kind = battery.lit_kind() + . += anomaly_core_overlays(src, "utilizer_core", kind, battery.charge_fraction()) + if(emitting && kind) + var/mutable_appearance/tips = mutable_appearance(icon, "utilizer_tips") + tips.color = kind.color + . += tips + . += emissive_appearance(icon, "utilizer_tips", src) + +/obj/item/anomaly_utilizer/examine(mob/user) + . = ..() + if(!battery) + . += "Its battery slot is empty." + return + . += "An exotic battery sits in its slot." + . += anomaly_core_description(battery.lit_kind(), battery.charge_fraction()) + if(emitting) + var/datum/artifact_energy_kind/kind = battery.held_kind() + . += span_notice("It hums, its prongs glowing [anomaly_glow_word(kind?.key)].") + +// ===== Window ===== + +/obj/item/anomaly_utilizer/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "AnomalyUtilizer", name) + ui.open() + +/obj/item/anomaly_utilizer/ui_data(mob/user) + var/list/data = list() + data["emitting"] = emitting + if(battery) + var/datum/artifact_effect/effect = loaded_effect() + var/datum/artifact_energy_kind/kind = battery.lit_kind() + var/mode + switch(effect?.release_method) + if(ARTIFACT_RELEASE_AURA) + mode = "field" + if(ARTIFACT_RELEASE_PULSE) + mode = "burst" + if(ARTIFACT_RELEASE_TOUCH) + mode = "contact" + data["cell"] = list( + "fill" = round(battery.charge_fraction(), 0.01), + "color" = kind?.color, + "serial" = battery.source_serial, + "mode" = kind ? mode : null, + ) + else + data["cell"] = null + return data + +/obj/item/anomaly_utilizer/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/living/user = ui.user + switch(action) + if("power") + if(emitting) + switch_off("[src] winds down.") + else + switch_on(user) + return TRUE + if("eject") + eject_battery(user) + return TRUE + +/datum/design/anomaly_utilizer + name = "Exotic Utilizer" + desc = "A handheld emitter that runs whatever an exotic battery holds." + id = "xenoarch_utilizer" + build_type = PROTOLATHE | AWAY_LATHE + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 2, + /datum/material/glass = SHEET_MATERIAL_AMOUNT, + /datum/material/gold = SHEET_MATERIAL_AMOUNT, + /datum/material/plasma = SHEET_MATERIAL_AMOUNT, + ) + build_path = /obj/item/anomaly_utilizer + category = list( + RND_CATEGORY_EQUIPMENT + RND_SUBCATEGORY_EQUIPMENT_SCIENCE + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE + +#undef UTILIZER_WAKE_MARGIN From 48a190292f221003b87f974e4c6538cda80de298 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:02:20 -0800 Subject: [PATCH 29/72] Add the artifact analyser, scan pad, labeler and xenoarch research The analyser reads a large find standing on its scan pad and prints a stamped report with the find's serial, condition and one line per emission. Its tgui bench shows a phosphor scope that follows the find live, engraved plates for the last reading, and a paper tape of every stimulus the pad sensed while the crew tested it, which can be torn off. A scan takes the find's lock and releases it on finish, halt, power loss, panel open, the find leaving the pad, and Destroy. The first full analysis of a dug find pays research once, as research notes, and records artifact_analysed. The strange object pays once per techweb in the destructive analyzer. The specimen labeler prints the tag a crew presses onto a find, from fixed word lists, and peels it off again. Tagging is logged. Three research nodes on the tier ladder: Xenoarchaeology, Advanced Excavation and Exotic Particles. They list the dig, use and trade designs by id. --- .../interfaces/XenoarchAnalyser.tsx | 544 ++++++++++++++++ .../interfaces/XenoarchLabeler.tsx | 206 ++++++ .../unit_tests/voidcrew_xenoarch_study.dm | 204 ++++++ voidcrew/modules/xenoarch/icons/study.dmi | Bin 0 -> 7887 bytes voidcrew/modules/xenoarch/study/analyser.dm | 586 +++++++++++++++++- voidcrew/modules/xenoarch/study/labeler.dm | 186 +++++- voidcrew/modules/xenoarch/study/research.dm | 62 +- voidcrew/modules/xenoarch/study/scanpad.dm | 179 +++++- voidcrew/modules/xenoarch/study/techweb.dm | 87 ++- 9 files changed, 2049 insertions(+), 5 deletions(-) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/XenoarchAnalyser.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/XenoarchLabeler.tsx create mode 100644 voidcrew/modules/xenoarch/icons/study.dmi diff --git a/tgui/packages/voidcrew_tgui/interfaces/XenoarchAnalyser.tsx b/tgui/packages/voidcrew_tgui/interfaces/XenoarchAnalyser.tsx new file mode 100644 index 00000000000..423624f9b9f --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/XenoarchAnalyser.tsx @@ -0,0 +1,544 @@ +/** + * The artifact analyser's bench: a phosphor scope that follows the find on the + * scan pad, two engraved plates for the last reading, and the paper tape the + * pad prints while the crew tests the find. + * + * Before a scan the scope shows one green trace for the find as a whole. Once a + * find has been read, each emission gets its own trace in its own colour, and + * the trace's shape follows how it releases. Nothing here explains a rule; it + * only shows what the instrument picks up. + */ +import { type ReactNode, useEffect, useRef } from 'react'; +import { Box, Button, Stack } from 'tgui-core/components'; +import type { BooleanLike } from 'tgui-core/react'; + +import { useBackend } from '../../tgui/backend'; +import { Window } from '../../tgui/layouts'; + +type Channel = { + channel: string; + kind: string; + kind_key: string; + release: string; + release_key: string; + family: string; + awake: BooleanLike; + resting: BooleanLike; +}; + +type Reading = { + serial: string; + name: string; + damaged: BooleanLike; + current: BooleanLike; + channels: Channel[]; +}; + +type Specimen = { + serial: string; + name: string; + awake: BooleanLike; + resting: BooleanLike; + label: string | null; +}; + +type TapeLine = { + time: string; + text: string; + tone: 'note' | 'response' | 'quiet' | 'fault'; + count: number; +}; + +type Data = { + powered: BooleanLike; + pad: 'none' | 'offline' | 'ready'; + scanning: BooleanLike; + progress: number; + specimen: Specimen | null; + reading: Reading | null; + tape: TapeLine[]; + kind_colors: Record; +}; + +const SCOPE_W = 360; +const SCOPE_H = 190; +const PHOSPHOR = '#7dffa0'; +const SCAN_VIOLET = '#c48cff'; + +/** 0 dormant, 1 resting between runs, 2 awake. */ +const activity = (awake: BooleanLike, resting: BooleanLike) => + awake ? (resting ? 1 : 2) : 0; + +/** + * A trace two screens wide, so scrolling it left by one screen loops cleanly. + * Fields roll, bursts spike, contact emissions blip now and then, and anything + * dormant is just grain. + */ +const wavePath = (shape: string, amp: number, mid: number, seed: number) => { + const period = shape === 'pulse' ? 90 : shape === 'touch' ? 120 : 60; + const points: string[] = []; + for (let x = 0; x <= SCOPE_W * 2; x += 3) { + const t = (x % period) / period; + let y = 0; + if (shape === 'aura') { + y = Math.sin(t * Math.PI * 2); + } else if (shape === 'pulse') { + if (t < 0.08) { + y = Math.sin((t / 0.08) * Math.PI) * 1.4; + } else if (t < 0.16) { + y = -Math.sin(((t - 0.08) / 0.08) * Math.PI) * 0.4; + } + } else if (shape === 'touch' && t > 0.45 && t < 0.55) { + y = Math.sin(((t - 0.45) / 0.1) * Math.PI); + } + const grain = Math.sin(x * 12.9898 + seed) * 43758.5453; + y += (grain - Math.floor(grain) - 0.5) * 0.18; + points.push(`${x},${(mid - y * amp).toFixed(1)}`); + } + return `M${points.join(' L')}`; +}; + +const Trace = (props: { + shape: string; + level: number; + mid: number; + color: string; + seed: number; +}) => { + const { shape, level, mid, color, seed } = props; + const amp = level === 2 ? 34 : level === 1 ? 14 : 3; + return ( + + + + + ); +}; + +const scopeMessage = (data: Data) => { + if (data.pad === 'none') { + return 'NO PAD'; + } + if (data.pad === 'offline') { + return 'PAD OFFLINE'; + } + if (!data.specimen) { + return 'PAD CLEAR'; + } + return ''; +}; + +const Scope = (props: { data: Data }) => { + const { data } = props; + const { powered, scanning, progress, specimen, reading, kind_colors } = data; + const channels = reading?.current ? reading.channels : null; + const lines: ReactNode[] = []; + if (powered) { + for (let x = 0; x <= SCOPE_W; x += 36) { + lines.push( + , + ); + } + for (let y = 0; y <= SCOPE_H; y += 38) { + lines.push( + , + ); + } + } + const midFor = (index: number, count: number) => + count > 1 ? (index ? 140 : 58) : SCOPE_H / 2; + const message = powered ? scopeMessage(data) : ''; + const sweepX = (SCOPE_W * progress) / 100; + return ( + + + {lines} + {!!powered && + !!specimen && + (channels ? ( + channels.map((ch, i) => ( + + )) + ) : ( + + ))} + {!!powered && !!scanning && ( + <> + + + + SCAN {progress}% + + + )} + {!!powered && !!specimen && ( + + {specimen.serial.toUpperCase()} + + )} + {!!powered && + !!specimen && + !!channels && + channels.map((ch, i) => ( + 1 ? (i ? 182 : 100) : SCOPE_H - 8} + fill={kind_colors[ch.kind_key] || PHOSPHOR} + fontFamily="monospace" + fontSize={10} + opacity={0.8} + > + CH{i + 1} + + ))} + {!!message && ( + + {message} + + )} + + + ); +}; + +/** The specimen's paper card on the bench. */ +const Nameplate = (props: { data: Data }) => { + const { powered, specimen, reading } = props.data; + const shown = powered ? specimen : null; + return ( + + + {shown ? shown.serial : '- - - -'} + + {shown ? shown.name : ''} + {!!shown && !!reading?.current && !!reading.damaged && ( + fractured + )} + {!!shown?.label && ( + + Tag: {shown.label} + + )} + + ); +}; + +/** An engraved plate holding one emission's reading. */ +const Plate = (props: { + label: string; + channel?: Channel; + color?: string; + empty: string; +}) => { + const { label, channel, color, empty } = props; + return ( + + + {label} + + {channel ? ( + <> + {channel.kind} + {channel.release} + {channel.family} + + ) : ( + {empty} + )} + + ); +}; + +const INK: Record = { + note: '#3b3a36', + response: '#9b1d1d', + quiet: '#8d8a80', + fault: '#a8561b', +}; + +/** The tape the pad prints on, newest line at the bottom. */ +const Tape = (props: { tape: TapeLine[] }) => { + const { tape } = props; + const roll = useRef(null); + const last = tape[tape.length - 1]; + useEffect(() => { + if (roll.current) { + roll.current.scrollTop = roll.current.scrollHeight; + } + }, [tape.length, last?.text, last?.count]); + return ( +
+ {tape.map((line, i) => ( +
+ {line.time} + {' '} + {line.text} + {line.count > 1 ? ` x${line.count}` : ''} +
+ ))} +
+ ); +}; + +/** A chunky bench key. */ +const Key = (props: { + label: string; + icon?: string; + color: string; + disabled: boolean; + height?: string; + onClick: () => void; +}) => { + const { label, icon, color, disabled, onClick } = props; + const height = props.height ?? '44px'; + return ( + + ); +}; + +export const XenoarchAnalyser = () => { + const { act, data } = useBackend(); + const { powered, pad, scanning, specimen, reading, tape, kind_colors } = data; + const channels = reading?.current ? reading.channels : []; + const read = !!powered && !!reading?.current; + const canScan = !!powered && pad === 'ready' && !!specimen && !scanning; + return ( + + + + + + + + + + + + + + + + act('scan')} + /> + + + act('halt')} + /> + + + + + + + + + + + + + + + + + + + + + + + + + act('tear')} + /> + + + + + + + + + ); +}; diff --git a/tgui/packages/voidcrew_tgui/interfaces/XenoarchLabeler.tsx b/tgui/packages/voidcrew_tgui/interfaces/XenoarchLabeler.tsx new file mode 100644 index 00000000000..f84071aebed --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/XenoarchLabeler.tsx @@ -0,0 +1,206 @@ +/** + * The specimen labeler: the tag it will print, stamped in purple ink on manila + * card, over the words it knows for each line. Click a line on the tag or a + * tab to choose which line to set, then click a word. + */ +import { useState } from 'react'; +import { Box, Button, Stack } from 'tgui-core/components'; + +import { useBackend } from '../../tgui/backend'; +import { Window } from '../../tgui/layouts'; + +type Field = 'effect' | 'trigger' | 'second'; + +type Data = { + effect: string | null; + trigger: string | null; + second: string | null; + effects: string[]; + triggers: string[]; + none: string; +}; + +const FIELDS: { key: Field; title: string }[] = [ + { key: 'effect', title: 'Effect' }, + { key: 'trigger', title: 'Trigger' }, + { key: 'second', title: 'Second' }, +]; + +const INK = '#5b2a86'; + +/** A manila tag on a string, its three lines stamped in purple. */ +const Tag = (props: { + data: Data; + field: Field; + onField: (field: Field) => void; +}) => { + const { data, field, onField } = props; + return ( +
+
+ {FIELDS.map((line) => { + const word = data[line.key]; + return ( +
onField(line.key)} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'baseline', + padding: '2px 4px', + borderBottom: '1px solid #a88a54', + background: field === line.key ? '#ffffff38' : undefined, + }} + > + + {line.title}: + + + {word ?? '...'} + +
+ ); + })} +
+ ); +}; + +export const XenoarchLabeler = () => { + const { act, data } = useBackend(); + const [field, setField] = useState('effect'); + let words = data.effects; + if (field === 'trigger') { + words = data.triggers; + } else if (field === 'second') { + words = [data.none, ...data.effects]; + } + const chosen = data[field]; + const anySet = !!data.effect || !!data.trigger || !!data.second; + const pick = (word: string) => { + act('set', { field, word }); + // Move on to the next blank line, as a hand would. + const next = FIELDS.find((line) => line.key !== field && !data[line.key]); + if (next) { + setField(next.key); + } + }; + return ( + + + + + + + + + {FIELDS.map((tab) => ( + + + + ))} + + + + + {words.map((word) => ( + pick(word)} + style={{ + display: 'inline-block', + cursor: 'pointer', + margin: '3px', + padding: '3px 8px', + borderRadius: '2px', + fontFamily: 'monospace', + fontSize: '12px', + background: word === chosen ? INK : '#e9e1cd', + color: word === chosen ? '#fff' : '#3a3631', + boxShadow: '0 1px 0 #0006', + }} + > + {word} + + ))} + + + + + + + + + + + + + ); +}; diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm index c4fdeca83f3..586a55130b6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm @@ -1 +1,205 @@ // Xenoarchaeology tests: study. +// +// Test files compile before voidcrew/_DEFINES, so the values below are literals. They mirror +// voidcrew/_DEFINES/xenoarch.dm (see also the header of voidcrew_xenoarch_core.dm): +// bands (ZONE_*): green 1, yellow 2, red 3 +// tiers: common 1, uncommon 2, rare 3, extreme 4 +// triggers: touch 1, water 2, acid 4 +// research for a first analysis: 100 / 150 / 250 by band, x1.5 for rare or extreme + +/// A scan pad with an analyser beside it, ready for a find. +/datum/unit_test/voidcrew_xenoarch_study + abstract_type = /datum/unit_test/voidcrew_xenoarch_study + var/obj/machinery/artifact_scanpad/pad + var/obj/machinery/artifact_analyser/analyser + +/datum/unit_test/voidcrew_xenoarch_study/proc/build_bench() + pad = allocate(/obj/machinery/artifact_scanpad, run_loc_floor_bottom_left) + analyser = allocate(/obj/machinery/artifact_analyser, get_step(run_loc_floor_bottom_left, EAST)) + analyser.print_delay = 0 + analyser.find_pad() + +/// A yellow-band find with one known effect, standing on the pad. +/datum/unit_test/voidcrew_xenoarch_study/proc/place_find(band = 2) + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, pad.loc, band) + find.replace_effects(/datum/artifact_effect/heal, null, band) + return find + +/// Counts things of a type on a turf. +/datum/unit_test/voidcrew_xenoarch_study/proc/count_on(turf/where, path) + . = 0 + for(var/atom/movable/thing in where) + if(istype(thing, path)) + .++ + +/// The first full analysis of a dug find prints a report and pays research once, ever. +/datum/unit_test/voidcrew_xenoarch_study/research_once/Run() + build_bench() + TEST_ASSERT_EQUAL(analyser.pad, pad, "the analyser didn't find the pad beside it") + TEST_ASSERT_EQUAL(analyser.scan_time, 10 SECONDS, "stock parts should make a ten-second scan") + var/obj/machinery/artifact/find = place_find() + TEST_ASSERT_EQUAL(analyser.watched, find, "the analyser isn't watching the find on its pad") + var/turf/out = analyser.drop_location() + + TEST_ASSERT(analyser.start_scan(), "a scan didn't start") + TEST_ASSERT(find.is_being_used(), "the scan didn't lock the find") + analyser.finish_scan() + TEST_ASSERT(find.research_paid, "the first analysis didn't mark research paid") + TEST_ASSERT_NULL(find.locked_by, "finishing the scan didn't release the lock") + TEST_ASSERT_EQUAL(count_on(out, /obj/item/research_notes), 1, "the first analysis didn't print notes") + var/obj/item/research_notes/notes = locate() in out + TEST_ASSERT_EQUAL(notes.value, 150, "a yellow uncommon find pays 150") + + var/obj/item/paper/artifact_report/report = locate() in out + TEST_ASSERT_NOTNULL(report, "no report was printed") + var/text = report.get_raw_text() + TEST_ASSERT(findtext(text, find.serial), "the report doesn't name the serial") + TEST_ASSERT(findtext(text, find.main_effect.report_line()), "the report doesn't carry the report line") + TEST_ASSERT(findtext(report.name, find.serial), "the report isn't named for its find") + TEST_ASSERT_NOTNULL(analyser.reading, "the screen kept no reading") + + TEST_ASSERT(analyser.start_scan(), "a second scan didn't start") + analyser.finish_scan() + TEST_ASSERT_EQUAL(count_on(out, /obj/item/research_notes), 1, "a second analysis paid research again") + TEST_ASSERT_EQUAL(count_on(out, /obj/item/paper/artifact_report), 2, "a second analysis printed no report") + +/// A find that never came out of a dig site pays nothing, but still reads. +/datum/unit_test/voidcrew_xenoarch_study/undug_pays_nothing/Run() + build_bench() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, pad.loc) + find.replace_effects(/datum/artifact_effect/heal, null, null) + TEST_ASSERT(!find.is_dug(), "a find made without a band was stamped") + TEST_ASSERT(analyser.start_scan(), "a scan didn't start") + analyser.finish_scan() + TEST_ASSERT(!find.research_paid, "an undug find was marked paid") + TEST_ASSERT_EQUAL(count_on(analyser.drop_location(), /obj/item/research_notes), 0, "an undug find paid research") + TEST_ASSERT_EQUAL(count_on(analyser.drop_location(), /obj/item/paper/artifact_report), 1, "an undug find printed no report") + TEST_ASSERT_EQUAL(xenoarch_claim_analysis(find, analyser, null), 0, "claiming an undug find paid") + +/// Halting, losing the find, losing power or deleting the analyser always lets go of the lock. +/datum/unit_test/voidcrew_xenoarch_study/lock_release/Run() + build_bench() + var/obj/machinery/artifact/find = place_find() + + TEST_ASSERT(analyser.start_scan(), "a scan didn't start") + analyser.abort_scan("Scan halted.") + TEST_ASSERT_NULL(find.locked_by, "halting didn't release the lock") + TEST_ASSERT(!pad.beam_on, "halting left the beam on") + + TEST_ASSERT(analyser.start_scan(), "a scan didn't restart after a halt") + find.forceMove(get_step(pad.loc, NORTH)) + TEST_ASSERT_NULL(analyser.scan_target, "moving the find off the pad didn't stop the scan") + TEST_ASSERT_NULL(find.locked_by, "moving the find off the pad didn't release the lock") + TEST_ASSERT_NULL(analyser.watched, "the analyser kept watching a find that left the pad") + TEST_ASSERT(!find.research_paid, "an aborted scan paid research") + + find.forceMove(pad.loc) + TEST_ASSERT_EQUAL(analyser.watched, find, "the analyser didn't notice the find coming back") + TEST_ASSERT(analyser.start_scan(), "a scan didn't start on the returned find") + analyser.set_is_operational(FALSE) + TEST_ASSERT_NULL(find.locked_by, "losing power didn't release the lock") + analyser.set_is_operational(TRUE) + + TEST_ASSERT(analyser.start_scan(), "a scan didn't start after power came back") + qdel(analyser) + TEST_ASSERT_NULL(find.locked_by, "deleting the analyser mid-scan didn't release the lock") + TEST_ASSERT(!pad.beam_on, "deleting the analyser left the beam on") + +/// A find another machine holds can't be scanned until it lets go. +/datum/unit_test/voidcrew_xenoarch_study/lock_respected/Run() + build_bench() + var/obj/machinery/artifact/find = place_find() + var/obj/machinery/artifact_analyser/other = allocate(/obj/machinery/artifact_analyser, get_step(run_loc_floor_bottom_left, NORTHEAST)) + TEST_ASSERT(find.try_lock(other), "another machine couldn't take the lock") + TEST_ASSERT(!analyser.start_scan(), "a scan started on a find another machine holds") + find.release_lock(other) + TEST_ASSERT(analyser.start_scan(), "a scan didn't start once the other machine let go") + analyser.abort_scan() + +/// Deleting a find mid-scan stops the scan cleanly. +/datum/unit_test/voidcrew_xenoarch_study/find_deleted/Run() + build_bench() + var/obj/machinery/artifact/find = place_find() + TEST_ASSERT(analyser.start_scan(), "a scan didn't start") + qdel(find) + TEST_ASSERT_NULL(analyser.scan_target, "the scan outlived its find") + TEST_ASSERT_NULL(analyser.watched, "the analyser kept a deleted find") + TEST_ASSERT(!pad.beam_on, "the beam outlived the find") + +/// The tape records each stimulus and whether the find answered it. +/datum/unit_test/voidcrew_xenoarch_study/tape/Run() + build_bench() + var/obj/machinery/artifact/find = place_find() + find.main_effect.trigger = 2 // water + find.stimulate(4) // acid + find.stimulate(2) // water + var/list/texts = list() + for(var/list/line as anything in analyser.tape) + texts += line["text"] + TEST_ASSERT("Acid. No response." in texts, "the tape didn't record a stimulus that woke nothing") + TEST_ASSERT("Water. Response." in texts, "the tape didn't record a stimulus that woke the find") + var/before = length(analyser.tape) + find.stimulate(4) + find.stimulate(4) + TEST_ASSERT(length(analyser.tape) <= before + 1, "repeated stimuli each took a new line") + TEST_ASSERT(analyser.tear_tape(null), "the tape couldn't be torn off") + TEST_ASSERT(!length(analyser.tape), "tearing the tape left lines on it") + TEST_ASSERT_EQUAL(count_on(analyser.drop_location(), /obj/item/paper/artifact_tape), 1, "tearing the tape left no slip") + find.main_effect.deactivate(quiet = TRUE) + +/// The labeler takes only words from its lists, and a tag made from the right words reads as right. +/datum/unit_test/voidcrew_xenoarch_study/labeler/Run() + var/obj/item/artifact_labeler/labeler = allocate(/obj/item/artifact_labeler) + TEST_ASSERT_NULL(labeler.make_label(), "a blank labeler made a tag") + TEST_ASSERT(!labeler.set_word("effect", "Turns lead into gold"), "the labeler took a word it doesn't know") + TEST_ASSERT(!labeler.set_word("trigger", "Heals"), "the labeler took an effect word as a trigger") + TEST_ASSERT(!labeler.set_word("colour", "Heals"), "the labeler took a field it doesn't have") + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, run_loc_floor_bottom_left, 1) + find.replace_effects(/datum/artifact_effect/heal, null, 1) + TEST_ASSERT(labeler.set_word("effect", "Heals"), "the labeler refused an effect word") + TEST_ASSERT(labeler.set_word("trigger", artifact_trigger_name(find.main_effect.trigger)), "the labeler refused a trigger word") + TEST_ASSERT_NULL(labeler.make_label(), "a labeler with a blank line made a tag") + TEST_ASSERT(labeler.set_word("second", "None"), "the labeler refused None for the second line") + var/datum/artifact_label/tag = labeler.make_label() + TEST_ASSERT_NOTNULL(tag, "a full labeler made no tag") + find.apply_label(tag) + TEST_ASSERT_EQUAL(find.label_accuracy(), 1, "a tag with every word right didn't read as right") + TEST_ASSERT(labeler.set_word("second", "Light"), "the labeler refused an effect word for the second line") + find.apply_label(labeler.make_label()) + TEST_ASSERT_EQUAL(find.label_accuracy(), 0.8, "a wrong second line should cost a fifth") + +/// The three research nodes sit on the price ladder and unlock every study design. +/datum/unit_test/voidcrew_xenoarch_study_nodes/Run() + var/list/expected = list( + "xenoarchaeology" = list(40, list("xenoarch_analyser", "xenoarch_scanpad", "xenoarch_labeler")), + "xenoarch_excavation" = list(80, list()), + "xenoarch_exotic_particles" = list(120, list()), + ) + for(var/node_id in expected) + var/datum/techweb_node/node = SSresearch.techweb_node_by_id(node_id) + TEST_ASSERT(node && node.id == node_id, "research node [node_id] is missing") + var/list/costs = node.research_costs + TEST_ASSERT_EQUAL(costs[TECHWEB_POINT_TYPE_GENERIC], expected[node_id][1], "[node_id] is off the price ladder") + for(var/design_id in expected[node_id][2]) + TEST_ASSERT_NOTNULL(SSresearch.techweb_designs[design_id], "design [design_id] is missing") + TEST_ASSERT(design_id in node.design_ids, "[node_id] doesn't unlock [design_id]") + var/datum/design/board/analyser_board = SSresearch.techweb_designs["xenoarch_analyser"] + TEST_ASSERT_EQUAL(analyser_board.build_path, /obj/item/circuitboard/machine/artifact_analyser, "the analyser design prints the wrong board") + var/list/curio_points = SSresearch.techweb_point_items[/obj/item/relic] + TEST_ASSERT_EQUAL(curio_points?[TECHWEB_POINT_TYPE_GENERIC], 60, "the strange object pays no research in the destructive analyzer") + +/** + * Every design the xenoarchaeology nodes list exists. The dig, use and trade parts define most of + * them, so this only passes once all the parts are together; a failure names the id to rename. + */ +/datum/unit_test/voidcrew_xenoarch_study_node_designs/Run() + var/list/expected = list( + "xenoarchaeology" = list("xenoarch_analyser", "xenoarch_scanpad", "xenoarch_labeler", "xenoarch_stabiliser", "xenoarch_spectrometer", "xenoarch_specimen_crate"), + "xenoarch_excavation" = list("xenoarch_scanner_advanced", "xenoarch_brush_quick"), + "xenoarch_exotic_particles" = list("xenoarch_harvester", "xenoarch_battery", "xenoarch_utilizer"), + ) + for(var/node_id in expected) + var/datum/techweb_node/node = SSresearch.techweb_node_by_id(node_id) + for(var/design_id in expected[node_id]) + TEST_ASSERT_NOTNULL(SSresearch.techweb_designs[design_id], "[node_id] lists design [design_id], which no part defines") + TEST_ASSERT(design_id in node?.design_ids, "[node_id] lost design [design_id]") diff --git a/voidcrew/modules/xenoarch/icons/study.dmi b/voidcrew/modules/xenoarch/icons/study.dmi new file mode 100644 index 0000000000000000000000000000000000000000..93bb8673886716e17fc6d2cdfd75950db68900e5 GIT binary patch literal 7887 zcmd6Mc|4Te`~Phh2?-&FB4sUW_CZ3nR477a%PvG>%t$3IsO;O=_kB&4u@seM?2M%; zVleixWX8<=#(19JXZb$=eE<8->oqg?eeSu=xz0J)`+C2xGm+O!_1ReXSpWcFGc>q- z9RO&A58lj-005i_FN0COww~O)<$u}9&%xK#$KTc43jiLcmVPi|X}`b$9_u}?Gkvvk z;;DdgmI3b*2ELUVwmd__r!y-i#Qvw}ue<{%9`Po9;Iu7MMn#p?@u_4F8nb-LFjNp} zdp?&}_1)v@QeSX7gIbQf8*Ou^CoV-Y(@tTf)`?1BhcOjA$781JFO|`sNh#aEHpN79`JCVGR*BST_7on~;0+uCy!nRUKna z1rd&>yZ{f}{S!)ovnL%cD8*d*W9icK7!!q=>q%1a>+~aOIrgS?Yhbi6Nf>p%`JN;{ ziBF$CF$2KOyY$4n1T?Ycgx=lV6gA1LoCwWjc5=5Dv3T8`=HiA)%A~8K<1K^z76Sjm zdS-b071+UZcceeD_{4WU0Jsm(Fat3^G&7c#_^Z#0(HaSGFLaRB5ifd1wa`Fp_gn#C z%UtAPu#own4rnKW>{L(MW6VGjp1gbj$)QX9cIK)z_4g&Ch*p~lz1KMZrQZ)lHrRSg zbrv(&2_59E0%#b`*O||eN#PIhzd+`@5r||QYNEcNL6Lvw@r_bw^(W2YHK%y{+LnY~ zi^WMI4IbA`3Zr@8JJXrk)2tbs`&P$#>w_t_CtP`5G!q#tWw;nD$iC&B^Me$peMa~; z*PFJi)NRaEz5Zo2OY3(7v93eBGiEy{l26ejD0H z%#b|YE%}CXumHrcfP@lW*MMh7y=T_9bGFjpdfN?5r(fNnnW4v5Xzj}eZ%3;EDr)fe zj0d-CR!POsOlz=ta;AADHlmp~%Eq3dKZ2Mdw*Z}r{$*oreIDQH^>7)#EPv+Ptl^El zOm>3H7cwnfKNu=I=Z<|Q=i-`83mFHItP)Uoi~y|81M==BwIN{z@A9}AJQ}=XT%vQ& z?p64~PN`+<5#^iggkR5U99O&dLp+SSeIr>Uh8>#7mY2?)wZfFqe?d{D&~%!GiE#_R zM&FSM1E#xkXOfRm{cqex)-%*NZ?jmB#$i0x|AXeJw#;NoyQrK%9CiVk= z61x3~eu6K@Q9-S#Uo_#!8<`bhPd){ItnX9sK&OkdQ-{gXBjN;)Xeiq1@%HVS8`%i# zjwZcWc}?|yEMXDPJAOptX3xO2jZ?ufoek1vT!+!*msAS9a@>LD^4ZQ8)LDJl!@G!H zTe-{;lSm5DatCEP`fs1Wm@u|K^=!K?J*5@tlT$+V7`Bh7xb5aeu!8T&jitzhu#H}Q$JqJ zhxN5Tkw&>ugPiy@-3EARgD&qej71w>17b?oES)idRo!d6fFo9UdIV=9qf&@lb7B%A ze$U{HDLLYNWMa%QFf>3YR-^RmgYOl1FYK4DLgZ0*B5HS&&x1%mR~ED^rE zX=CHk@S#5t7cRQp2wBoSjt|`ydDG$!+&_}c(mVB3dmwR+yOa4eJ~WCWqQwA+DMWGs zj!QUtAPKKd1J2VlqcovEb5hy}ikI6L{Max1I}7E}c4|*-bBlZ4?mC27q>CwpU+%{pULhyh6Mydo?u30C z_V%sR9VD&ok}H{nlRCKv%v6C(+aHbrUUT)J*B)w*KH7an4MavK@bd^Sb{c>(eKAYQveiSmx+M;tllA;+od z&Qf{_E)wjSQNTbA1xmif9R-hdmL*Td6^~v~1tdWO{(s}LMLI*V*s8zk#VJK4rNN&+ zg~#f!x4dsB&#a%=X1{Yn^)a=_J9$_*w)kXP8dJ~hW!#=RH;X_)eq%&1*0^J5C1?|% z#=AZd2jTT_`}9gCbuBx-PHDANo^6u%a1ydZ+p-m0PYM#VGiOwGgx~tvq@R_b`hbzM zA-@srqe!<_V_LfNCt-)UaB3v&m9en{fLz;P4*)m` z12jM^=bw{yj#nURjp(Uw8UV||vYD0RF_TQ6KP!8B<-dD(Pvl`;0r8hE)AP4fU&qoH z@ewu`{zC8K3SCiI*~OIWrz>jA4|CX852F0j7uN#eni8U3$iI1Hc~tAngG)*Scmt*j zAM|5us1vwd3Jt5j>k$?-AcA_>WJJw2f$G)U(ZToHQ%1I(2-=c$br7p14AQg~!u9Gab6Uv=CV*77l5_(|d64zh1j8MLhYzJGww1(!-B26ywEM@IUp z5EVxCm~V6k1ORe1Cx@UJQj*KESTTfx+|svH4C+y%7E2*X*5zkH67vrV9!F^y%udDU zN3tB4p&KakI}=$6oaS=SJ#Z@RTL@A9{Yh5g&2#M3ETc$4U5pB&@F6HsWzZJ)4mtp4 zT%jO;$Wee3z2*zvBy)7%s&b$&JM8H}>KQ5~?d|-Vl1r6#gZ}B)zITSoVYM!wNWXZF z0vSi?g#qvr0<5Umy(cf0S<3T}N%;?;pCK|logd8-If;Au{&1x!E6L0sWm2O#NYnHC zZ{oAW+W(Y3X>-@k-yq=B+IwpMf=CbItX<-|^CQT)jbxkb%utY})&nO~6yEh5LQt@K zt)2=spX!c~^H3_K{laS?2b=6%JATG7Q7+`RgW~5+vi3vK-(;ZugH48TvgkpR>E^z& zmree4ETIpfjb$(9wQF)2zg|x#Bx5~3ZjJ>L(&0C%!>nGhcQP0iWh%3 zAtKLE+MT2}`FIXEP?~~R6Oq*eWa|Cq;jjkgc48Q; z0h9;L`sr#9iS34eV=MTI&G7h+lc2-D5!Hevsbt~*G*uZa^!3rrOYQ81q_aF{8y=eO zXnvG;kFcX2M6EE+PzZ9Z_~eZt3;$mspxT<+hu&$f<1Oc?`5Z!lVOUzf?w3<++{j8M z*VM^Pg+o?QqljXvFm$}{L8T`7r1C@MC^ScfdaZK{iBGNzxJ)HISqf1HdOik#m?QDd zjKI0@U`=75>gx?Njoh0EBb-`HDl%Kr9R}O7~fQ>_j<^%Zo_;`-Hyq*AQ z`d=Gorg-N>uurxDPbyv_FVN8f=ji^aJdz-qDUR@Ik+1Y~EUgadKmWWEblH#hLyAO)RAM(sa3J-ypxb znQ%4t8e>60!OIa~?QL%?xl6X$c&!DulZqT|`7+X1jcY$Fqh4Tly^Q4561=vr3NV(d zKaQV|D@0Z+lp8}(u0wfib1tFX3|*P!$=@2CDwuf7k#?>_9^^xPInP+UyrYFxJ1nfU z@g+Ygca1q)L9oz}oj{x-Ch*~ez2cI?m>-JJ-SXr9_Kt z{C@GgZM3E7^{-W0fq-woB{8*n}Q%7SCZNRz7&JuU9bW7|0Bjq^KPg zzJWuLfWPN*QG;!Rn`aJP#U`f-6D2yHlMCmQ&yv$R=v#dh?XBa^>z#oNL{^UWay?hd(vc7972Dx&pweDI_-zZY3b8~Pe)rv z-mJV5j$2dZsI11pROUtgv1VH#U0Xgsh<#li?}>sluxd&%S0aSr$?X1a{v?RLj+u$o zz;hgAcyP3nir*}-y*FVpXz%k1YGqQI6|=9!s$(g9zZ5jelDuZ@>O-Pa%ZzxX!Y`5T zz&?1Xe$F+E76Mt(Qad(hxwk6_IC=^@=?h!Zl}4Ocpi_qGmHhU~hCySB*1Zs5ER zNcmS`&W~;oHMM}{b8n-g38cKby7+*V2{EVs6gSAof*na}hN4j=$a%dh z3UYE%u_}L+h}y!25Fw1S`o=RA+UKbVrzqr~KeN$vIGXlZHTd%Izum~1Ilv4wS8&39Ku#20zH zy@A^1dD|0j>4v+_cXICVjOltXCf&oHL%p<~d>t|=L4#k&#|g18+?`sG4) zTp|;((AhnKMp8RNt^Z=TwiQHDc)jlFxOa*0XMvFYO-tKQ*_}H3iIr`r?5R5~X}ii< z%N8Z&3P2@QeMkr}!gDm;TdE~xSvjPbM`TL!9TXAnxgXnT&91fd?3Ipkcc)DAdyH4K zH3|9*xo|(T&L{KIi+9Wf=qwaQ4Zl=+IeB2+3~T4E67XEzpNJXW@LS7+-coAb0Dr0TRJ*zp zWv)e>i8E@c7rM8HDuY{A*l-F#u1pCI+}kwL-P87Mi*Dq?H&@R3#lL(c!|QI}u;GS5 z-D2PR9Qm)N%K6a;1^d#NH$xjMxAvY`(2wAY2AcOj9X|;>EAG{EM8*!@Jb0z;dxy}S zCQbc;dZ7_HrZ|fuw|)eaDl8UX+19X_|m+_on8j~n3x}=V`uPS;e=k2X<=qin>-)aF zIF2jduc~EG?=_F%(A^+;w!AncqJpsepPq6m{!Ceh0E-a(V*cWf;I+6-(HC=jJ@Ux@@$r); zrlxtO_KHZ-c78PktQv1`^`GZ)?_)I^B}|Qt4VqP>qN0-5lpC6N3$5)VG&5=v?Ddb9 zJxV(9E=Mz1u3c5!$xg7eX*#{VVAPDRPb}_{BqHi$)fjyhhHt)JVhfqB;U3lTj=^tV zw&hRz4c*!nH%CVJ-nCG{i+>#-$Z+F#(NM%<>av-oc!6STl&funub2hCXm21o^pYBT zOw`;Mx9-ja*qqLA5MO0SA&+DHbHik}kfmDTC`Yb%TA1Llfq-_akGKVrG?->k1f zWwVP0t=_IuOwm03$s>{vZtF(z3kbOS_{6wRJ}Rk~GI{v;6`o0kMexph{7!-h$|L9@ zlDB7Gn6y+}{=H@}r<{MfyIiu2j#EvIJyBfie=>>0R%!9+6z#Q28=LdXqW429nr@|! z4rhc)3-6?SU*kERa#>Oa63^Q`WQJA=zc;Rul|7%3w;3M$=2`lG7gC1T(V-TOL-}@m z$p}4crAUDl~2rD`9C?C z`@&8}@>;u7psuAi_f#`27OL}QOT2|wi_5tJxSi87`Zi=JBqM9$u?H`k8xuTJ2IEvV z#EPRX@(mv;)uC>+mAVZW%zm>TxJiI7ZPHQxeEIL!=Vxno5zQ+J0bL@S?QL_DvtOBj z3`5|RDsA*hdCN{#}}| z<(igu#z!M~Fo**NKHV0f%uJc-xAzk^7cv;9CC|~`t;%4a2eW{5mPc~fIcS(+u~o`> z!lL{k@B8%uTEKu+$JH~4=D$1@W><+1S83PT) zmJZr`K?0NnC&}N6%Pl`=LhAxysnRj}2!yIRL|j10d-w{P)f_@E-1@}`%Bf!3twvUr zf7FwZ@Ve558$T)U3Z*<^@GCQdgr}3eZC4y=h|@_mTC^PN&sE|_w(RgP1G!SK{#eWc zh@HlhAR6YL#eT=WjGRa01K}GU#u^Ye`mWf9=C@7BI%#+ z`?RyASpyRdL6Xo|7Cy!W*^%$02Q*=FQ6%qyCj8qsA?~YI70|fL>@`x^dhF6U_~%h6 zkkkClw%QHf)h$9)aD;PUL_r*p69(qnoeqo++Qw!}Dm->@+aZ*jk>Z#9Y84uMR>fBP zR$TctR7|&iu61Hw(h`Gfswhi!F5Gr65qH}P{F;!0(0|R}R1RZJ=H-O&O(*t~EhPzO zfH1j!+JRz*8G32qj1vUz^|pwF^Ubun(A)DX(L5aIW|5(r$5CBjA^0CH#|1DOBax>^ zwBF@!6g61=T=dR!-I^7+`Q&5d)jm#FkuZ{xHHxwiv2CfVn;#pb1sv-fzg)Mzke2-^ z_)cfoR8fwYWYI$3XF=SqIgI2+UGtFh)8~`U+(8LfGu&V;xIYdRl_E4ZFA8xlB*=ME z0^q07?w9T@xV~2AD;7EOl$Ec9dEw=Gke{Z~Hf_q&UkDQB0}5g;uS@pLTZvAZIuZV; z4c13r2cyWj#;T&uGN308RR(6p)2D383JImTeygBc0 zsyg2Z(^udSD_rA&)p<@@PEZu`UQkF#$PQs*VgiM{htTr<&HTvUKW8(94r1se?w@QU zWq3^@ZegPflyBP@Fc2lEc7KvKM#)F-=9_8;$J)@x)|X75TI)Pu2&;MbohPQRqICd; pD#Tv^4CrfMC&IhdVG8qPn%TU;)Oze4wgVs=>X}|H)4BWf{{i@Axa 1) + set_pad(null) + else + refresh_watch() + SStgui.update_uis(src) + +/obj/machinery/artifact_analyser/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + if(pad && get_dist(src, pad) > 1) + set_pad(null) + +/// Ready to read the pad: powered, bolted down, closed up. +/obj/machinery/artifact_analyser/proc/can_operate() + return is_operational && anchored && !panel_open + +// ===== Watching the find on the pad ===== + +/// Follows whatever now stands on the pad, and drops a scan whose find has gone. +/obj/machinery/artifact_analyser/proc/refresh_watch() + var/obj/machinery/artifact/on_pad = (pad && !QDELETED(pad)) ? pad.get_specimen() : null + if(scan_target && (scan_target != on_pad || !pad.can_scan())) + abort_scan(scan_target == on_pad ? "Pad offline. Scan stopped." : "Specimen moved. Scan lost.") + set_watched(on_pad) + +/obj/machinery/artifact_analyser/proc/set_watched(obj/machinery/artifact/find, quiet = FALSE) + if(find == watched) + return + if(watched) + UnregisterSignal(watched, list( + COMSIG_ARTIFACT_STIMULATED, + COMSIG_ARTIFACT_EFFECT_ACTIVATED, + COMSIG_ARTIFACT_EFFECT_DEACTIVATED, + COMSIG_ARTIFACT_LABEL_CHANGED, + COMSIG_QDELETING, + )) + if(!quiet && !find) + add_tape("Pad clear.", TAPE_NOTE) + watched = find + pending_lines = null + if(!watched) + return + RegisterSignal(watched, COMSIG_ARTIFACT_STIMULATED, PROC_REF(on_stimulated)) + RegisterSignal(watched, COMSIG_ARTIFACT_EFFECT_ACTIVATED, PROC_REF(on_effect_woke)) + RegisterSignal(watched, COMSIG_ARTIFACT_EFFECT_DEACTIVATED, PROC_REF(on_effect_slept)) + RegisterSignal(watched, COMSIG_ARTIFACT_LABEL_CHANGED, PROC_REF(on_label_changed)) + RegisterSignal(watched, COMSIG_QDELETING, PROC_REF(on_watched_deleted)) + if(!quiet) + add_tape("[specimen_name(watched)] on the pad.", TAPE_NOTE) + if(watched.is_active()) + add_tape("Active on arrival.", TAPE_RESPONSE) + +/obj/machinery/artifact_analyser/proc/on_watched_deleted(datum/source) + SIGNAL_HANDLER + if(scan_target == source) + abort_scan("Specimen lost. Scan stopped.") + set_watched(null) + SStgui.update_uis(src) + +/// "Kappa-4412", or a plain description for a find nobody tagged. +/obj/machinery/artifact_analyser/proc/specimen_name(obj/machinery/artifact/find) + return find.is_dug() ? find.serial : "Untagged find" + +/// "Primary" or "Secondary": which of the find's emissions an effect is. +/obj/machinery/artifact_analyser/proc/channel_name(datum/artifact_effect/effect) + if(watched && effect == watched.second_effect) + return "Secondary" + return "Primary" + +/// A stimulus reached the find: the tape records it, and whether anything answered. +/obj/machinery/artifact_analyser/proc/on_stimulated(datum/source, trigger, mob/living/user, woke) + SIGNAL_HANDLER + if(!can_operate()) + return + add_tape("[artifact_trigger_name(trigger)]. [woke ? "Response." : "No response."]", woke ? TAPE_RESPONSE : TAPE_QUIET) + if(woke) + flick("[base_icon_state]_alert", src) + playsound(src, 'sound/machines/beep/twobeep.ogg', 25, FALSE) + +/// An emission rose. Held a moment, so the stimulus that caused it prints first. +/obj/machinery/artifact_analyser/proc/on_effect_woke(datum/source, datum/artifact_effect/effect) + SIGNAL_HANDLER + if(!can_operate()) + return + LAZYADD(pending_lines, "[channel_name(effect)] emission rising.") + addtimer(CALLBACK(src, PROC_REF(flush_pending_lines)), 1, TIMER_UNIQUE) + +/obj/machinery/artifact_analyser/proc/on_effect_slept(datum/source, datum/artifact_effect/effect) + SIGNAL_HANDLER + if(!can_operate()) + return + add_tape("[channel_name(effect)] emission fading.", TAPE_QUIET) + SStgui.update_uis(src) + +/obj/machinery/artifact_analyser/proc/flush_pending_lines() + for(var/line in pending_lines) + add_tape(line, TAPE_RESPONSE) + pending_lines = null + SStgui.update_uis(src) + +/obj/machinery/artifact_analyser/proc/on_label_changed(datum/source) + SIGNAL_HANDLER + SStgui.update_uis(src) + +/// Prints a line on the tape, folding a quick repeat into the line before. +/obj/machinery/artifact_analyser/proc/add_tape(text, tone = TAPE_NOTE) + var/list/last = length(tape) ? tape[length(tape)] : null + if(last && last["text"] == text && world.time - last["at"] < ANALYSER_TAPE_REPEAT_WINDOW) + last["count"] += 1 + last["at"] = world.time + last["time"] = station_time_timestamp("hh:mm:ss") + else + tape += list(list( + "time" = station_time_timestamp("hh:mm:ss"), + "text" = text, + "tone" = tone, + "count" = 1, + "at" = world.time, + )) + if(length(tape) > ANALYSER_TAPE_LENGTH) + tape.Cut(1, 2) + SStgui.update_uis(src) + +// ===== Scanning ===== + +/// Starts a scan of the find on the pad. Returns TRUE if one began. +/obj/machinery/artifact_analyser/proc/start_scan(mob/user) + if(scan_target || !can_operate()) + return FALSE + find_pad() + if(!pad) + say("No pad connected.") + playsound(src, 'sound/machines/scanner/scanbuzz.ogg', 30, FALSE) + return FALSE + if(!pad.can_scan()) + say("Pad offline.") + playsound(src, 'sound/machines/scanner/scanbuzz.ogg', 30, FALSE) + return FALSE + var/obj/machinery/artifact/find = pad.get_specimen() + if(!find) + say("Nothing on the pad.") + playsound(src, 'sound/machines/scanner/scanbuzz.ogg', 30, FALSE) + return FALSE + if(!find.try_lock(src)) + say("Too much interference.") + add_tape("Interference. Scan refused.", TAPE_FAULT) + playsound(src, 'sound/machines/scanner/scanbuzz.ogg', 30, FALSE) + return FALSE + set_watched(find) + scan_target = find + scan_operator = user ? WEAKREF(user) : null + scan_started = world.time + scan_timer = addtimer(CALLBACK(src, PROC_REF(finish_scan)), scan_time, TIMER_STOPPABLE | TIMER_DELETE_ME) + pad.set_beam(TRUE) + update_use_power(ACTIVE_POWER_USE) + update_appearance() + playsound(src, 'sound/machines/terminal/terminal_processing.ogg', 30, FALSE) + add_tape("Scan begun.", TAPE_NOTE) + return TRUE + +/// Stops a scan before it finishes and lets go of the find. +/obj/machinery/artifact_analyser/proc/abort_scan(reason, quiet = FALSE) + if(!scan_target) + return + if(scan_timer) + deltimer(scan_timer) + scan_timer = null + var/obj/machinery/artifact/find = scan_target + scan_target = null + scan_operator = null + if(!QDELETED(find)) + find.release_lock(src) + if(pad && !QDELETED(pad)) + pad.set_beam(FALSE) + if(QDELETED(src)) + return + update_use_power(IDLE_POWER_USE) + update_appearance() + if(quiet) + return + if(reason) + add_tape(reason, TAPE_FAULT) + playsound(src, 'sound/machines/scanner/scanbuzz.ogg', 30, FALSE) + SStgui.update_uis(src) + +/// The scan ran its full length: read the find, print the report, pay research once. +/obj/machinery/artifact_analyser/proc/finish_scan() + scan_timer = null + var/obj/machinery/artifact/find = scan_target + if(QDELETED(find) || !pad || QDELETED(pad) || pad.get_specimen() != find || !pad.can_scan() || !can_operate()) + abort_scan("Specimen lost. Scan stopped.") + return + var/mob/operator = scan_operator?.resolve() + reading = build_reading(find) + reading_of = WEAKREF(find) + var/report_text = report_text(find) + var/report_name = "analysis report ([specimen_name(find)])" + // Research is claimed now, while the find is still locked to us, and printed with the report. + var/research_points = xenoarch_claim_analysis(find, src, operator) + scan_target = null + scan_operator = null + find.release_lock(src) + pad.set_beam(FALSE) + update_use_power(IDLE_POWER_USE) + update_appearance() + say("Analysis complete.") + playsound(src, 'sound/machines/ping.ogg', 30, FALSE) + playsound(src, 'sound/machines/printer.ogg', 35, FALSE) + add_tape(research_points ? "Scan complete. Report and data printed." : "Scan complete. Report printed.", TAPE_NOTE) + // Held on the machine until it prints, so a console torn down mid-print still drops it. + eject_printout() + pending_printout = list(report_name, report_text, research_points) + if(print_delay) + addtimer(CALLBACK(src, PROC_REF(eject_printout)), print_delay) + else + eject_printout() + +/// Drops the waiting report, and the research notes if that analysis paid any. +/obj/machinery/artifact_analyser/proc/eject_printout() + if(!pending_printout) + return + var/report_name = pending_printout[1] + var/report_text = pending_printout[2] + var/research_points = pending_printout[3] + pending_printout = null + var/turf/out = drop_location() + if(!out) + return + var/obj/item/paper/artifact_report/report = new(out) + report.name = report_name + report.add_raw_text(report_text, advanced_html = TRUE) + report.update_appearance() + INVOKE_ASYNC(report, TYPE_PROC_REF(/obj/item/paper/artifact_report, stamp_report)) + if(research_points) + xenoarch_print_analysis_notes(out, research_points) + +/// What the screen shows for a finished reading. +/obj/machinery/artifact_analyser/proc/build_reading(obj/machinery/artifact/find) + var/list/channels = list() + if(find.main_effect) + channels += list(effect_reading("Primary", find.main_effect)) + if(find.second_effect) + channels += list(effect_reading("Secondary", find.second_effect)) + return list( + "serial" = specimen_name(find), + "name" = find.name, + "damaged" = find.damaged, + "channels" = channels, + ) + +/obj/machinery/artifact_analyser/proc/effect_reading(channel, datum/artifact_effect/effect) + var/list/parts = effect.report_parts() + return list( + "channel" = channel, + "kind" = parts["kind"], + "kind_key" = effect.energy_kind, + "release" = parts["release"], + "release_key" = effect.release_method, + "family" = parts["family"], + ) + +/// The printed report: lab paperwork with blanks for the analyst. +/obj/machinery/artifact_analyser/proc/report_text(obj/machinery/artifact/find) + reports_printed++ + var/list/lines = list() + lines += "
XENOARCHAEOLOGY
Analysis report no. [reports_printed]
[station_time_timestamp("hh:mm")]
" + lines += "
" + lines += "Specimen: [specimen_name(find)], [find.name]
" + lines += "Condition: [find.damaged ? "Fractured" : "Sound"]
" + lines += "
" + if(!find.main_effect && !find.second_effect) + lines += "No emissions detected.
" + else + lines += "Primary emission: [find.main_effect ? find.main_effect.report_line() : "None detected."]
" + lines += "Secondary emission: [find.second_effect ? find.second_effect.report_line() : "None detected."]
" + lines += "
" + lines += "Observations: \[____________________________________\]
" + lines += "\[____________________________________________________\]
" + lines += "Analyst: \[____________________\]" + return lines.Join("\n") + +/// Tears the tape off as a paper slip. +/obj/machinery/artifact_analyser/proc/tear_tape(mob/user) + if(!length(tape) || !COOLDOWN_FINISHED(src, tear_cooldown)) + return FALSE + COOLDOWN_START(src, tear_cooldown, 2 SECONDS) + var/obj/item/paper/artifact_tape/slip = new(drop_location()) + var/list/lines = list() + for(var/list/entry as anything in tape) + lines += "[entry["time"]] [entry["text"]][entry["count"] > 1 ? " (x[entry["count"]])" : ""]" + slip.add_raw_text("[lines.Join("
\n")]
", advanced_html = TRUE) + slip.update_appearance() + tape.Cut() + playsound(src, 'sound/items/poster/poster_ripped.ogg', 30, TRUE) + if(user) + user.put_in_hands(slip) + return TRUE + +// ===== Power, tools and looks ===== + +/obj/machinery/artifact_analyser/on_set_is_operational(old_value) + . = ..() + if(!is_operational) + abort_scan("Power lost. Scan stopped.") + update_appearance() + +/obj/machinery/artifact_analyser/update_icon_state() + if(panel_open) + icon_state = "[base_icon_state]_open" + else if(!is_operational) + icon_state = "[base_icon_state]_off" + else if(scan_target) + icon_state = "[base_icon_state]_working" + else + icon_state = base_icon_state + return ..() + +/obj/machinery/artifact_analyser/update_overlays() + . = ..() + if(!panel_open && is_operational) + . += emissive_appearance(icon, "[base_icon_state]_screen", src, alpha = 120) + +/obj/machinery/artifact_analyser/screwdriver_act(mob/living/user, obj/item/tool) + if(!default_deconstruction_screwdriver(user, "[base_icon_state]_open", base_icon_state, tool)) + return NONE + if(panel_open) + abort_scan("Panel open. Scan stopped.") + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/artifact_analyser/crowbar_act(mob/living/user, obj/item/tool) + if(default_deconstruction_crowbar(tool)) + return ITEM_INTERACT_SUCCESS + return NONE + +/obj/machinery/artifact_analyser/wrench_act(mob/living/user, obj/item/tool) + if(scan_target) + balloon_alert(user, "it's scanning!") + return ITEM_INTERACT_BLOCKING + if(default_unfasten_wrench(user, tool) == SUCCESSFUL_UNFASTEN) + find_pad() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/artifact_analyser/examine(mob/user) + . = ..() + if(!pad) + . += "Its pad lamp is dark." + else if(scan_target) + . += span_notice("Its screen is crawling with numbers.") + +// ===== tgui ===== + +/obj/machinery/artifact_analyser/ui_interact(mob/user, datum/tgui/ui) + find_pad() + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "XenoarchAnalyser", name) + ui.open() + +/obj/machinery/artifact_analyser/ui_static_data(mob/user) + var/list/colors = list() + for(var/key in GLOB.artifact_energy_kinds) + var/datum/artifact_energy_kind/kind = GLOB.artifact_energy_kinds[key] + colors[key] = kind.color + return list("kind_colors" = colors) + +/obj/machinery/artifact_analyser/ui_data(mob/user) + var/list/data = list() + data["powered"] = can_operate() + if(!pad || QDELETED(pad)) + data["pad"] = "none" + else + data["pad"] = pad.can_scan() ? "ready" : "offline" + data["scanning"] = !!scan_target + data["progress"] = scan_target ? clamp(round((world.time - scan_started) / scan_time * 100), 0, 100) : 0 + data["specimen"] = null + if(watched && !QDELETED(watched)) + data["specimen"] = list( + "serial" = specimen_name(watched), + "name" = watched.name, + "awake" = !!watched.is_active() && !watched.suppressed, + "resting" = !!watched.all_awake_resting(), + "label" = watched.label ? "[watched.label.effect_name] / [watched.label.trigger_name] / [watched.label.second_name]" : null, + ) + data["reading"] = null + if(reading) + var/obj/machinery/artifact/subject = reading_of?.resolve() + var/current = !!(subject && subject == watched) + var/list/shown = reading.Copy() + shown["current"] = current + // While the read find is on the pad, each emission's trace follows it live. + var/list/channels = list() + for(var/list/channel as anything in reading["channels"]) + var/list/live = channel.Copy() + var/datum/artifact_effect/effect = null + if(current) + effect = channel["channel"] == "Secondary" ? subject.second_effect : subject.main_effect + live["awake"] = !!(effect?.activated && !subject.suppressed) + live["resting"] = !!(effect?.activated && effect.exhausted) + channels += list(live) + shown["channels"] = channels + data["reading"] = shown + var/list/tape_lines = list() + for(var/list/entry as anything in tape) + tape_lines += list(list("time" = entry["time"], "text" = entry["text"], "tone" = entry["tone"], "count" = entry["count"])) + data["tape"] = tape_lines + return data + +/obj/machinery/artifact_analyser/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/user = ui.user + switch(action) + if("scan") + if(start_scan(user)) + return TRUE + if("halt") + if(scan_target) + say("Scan halted.") + abort_scan("Scan halted.") + return TRUE + if("tear") + return tear_tape(user) + return FALSE + +// ===== Paperwork ===== + +/// The printed analysis report. +/obj/item/paper/artifact_report + name = "analysis report" + +/// The analyser's stamp across the report. +/obj/item/paper/artifact_report/proc/stamp_report() + var/datum/asset/spritesheet_batched/sheet = get_asset_datum(/datum/asset/spritesheet/simple/paper) + if(QDELETED(src) || !sheet) + return + add_stamp(sheet.icon_class_name("stamp-rd"), rand(260, 330), rand(20, 70), rand(-15, 15), "stamp-rd") + update_appearance() + +/// A length of tape torn off the analyser. +/obj/item/paper/artifact_tape + name = "analyser tape" + desc = "A curl of narrow printer tape." + +/obj/item/circuitboard/machine/artifact_analyser + name = "Artifact Analyser" + greyscale_colors = CIRCUIT_COLOR_SCIENCE + build_path = /obj/machinery/artifact_analyser + req_components = list( + /datum/stock_part/scanning_module = 2, + /obj/item/stack/sheet/glass = 1, + ) + +#undef ANALYSER_TAPE_LENGTH +#undef ANALYSER_TAPE_REPEAT_WINDOW +#undef TAPE_NOTE +#undef TAPE_RESPONSE +#undef TAPE_QUIET +#undef TAPE_FAULT diff --git a/voidcrew/modules/xenoarch/study/labeler.dm b/voidcrew/modules/xenoarch/study/labeler.dm index 5202cbd12bb..c556fe510a7 100644 --- a/voidcrew/modules/xenoarch/study/labeler.dm +++ b/voidcrew/modules/xenoarch/study/labeler.dm @@ -1 +1,185 @@ -// The artifact labeler: prints the sticker a crew puts on a find. +/** + * # Specimen labeler + * + * New code on BeeStation's idea (42b0625, code/modules/xenoarchaeology/tools/xenoarchaeology_labeler.dm). + * Art: BeeStation icons/obj/xenoarchaeology/xenoartifact_tech.dmi "labeler", CC BY-SA 3.0; the + * print animation in icons/study.dmi was drawn from it for Voidcrew. + * + * A handheld tag printer. The crew dials in three words from its fixed lists (what the find does, + * what wakes it, its second effect or "None") and presses a printed tag onto a large find. Nothing + * says whether the tag is right; a buyer's price is the only answer. Right-click peels a tag off. + */ +/obj/item/artifact_labeler + name = "specimen labeler" + desc = "A handheld tag printer with a little green screen. Its word list is burned into memory and can't be changed." + icon = 'voidcrew/modules/xenoarch/icons/study.dmi' + icon_state = "labeler" + inhand_icon_state = "electronic" + lefthand_file = 'icons/mob/inhands/items/devices_lefthand.dmi' + righthand_file = 'icons/mob/inhands/items/devices_righthand.dmi' + item_flags = NOBLUDGEON + w_class = WEIGHT_CLASS_SMALL + drop_sound = 'sound/items/handling/tape_drop.ogg' + pickup_sound = 'sound/items/handling/tape_pickup.ogg' + custom_materials = list( + /datum/material/iron = SMALL_MATERIAL_AMOUNT * 2, + /datum/material/glass = SMALL_MATERIAL_AMOUNT * 1.5, + ) + /// What the find does, as an effect's label word + var/effect_word + /// What wakes it, as a trigger word + var/trigger_word + /// Its second effect's label word, or ARTIFACT_LABEL_NONE + var/second_word + COOLDOWN_DECLARE(print_cooldown) + +/obj/item/artifact_labeler/examine(mob/user) + . = ..() + if(effect_word || trigger_word || second_word) + . += "Its screen reads: [effect_word || "..."] / [trigger_word || "..."] / [second_word || "..."]" + +/obj/item/artifact_labeler/attack_self(mob/user, modifiers) + . = ..() + if(.) + return + ui_interact(user) + return TRUE + +/// Whether all three words are set. +/obj/item/artifact_labeler/proc/is_complete() + return effect_word && trigger_word && second_word + +/** + * Dials one field to a word from its list. Returns TRUE if it changed. + * `field` is "effect", "trigger" or "second"; a null word clears the field. + */ +/obj/item/artifact_labeler/proc/set_word(field, word) + if(word && !(word in words_for(field))) + return FALSE + switch(field) + if("effect") + if(effect_word == word) + return FALSE + effect_word = word + if("trigger") + if(trigger_word == word) + return FALSE + trigger_word = word + if("second") + if(second_word == word) + return FALSE + second_word = word + else + return FALSE + return TRUE + +/// The words a field may take. +/obj/item/artifact_labeler/proc/words_for(field) + switch(field) + if("effect") + return artifact_effect_label_names() + if("trigger") + return artifact_trigger_label_names() + if("second") + return list(ARTIFACT_LABEL_NONE) + artifact_effect_label_names() + return list() + +/// A label datum for the words now set, or null if any is missing. +/obj/item/artifact_labeler/proc/make_label() + if(!is_complete()) + return null + return new /datum/artifact_label(effect_word, trigger_word, second_word) + +/obj/item/artifact_labeler/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers) + if(!istype(interacting_with, /obj/machinery/artifact)) + return NONE + var/obj/machinery/artifact/find = interacting_with + if(!is_complete()) + balloon_alert(user, "the tag is half blank!") + return ITEM_INTERACT_BLOCKING + if(!COOLDOWN_FINISHED(src, print_cooldown)) + return ITEM_INTERACT_BLOCKING + COOLDOWN_START(src, print_cooldown, 2 SECONDS) + flick("labeler_print", src) + playsound(src, 'sound/items/taperecorder/taperecorder_print.ogg', 35, TRUE) + if(!do_after(user, 1 SECONDS, find)) + return ITEM_INTERACT_BLOCKING + var/datum/artifact_label/new_label = make_label() + if(!new_label) + return ITEM_INTERACT_BLOCKING + var/replaced = !!find.label + find.apply_label(new_label, user) + // A tag sets the sale price, and anyone can reach a find, so leave a trail for admins. + log_game("[key_name(user)] tagged artifact [find.serial] as [effect_word] / [trigger_word] / [second_word] at [AREACOORD(find)].") + playsound(find, 'sound/items/handling/component_pickup.ogg', 30, TRUE) + user.visible_message( + span_notice("[user] presses a printed tag onto [find]."), + span_notice(replaced ? "You peel off the old tag and press the new one onto [find]." : "You press the tag onto [find]."), + ) + return ITEM_INTERACT_SUCCESS + +/obj/item/artifact_labeler/interact_with_atom_secondary(atom/interacting_with, mob/living/user, list/modifiers) + if(!istype(interacting_with, /obj/machinery/artifact)) + return NONE + var/obj/machinery/artifact/find = interacting_with + if(!find.label) + balloon_alert(user, "no tag on it!") + return ITEM_INTERACT_BLOCKING + if(!do_after(user, 1 SECONDS, find)) + return ITEM_INTERACT_BLOCKING + if(!find.remove_label(user)) + return ITEM_INTERACT_BLOCKING + log_game("[key_name(user)] peeled the tag off artifact [find.serial] at [AREACOORD(find)].") + playsound(find, 'sound/items/poster/poster_ripped.ogg', 20, TRUE) + user.visible_message( + span_notice("[user] peels the tag off [find]."), + span_notice("You peel the tag off [find]."), + ) + return ITEM_INTERACT_SUCCESS + +// ===== tgui ===== + +/obj/item/artifact_labeler/ui_state(mob/user) + return GLOB.hands_state + +/obj/item/artifact_labeler/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "XenoarchLabeler", name) + ui.open() + +/obj/item/artifact_labeler/ui_static_data(mob/user) + return list( + "effects" = artifact_effect_label_names(), + "triggers" = artifact_trigger_label_names(), + "none" = ARTIFACT_LABEL_NONE, + ) + +/obj/item/artifact_labeler/ui_data(mob/user) + return list( + "effect" = effect_word, + "trigger" = trigger_word, + "second" = second_word, + ) + +/obj/item/artifact_labeler/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + switch(action) + if("set") + var/field = params["field"] + var/word = params["word"] + if(!istext(field) || (!isnull(word) && !istext(word))) + return FALSE + if(set_word(field, word)) + playsound(src, 'sound/machines/terminal/terminal_button03.ogg', 15, TRUE) + return TRUE + if("clear") + if(effect_word || trigger_word || second_word) + effect_word = null + trigger_word = null + second_word = null + playsound(src, 'sound/machines/terminal/terminal_button07.ogg', 15, TRUE) + return TRUE + return FALSE diff --git a/voidcrew/modules/xenoarch/study/research.dm b/voidcrew/modules/xenoarch/study/research.dm index 83ca4c85777..7e9a5164088 100644 --- a/voidcrew/modules/xenoarch/study/research.dm +++ b/voidcrew/modules/xenoarch/study/research.dm @@ -1 +1,61 @@ -// Research paid for analysing finds, and the curio point table. +/** + * # Xenoarchaeology research + * + * Large finds: the first full analysis of a dug find pays research once, whichever crew runs it. + * The flag lives on the find (research_paid), so passing one find between crews pays nothing + * more. The points come out of the analyser as research notes, which any R&D console takes, so + * no machine here needs a techweb link. + * + * Small finds: each curio type pays once per techweb through the destructive analyzer, by tg's + * own techweb_point_items table (subtypes count as their nearest listed type). + */ + +/** + * Claims the research for analysing `find`, once per find. Marks it paid and logs the metric; + * the caller prints the notes. Returns the points, or 0 if it was never dug or already paid. + */ +/proc/xenoarch_claim_analysis(obj/machinery/artifact/find, atom/analyser, mob/user) + if(QDELETED(find) || !find.is_dug()) + return 0 + var/points = artifact_research_value(find) + if(points <= 0) + return 0 + find.research_paid = TRUE + record_metric( + category = METRIC_RESEARCH, + event = XENOARCH_METRIC_ANALYSED, + subject = "[find.main_effect?.type]", + points = points, + zone = xenoarch_band_name(find.origin_band), + details = list("serial" = find.serial, "tier" = find.main_effect?.tier), + actor = user, + location = analyser, + ) + return points + +/// Prints claimed analysis research as notes at `where`. +/proc/xenoarch_print_analysis_notes(atom/where, points) + if(!where || points <= 0) + return null + return new /obj/item/research_notes(where, points, "xenoarchaeology") + +/** + * Research the destructive analyzer pays once per techweb for each kind of small find. + * Points sit between 20 and 60, under the cheapest research node, so a full set of curios is a + * pleasant extra rather than a research route. + */ +GLOBAL_LIST_INIT(xenoarch_curio_research_points, list( + // The strange object a technical, house or temple site gives up is tg's relic. + /obj/item/relic = 60, +)) + +/// Adds the curio table to tg's destructive-analyzer payouts. +/proc/xenoarch_register_curio_research() + for(var/curio_type in GLOB.xenoarch_curio_research_points) + if(SSresearch.techweb_point_items[curio_type]) + continue + SSresearch.techweb_point_items[curio_type] = list(TECHWEB_POINT_TYPE_GENERIC = GLOB.xenoarch_curio_research_points[curio_type]) + +/datum/controller/subsystem/research/Initialize() + . = ..() + xenoarch_register_curio_research() diff --git a/voidcrew/modules/xenoarch/study/scanpad.dm b/voidcrew/modules/xenoarch/study/scanpad.dm index 63a85615170..54b795108ab 100644 --- a/voidcrew/modules/xenoarch/study/scanpad.dm +++ b/voidcrew/modules/xenoarch/study/scanpad.dm @@ -1 +1,178 @@ -// The scan pad a find stands on for the analyser and harvester. +/** + * # Scan pad + * + * Machine art: NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/icons/machinery.dmi, + * by way of TauCetiClassic, CC BY-SA 3.0. The unpowered, open and glow states in icons/study.dmi + * were edited from it for Voidcrew. The code is new. + * + * A floor plate under a low arch of sensor rods. A large find stands under the arch for the + * analyser beside it to read (and for the harvester to drain). The pad only knows what stands on + * it: the moment a find arrives or leaves it tells whatever is listening, so a reading can never + * outlive its subject (COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED, defined in analyser.dm, which compiles + * first). Whichever machine holds the find's lock drives the beam with set_beam(). + */ + +/obj/machinery/artifact_scanpad + name = "scan pad" + desc = "A floor plate under a low arch of sensor rods. The rods are tipped with violet glass." + icon = 'voidcrew/modules/xenoarch/icons/study.dmi' + icon_state = "scanpad" + base_icon_state = "scanpad" + // The arch draws over whatever stands under it; the plate is an overlay beneath. + layer = ABOVE_MOB_LAYER + density = FALSE + anchored = TRUE + circuit = /obj/item/circuitboard/machine/artifact_scanpad + idle_power_usage = BASE_MACHINE_IDLE_CONSUMPTION * 0.5 + active_power_usage = BASE_MACHINE_ACTIVE_CONSUMPTION + /// The scanning beam is sweeping + var/beam_on = FALSE + /// Hum while the beam sweeps + var/datum/looping_sound/artifact_scanpad/scan_hum + +/obj/machinery/artifact_scanpad/Initialize(mapload) + . = ..() + var/static/list/loc_connections = list( + COMSIG_ATOM_ENTERED = PROC_REF(on_entered), + COMSIG_ATOM_EXITED = PROC_REF(on_exited), + ) + AddElement(/datum/element/connect_loc, loc_connections) + scan_hum = new(src) + update_appearance() + +/obj/machinery/artifact_scanpad/post_machine_initialize() + . = ..() + // An analyser built before its pad finds it now. + for(var/obj/machinery/artifact_analyser/analyser in orange(1, src)) + analyser.find_pad() + +/obj/machinery/artifact_scanpad/Destroy() + beam_on = FALSE + QDEL_NULL(scan_hum) + return ..() + +/// The large find standing on the pad, if any. +/obj/machinery/artifact_scanpad/proc/get_specimen() + if(!isturf(loc)) + return null + for(var/obj/machinery/artifact/find in loc) + if(!QDELETED(find)) + return find + return null + +/// Ready to read a find: powered, bolted down and closed up. +/obj/machinery/artifact_scanpad/proc/can_scan() + return is_operational && anchored && !panel_open + +/obj/machinery/artifact_scanpad/proc/on_entered(datum/source, atom/movable/arrived, atom/old_loc, list/atom/old_locs) + SIGNAL_HANDLER + if(istype(arrived, /obj/machinery/artifact)) + SEND_SIGNAL(src, COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED) + +/obj/machinery/artifact_scanpad/proc/on_exited(datum/source, atom/movable/gone, direction) + SIGNAL_HANDLER + if(istype(gone, /obj/machinery/artifact)) + SEND_SIGNAL(src, COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED) + +/obj/machinery/artifact_scanpad/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + // connect_loc follows us, but whoever was reading the old tile has to start over. + SEND_SIGNAL(src, COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED) + +/// Starts or stops the sweeping beam. +/obj/machinery/artifact_scanpad/proc/set_beam(on) + if(on == beam_on || QDELETED(src)) + return + beam_on = on + if(beam_on) + update_use_power(ACTIVE_POWER_USE) + playsound(src, 'sound/machines/destructive_scanner/TubeUp.ogg', 40, TRUE) + scan_hum?.start() + set_light(2, 0.8, "#a34de0") + else + update_use_power(IDLE_POWER_USE) + scan_hum?.stop() + set_light(0) + update_appearance() + +/obj/machinery/artifact_scanpad/on_set_is_operational(old_value) + . = ..() + if(!is_operational) + set_beam(FALSE) + update_appearance() + SEND_SIGNAL(src, COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED) + +/obj/machinery/artifact_scanpad/update_icon_state() + if(panel_open) + icon_state = "[base_icon_state]_open" + else if(!is_operational) + icon_state = "[base_icon_state]_off" + else + icon_state = base_icon_state + return ..() + +/obj/machinery/artifact_scanpad/update_overlays() + . = ..() + // The plate sits below anything standing on it. + . += mutable_appearance(icon, "[base_icon_state]_bottom", LOW_OBJ_LAYER) + if(panel_open || !is_operational) + return + . += emissive_appearance(icon, "[base_icon_state]_lights", src, alpha = 200) + if(beam_on) + . += mutable_appearance(icon, "[base_icon_state]_beam") + . += emissive_appearance(icon, "[base_icon_state]_beam", src) + +/obj/machinery/artifact_scanpad/screwdriver_act(mob/living/user, obj/item/tool) + if(beam_on) + balloon_alert(user, "it's scanning!") + return ITEM_INTERACT_BLOCKING + if(!default_deconstruction_screwdriver(user, "[base_icon_state]_open", base_icon_state, tool)) + return NONE + update_appearance() + SEND_SIGNAL(src, COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED) + return ITEM_INTERACT_SUCCESS + +/obj/machinery/artifact_scanpad/crowbar_act(mob/living/user, obj/item/tool) + if(default_deconstruction_crowbar(tool)) + return ITEM_INTERACT_SUCCESS + return NONE + +/obj/machinery/artifact_scanpad/wrench_act(mob/living/user, obj/item/tool) + if(beam_on) + balloon_alert(user, "it's scanning!") + return ITEM_INTERACT_BLOCKING + if(default_unfasten_wrench(user, tool) == SUCCESSFUL_UNFASTEN) + SEND_SIGNAL(src, COMSIG_XENOARCH_PAD_SPECIMEN_CHANGED) + return ITEM_INTERACT_SUCCESS + +/obj/machinery/artifact_scanpad/examine(mob/user) + . = ..() + var/obj/machinery/artifact/find = get_specimen() + if(find) + . += "[find] stands under the arch." + if(beam_on) + . += span_notice("A violet line sweeps slowly up and down under the arch.") + +/// The arch's hum while it sweeps. +/datum/looping_sound/artifact_scanpad + mid_sounds = list( + 'sound/machines/tcomms/tcomms_mid1.ogg' = 1, + 'sound/machines/tcomms/tcomms_mid3.ogg' = 1, + 'sound/machines/tcomms/tcomms_mid5.ogg' = 1, + ) + mid_length = 1.8 SECONDS + volume = 20 + extra_range = -6 + falloff_distance = 2 + falloff_exponent = 5 + ignore_walls = FALSE + +/obj/item/circuitboard/machine/artifact_scanpad + name = "Scan Pad" + greyscale_colors = CIRCUIT_COLOR_SCIENCE + build_path = /obj/machinery/artifact_scanpad + req_components = list( + /datum/stock_part/scanning_module = 2, + /datum/stock_part/micro_laser = 1, + /obj/item/stack/cable_coil = 2, + ) diff --git a/voidcrew/modules/xenoarch/study/techweb.dm b/voidcrew/modules/xenoarch/study/techweb.dm index 787847fb146..f136e7c949f 100644 --- a/voidcrew/modules/xenoarch/study/techweb.dm +++ b/voidcrew/modules/xenoarch/study/techweb.dm @@ -1 +1,86 @@ -// Xenoarchaeology research nodes and their designs. +/** + * # Xenoarchaeology research nodes + * + * Three nodes on tg's price ladder. The excavation kit itself comes from the ship autolathe and + * needs no research (dig/designs.dm). + * + * - Xenoarchaeology (tier 1): stabiliser, spectrometer, analyser and scan pad, labeler, specimen crate. + * - Advanced Excavation (tier 2): the advanced scanner and quick brush. + * - Exotic Particles (tier 3): the harvester, exotic battery and utilizer. + * + * Each node lists every design it unlocks, including those printed by the dig, use and trade + * parts of this module (named below by the file that defines them). + */ + +#define TECHWEB_NODE_XENOARCH "xenoarchaeology" +#define TECHWEB_NODE_XENOARCH_EXCAVATION "xenoarch_excavation" +#define TECHWEB_NODE_XENOARCH_EXOTIC "xenoarch_exotic_particles" + +/datum/techweb_node/xenoarchaeology + id = TECHWEB_NODE_XENOARCH + display_name = "Xenoarchaeology" + description = "Field stabilisers and a sample spectrometer for getting large finds out whole, and a bench analyser to read them once they're home." + prereq_ids = list(TECHWEB_NODE_FUNDIMENTAL_SCI) + design_ids = list( + "xenoarch_analyser", + "xenoarch_scanpad", + "xenoarch_labeler", + "xenoarch_stabiliser", // dig/stabiliser.dm + "xenoarch_spectrometer", // dig/spectrometer.dm + "xenoarch_specimen_crate", // trade/specimen_crate.dm + ) + research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_1_POINTS) + +/datum/techweb_node/xenoarch_excavation + id = TECHWEB_NODE_XENOARCH_EXCAVATION + display_name = "Advanced Excavation" + description = "A depth scanner that reads the stabilising field on the spot, and a quicker brush." + prereq_ids = list(TECHWEB_NODE_XENOARCH) + design_ids = list( + "xenoarch_scanner_advanced", // dig/tools.dm + "xenoarch_brush_quick", // dig/tools.dm + ) + research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_2_POINTS) + +/datum/techweb_node/xenoarch_exotic_particles + id = TECHWEB_NODE_XENOARCH_EXOTIC + display_name = "Exotic Particles" + description = "A harvester that draws a live emission out of a large find into a battery, and a handheld projector that can let it out again." + prereq_ids = list(TECHWEB_NODE_XENOARCH) + design_ids = list( + "xenoarch_harvester", // use/harvester.dm + "xenoarch_battery", // use/battery.dm + "xenoarch_utilizer", // use/utilizer.dm + ) + research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_3_POINTS) + +// ===== Designs printed by this part ===== + +/datum/design/board/artifact_analyser + name = "Machine Design (Artifact Analyser Board)" + desc = "The circuit board for a bench artifact analyser." + id = "xenoarch_analyser" + build_path = /obj/item/circuitboard/machine/artifact_analyser + category = list(RND_CATEGORY_MACHINE + RND_SUBCATEGORY_MACHINE_RESEARCH) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE + +/datum/design/board/artifact_scanpad + name = "Machine Design (Scan Pad Board)" + desc = "The circuit board for the scan pad an artifact analyser reads through." + id = "xenoarch_scanpad" + build_path = /obj/item/circuitboard/machine/artifact_scanpad + category = list(RND_CATEGORY_MACHINE + RND_SUBCATEGORY_MACHINE_RESEARCH) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE + +/datum/design/artifact_labeler + name = "Specimen Labeler" + desc = "A handheld printer for the tags that go on large finds." + id = "xenoarch_labeler" + build_type = PROTOLATHE | AWAY_LATHE + materials = list( + /datum/material/iron = SMALL_MATERIAL_AMOUNT * 2, + /datum/material/glass = SMALL_MATERIAL_AMOUNT * 1.5, + ) + build_path = /obj/item/artifact_labeler + category = list(RND_CATEGORY_EQUIPMENT + RND_SUBCATEGORY_EQUIPMENT_SCIENCE) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE From 0a0ad7703e8a18e8e16bef8d438c86dbe8d5be7e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:06:31 -0800 Subject: [PATCH 30/72] Gate the Exotic Particles node behind yellow standing The exotic battery and utilizer it unlocks are rated yellow. --- voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm | 2 ++ voidcrew/modules/xenoarch/study/techweb.dm | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm index 586a55130b6..951fa455106 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm @@ -183,6 +183,8 @@ for(var/design_id in expected[node_id][2]) TEST_ASSERT_NOTNULL(SSresearch.techweb_designs[design_id], "design [design_id] is missing") TEST_ASSERT(design_id in node.design_ids, "[node_id] doesn't unlock [design_id]") + TEST_ASSERT_EQUAL(SSresearch.techweb_node_by_id("xenoarchaeology").zone_permission, 0, "the analyser node needs no standing") + TEST_ASSERT_EQUAL(SSresearch.techweb_node_by_id("xenoarch_exotic_particles").zone_permission, 1, "the harvester node should need yellow standing (ZONE_PERMISSION_YELLOW)") var/datum/design/board/analyser_board = SSresearch.techweb_designs["xenoarch_analyser"] TEST_ASSERT_EQUAL(analyser_board.build_path, /obj/item/circuitboard/machine/artifact_analyser, "the analyser design prints the wrong board") var/list/curio_points = SSresearch.techweb_point_items[/obj/item/relic] diff --git a/voidcrew/modules/xenoarch/study/techweb.dm b/voidcrew/modules/xenoarch/study/techweb.dm index f136e7c949f..459e1be8a6b 100644 --- a/voidcrew/modules/xenoarch/study/techweb.dm +++ b/voidcrew/modules/xenoarch/study/techweb.dm @@ -6,7 +6,7 @@ * * - Xenoarchaeology (tier 1): stabiliser, spectrometer, analyser and scan pad, labeler, specimen crate. * - Advanced Excavation (tier 2): the advanced scanner and quick brush. - * - Exotic Particles (tier 3): the harvester, exotic battery and utilizer. + * - Exotic Particles (tier 3, yellow standing): the harvester, exotic battery and utilizer. * * Each node lists every design it unlocks, including those printed by the dig, use and trade * parts of this module (named below by the file that defines them). @@ -53,6 +53,8 @@ "xenoarch_utilizer", // use/utilizer.dm ) research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_3_POINTS) + // The utilizer and battery are rated yellow (tools/gear_catalog/ratings/relics.json). + zone_permission = ZONE_PERMISSION_YELLOW // ===== Designs printed by this part ===== From c879bfa9de50b79f7c2b12f40b4d158e5823cd20 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:06:43 -0800 Subject: [PATCH 31/72] Add xenoarchaeology dig sites, boulders, strange rocks and dig tools Planets get dig sites from a generator pass after the anomalies: a run of veined mountain wall with survey stakes and a spoil heap in front. What each site holds lives in the planet's dig reserve, so rebuilt surfaces only get what is left, and a boulder already cut loose comes back as it was. Site walls free a veined boulder (large find) or a strange rock (small find). Boulders are sounded, sampled, stabilised, hammered down and brushed out; they shrink as they are cut, and a find cut into or freed without the right field is lost. Large finds: alien artifacts, bluespace formations, charged crystals, sealed research cases, and in red sites a rare wrecked exosuit. Strange rocks hold curios by site kind and band, stamped as dug. Adds the rock hammer, brush, depth scanners, core sampler, field stabiliser, find satchel and webbing kit, the radiocarbon spectrometer, autolathe and research designs, gear ratings, the yellow and red dig ambush, and tests. --- tools/gear_catalog/categories.py | 1 + tools/gear_catalog/ratings/belts.json | 18 + tools/gear_catalog/ratings/relics.json | 1 + tools/gear_catalog/ratings/storage.json | 9 + tools/gear_catalog/ratings/tools.json | 64 +++ voidcrew/_DEFINES/xenoarch.dm | 54 +++ .../unit_tests/voidcrew_xenoarch_dig.dm | 259 +++++++++++ voidcrew/modules/xenoarch/dig/ambush.dm | 68 ++- voidcrew/modules/xenoarch/dig/boulder.dm | 406 +++++++++++++++++- voidcrew/modules/xenoarch/dig/curios.dm | 354 ++++++++++++++- voidcrew/modules/xenoarch/dig/designs.dm | 136 +++++- voidcrew/modules/xenoarch/dig/dig_reserve.dm | 131 +++++- voidcrew/modules/xenoarch/dig/dig_sites.dm | 366 +++++++++++++++- voidcrew/modules/xenoarch/dig/finds.dm | 351 ++++++++++++++- voidcrew/modules/xenoarch/dig/site_walls.dm | 104 ++++- voidcrew/modules/xenoarch/dig/spectrometer.dm | 194 ++++++++- voidcrew/modules/xenoarch/dig/stabiliser.dm | 69 ++- voidcrew/modules/xenoarch/dig/strange_rock.dm | 180 +++++++- voidcrew/modules/xenoarch/dig/tools.dm | 234 +++++++++- voidcrew/modules/xenoarch/icons/curios.dmi | Bin 0 -> 17159 bytes voidcrew/modules/xenoarch/icons/dig_finds.dmi | Bin 0 -> 2398 bytes .../modules/xenoarch/icons/dig_machines.dmi | Bin 0 -> 1434 bytes voidcrew/modules/xenoarch/icons/dig_scan.dmi | Bin 0 -> 1351 bytes voidcrew/modules/xenoarch/icons/dig_site.dmi | Bin 0 -> 9125 bytes voidcrew/modules/xenoarch/icons/dig_tools.dmi | Bin 0 -> 4816 bytes 25 files changed, 2982 insertions(+), 17 deletions(-) create mode 100644 voidcrew/modules/xenoarch/icons/curios.dmi create mode 100644 voidcrew/modules/xenoarch/icons/dig_finds.dmi create mode 100644 voidcrew/modules/xenoarch/icons/dig_machines.dmi create mode 100644 voidcrew/modules/xenoarch/icons/dig_scan.dmi create mode 100644 voidcrew/modules/xenoarch/icons/dig_site.dmi create mode 100644 voidcrew/modules/xenoarch/icons/dig_tools.dmi diff --git a/tools/gear_catalog/categories.py b/tools/gear_catalog/categories.py index 861e0dde54f..a9bad2a795f 100644 --- a/tools/gear_catalog/categories.py +++ b/tools/gear_catalog/categories.py @@ -208,6 +208,7 @@ ('/obj/item/dragons_blood', 'relics', ITEM), ('/obj/item/emergency_bed', 'tools', ITEM), ('/obj/item/experi_scanner', 'tools', ITEM), + ('/obj/item/excavation', 'tools', ITEM), ('/obj/item/extendohand', 'tools', ITEM), ('/obj/item/extinguisher', 'tools', ITEM), ('/obj/item/extraction_pack', 'tools', ITEM), diff --git a/tools/gear_catalog/ratings/belts.json b/tools/gear_catalog/ratings/belts.json index 527417b7636..e0827fa2280 100644 --- a/tools/gear_catalog/ratings/belts.json +++ b/tools/gear_catalog/ratings/belts.json @@ -37,6 +37,24 @@ "reason": "A trophy belt that holds one luchador mask.", "tier": "none" }, + "/obj/item/storage/belt/excavation": { + "confidence": "high", + "group": "mining belts", + "hash": "", + "name": "excavation webbing", + "rank": 3, + "reason": "Eight small slots for dig tools, scanners, a GPS and rock samples. Holds nothing a fight needs.", + "tier": "none" + }, + "/obj/item/storage/belt/excavation/full": { + "confidence": "high", + "group": "mining belts", + "hash": "", + "name": "excavation webbing", + "rank": 3, + "reason": "The excavation kit: webbing with a rock hammer, brush, depth scanner, core sampler and find satchel. Prints at round start.", + "tier": "none" + }, "/obj/item/storage/belt/fannypack": { "confidence": "high", "group": "fashion belts", diff --git a/tools/gear_catalog/ratings/relics.json b/tools/gear_catalog/ratings/relics.json index 66247cc16a8..7ab7eea7526 100644 --- a/tools/gear_catalog/ratings/relics.json +++ b/tools/gear_catalog/ratings/relics.json @@ -349,6 +349,7 @@ "/obj/item/hot_potato/harmless": "no route", "/obj/item/hot_potato/harmless/toy": "toy", "/obj/item/hot_potato/syndicate": "no route", + "/obj/item/relic/excavated": "no route", "/obj/item/shadowcloak/weaker": "no route", "/obj/item/soulstone": "no route", "/obj/item/soulstone/anybody/chaplain/sparring": "no route", diff --git a/tools/gear_catalog/ratings/storage.json b/tools/gear_catalog/ratings/storage.json index e4dbdf64bb9..5a146b4da88 100644 --- a/tools/gear_catalog/ratings/storage.json +++ b/tools/gear_catalog/ratings/storage.json @@ -53,6 +53,15 @@ "reason": "A bag for one kind of work item. Helps with the job, not with a fight.", "tier": "none" }, + "/obj/item/storage/bag/excavation": { + "confidence": "high", + "group": "work bags", + "hash": "", + "name": "find satchel", + "rank": 3, + "reason": "A bag for one kind of work item. Helps with the job, not with a fight.", + "tier": "none" + }, "/obj/item/storage/bag/fishing": { "confidence": "high", "group": "work bags", diff --git a/tools/gear_catalog/ratings/tools.json b/tools/gear_catalog/ratings/tools.json index 80c6cfaaa21..f51acee744f 100644 --- a/tools/gear_catalog/ratings/tools.json +++ b/tools/gear_catalog/ratings/tools.json @@ -749,6 +749,69 @@ "reason": "Wheels a patient along. Faster than dragging.", "tier": "none" }, + "/obj/item/excavation/brush": { + "confidence": "high", + "group": "Mining tools", + "hash": "", + "name": "excavation brush", + "rank": 24, + "reason": "Brushes the last grit off a dig find. Work tool only.", + "tier": "none" + }, + "/obj/item/excavation/brush/quick": { + "confidence": "high", + "group": "Mining tools", + "hash": "", + "name": "quick brush", + "rank": 25, + "reason": "Excavation brush that works in a third of the time. Saves minutes at a dig, nothing else.", + "tier": "none" + }, + "/obj/item/excavation/core_sampler": { + "confidence": "high", + "group": "Mining tools", + "hash": "", + "name": "core sampler", + "rank": 28, + "reason": "Draws a rock core from a dig find for the spectrometer. Work tool only.", + "tier": "none" + }, + "/obj/item/excavation/depth_scanner": { + "confidence": "high", + "group": "Mining tools", + "hash": "", + "name": "depth scanner", + "rank": 26, + "reason": "Reads roughly how deep a dig find sits and how much has been cut. Prints at round start.", + "tier": "none" + }, + "/obj/item/excavation/depth_scanner/advanced": { + "confidence": "medium", + "group": "Mining tools", + "hash": "", + "name": "advanced depth scanner", + "rank": 27, + "reason": "Reads a dig find exactly, and the field it needs, so a crew skips the trip back to the spectrometer. Speeds artifact digging; no use in a fight.", + "tier": "green" + }, + "/obj/item/excavation/hammer": { + "confidence": "high", + "group": "Mining tools", + "hash": "", + "name": "rock hammer", + "rank": 23, + "reason": "Rock hammer with swappable heads for cutting dig finds down. Hits for 6. Prints at round start.", + "tier": "none" + }, + "/obj/item/excavation/stabiliser": { + "confidence": "medium", + "group": "Mining tools", + "hash": "", + "name": "field stabiliser", + "rank": 29, + "reason": "Lays the field that keeps a large dig find whole as it comes out. Needed to get full value from artifacts; harmless otherwise.", + "tier": "green" + }, "/obj/item/experi_scanner": { "confidence": "high", "group": "Scanners and trackers", @@ -2993,6 +3056,7 @@ "/obj/item/door_remote/head_of_security": "no route", "/obj/item/door_remote/omni": "no route", "/obj/item/emergency_bed/silicon": "no route", + "/obj/item/excavation": "abstract", "/obj/item/extendohand/acme": "no route", "/obj/item/extinguisher/anti": "no route", "/obj/item/extinguisher/mini/nozzle": "uncreatable", diff --git a/voidcrew/_DEFINES/xenoarch.dm b/voidcrew/_DEFINES/xenoarch.dm index 0981e3337d9..c0882b0c22a 100644 --- a/voidcrew/_DEFINES/xenoarch.dm +++ b/voidcrew/_DEFINES/xenoarch.dm @@ -169,6 +169,60 @@ /// Minimum tiles between two dig sites. #define XENOARCH_SITE_SPACING 20 +/// do_after key for every dig tool, so one person works one thing at a time. +#define DOAFTER_SOURCE_XENOARCH "doafter_xenoarch" + +/// What a site wall gives up when it is mined. +#define XENOARCH_FIND_LARGE 1 +#define XENOARCH_FIND_SMALL 2 + +/// Dig site kinds (Bay's), which set the small finds a site holds. +#define XENOARCH_SITE_GARDEN "garden" +#define XENOARCH_SITE_ANIMAL "animal" +#define XENOARCH_SITE_HOUSE "house" +#define XENOARCH_SITE_TECHNICAL "technical" +#define XENOARCH_SITE_TEMPLE "temple" +#define XENOARCH_SITE_WAR "war" + +/// Large find shares, in percent, out of what a boulder holds. The rest is an alien artifact. +#define XENOARCH_FIND_SHARE_FORMATION 6 +#define XENOARCH_FIND_SHARE_CRYSTAL 4 +#define XENOARCH_FIND_SHARE_CASE 2 +/// Combat exosuit wrecks: red sites only, taken out of the artifact share. About one a round. +#define XENOARCH_FIND_SHARE_EXOSUIT_RED 1 + +/// A boulder's find sits this deep, in cm. +#define XENOARCH_BOULDER_DEPTH_MIN 25 +#define XENOARCH_BOULDER_DEPTH_MAX 200 +/// The plain depth scanner reads a boulder to within this many cm (a strange rock to a third of it). +#define XENOARCH_SCAN_ERROR 15 +/// Hammering costs this long per cm the head takes off. +#define XENOARCH_HAMMER_TIME_PER_CM (0.5 SECONDS) +/// One pass of the brush. +#define XENOARCH_BRUSH_TIME (3 SECONDS) +#define XENOARCH_BRUSH_TIME_QUICK (1 SECONDS) +/// A pick or drill takes this much off a boulder per swing, roughly. +#define XENOARCH_PICK_CUT_MIN 18 +#define XENOARCH_PICK_CUT_MAX 30 +/// Chance an unstabilised large find comes apart as it is freed. The rest come out damaged. +#define XENOARCH_UNSTABLE_CRUMBLE_CHANCE 50 + +/// Strange rocks: the find sits 6 to 14 cm deep, plus this much per tier. +#define XENOARCH_ROCK_DEPTH_PER_TIER 8 + +/// Curio classes, as buyers price them (design: shell 40, fossil 50, pottery 80, +/// statuette or instrument 150, strange object 200 cr). +#define XENOARCH_CURIO_SHELL "shell" +#define XENOARCH_CURIO_FOSSIL "fossil" +#define XENOARCH_CURIO_POTTERY "pottery" +#define XENOARCH_CURIO_STATUETTE "statuette" +#define XENOARCH_CURIO_INSTRUMENT "instrument" +#define XENOARCH_CURIO_STRANGE_OBJECT "strange object" + +/// Charged crystal: what it puts on the wire and for how long in all before it goes dull. +#define XENOARCH_CRYSTAL_POWER (15 KILO WATTS) +#define XENOARCH_CRYSTAL_LIFETIME (90 MINUTES) + // ===== Signals ===== // All sent on the artifact (/obj/machinery/artifact) unless noted. diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm index a2e008fae10..d060ea1ed8d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm @@ -1 +1,260 @@ // Xenoarchaeology tests: dig. +// +// Test files compile before voidcrew/_DEFINES, so the values below are literals. They mirror +// voidcrew/_DEFINES/xenoarch.dm: +// bands (ZONE_*): green 1, yellow 2, red 3 +// finds per site (large, small): green 1 + 3, yellow 1 + 3, red 2 + 2; 3 sites a planet +// XENOARCH_FIND_LARGE 1, XENOARCH_FIND_SMALL 2 +// XENOARCH_CRYSTAL_LIFETIME 90 minutes = 54000 deciseconds +// curio values: shell 40, fossil 50, pottery 80, statuette and instrument 150, strange object 200 + +/// The reserve holds a fixed supply: seeding happens once, finds come out one at a time, and a +/// boulder cut loose stays counted until it is opened or lost. +/datum/unit_test/voidcrew_xenoarch_dig_reserve/Run() + var/datum/planet_dig_reserve/red = allocate(/datum/planet_dig_reserve, 3) + TEST_ASSERT(red.seed(/datum/planet/lava), "the first build did not seed the reserve") + TEST_ASSERT(!red.seed(/datum/planet/lava), "a rebuild seeded the reserve again") + TEST_ASSERT_EQUAL(length(red.sites), 3, "a planet should hold three sites") + TEST_ASSERT_EQUAL(red.finds_left(), 12, "a red planet should hold twelve finds") + var/datum/dig_site_record/site = red.sites[1] + TEST_ASSERT_EQUAL(site.large_left, 2, "a red site should hold two large finds") + TEST_ASSERT_EQUAL(site.small_left, 2, "a red site should hold two small finds") + TEST_ASSERT_EQUAL(site.band, 3, "a red site was not stamped red") + + var/datum/planet_dig_reserve/green = allocate(/datum/planet_dig_reserve, 1) + green.seed(/datum/planet/beach) + var/datum/dig_site_record/green_site = green.sites[1] + TEST_ASSERT_EQUAL(green_site.large_left, 1, "a green site should hold one large find") + TEST_ASSERT_EQUAL(green_site.small_left, 3, "a green site should hold three small finds") + + TEST_ASSERT(site.take_find(1), "a large find could not be taken") + TEST_ASSERT(site.take_find(1), "the second large find could not be taken") + TEST_ASSERT(!site.take_find(1), "a third large find came out of a two-find site") + var/datum/dig_boulder_record/boulder = site.cut_boulder() + TEST_ASSERT_EQUAL(site.finds_left(), 3, "a boulder cut loose was not counted as still to come") + TEST_ASSERT_EQUAL(boulder.band, 3, "a red boulder was not stamped red") + TEST_ASSERT(boulder.target_depth >= 25 && boulder.target_depth <= 200, "boulder depth [boulder.target_depth] out of range") + TEST_ASSERT(boulder.field in GLOB.xenoarch_stabilising_fields, "boulder rolled unknown field [boulder.field]") + TEST_ASSERT(boulder.serial, "boulder has no catalogue number") + site.settle_boulder(boulder) + TEST_ASSERT_EQUAL(site.finds_left(), 2, "an opened boulder still counted") + qdel(boulder) + TEST_ASSERT_EQUAL(length(red.sites_with_finds()), 3, "a site with finds left was skipped") + site.small_left = 0 + TEST_ASSERT_EQUAL(length(red.sites_with_finds()), 2, "an emptied site would be rebuilt") + +/// Mining a site wall frees its find and debits the record; an unopened boulder survives its own +/// deletion (a planet teardown) and comes back as it was. +/datum/unit_test/voidcrew_xenoarch_site_wall + /// Turfs this test turned to rock -> their original type, put back even if an assertion fails + var/list/changed_turfs = list() + +/datum/unit_test/voidcrew_xenoarch_site_wall/Destroy() + for(var/turf/changed as anything in changed_turfs) + changed.ChangeTurf(changed_turfs[changed]) + changed_turfs = null + return ..() + +/datum/unit_test/voidcrew_xenoarch_site_wall/Run() + var/datum/dig_site_record/site = allocate(/datum/dig_site_record, 2, "house") + site.large_left = 1 + site.small_left = 1 + var/turf/large_spot = run_loc_floor_bottom_left + var/turf/small_spot = get_step(run_loc_floor_bottom_left, EAST) + changed_turfs[large_spot] = large_spot.type + changed_turfs[small_spot] = small_spot.type + + var/turf/closed/mineral/large_wall = large_spot.ChangeTurf(/turf/closed/mineral) + large_wall.make_dig_site_wall(site, 1) + TEST_ASSERT_EQUAL(large_wall.mineralType, null, "a site wall kept ore") + TEST_ASSERT_EQUAL(large_wall.scan_state, "xenoarch_site", "a site wall has no scanner mark") + TEST_ASSERT(!ambient_ore_wall_ok(large_wall), "NPC miners would dig a site wall") + large_wall.gets_drilled() + var/obj/structure/xenoarch_boulder/boulder = locate() in large_spot + TEST_ASSERT_NOTNULL(boulder, "mining a large-find wall left no boulder") + TEST_ASSERT_EQUAL(site.large_left, 0, "the large find was not taken from the record") + TEST_ASSERT_EQUAL(length(site.loose), 1, "the cut boulder was not kept in the record") + var/datum/dig_boulder_record/kept = site.loose[1] + TEST_ASSERT_EQUAL(boulder.record, kept, "the boulder does not draw its record") + kept.dug = 40 + var/serial = kept.serial + qdel(boulder) + TEST_ASSERT(!QDELETED(kept), "a torn-down boulder deleted its record") + TEST_ASSERT_EQUAL(length(site.loose), 1, "a torn-down boulder left its site") + var/obj/structure/xenoarch_boulder/rebuilt = new(large_spot, kept, site) + TEST_ASSERT_EQUAL(rebuilt.record.serial, serial, "the rebuilt boulder has a new catalogue number") + TEST_ASSERT_EQUAL(rebuilt.record.dug, 40, "the rebuilt boulder lost its cut depth") + TEST_ASSERT_EQUAL(rebuilt.cut_stage(), 1, "40 cm cut should show the second stage") + qdel(rebuilt) + + var/turf/closed/mineral/small_wall = small_spot.ChangeTurf(/turf/closed/mineral) + small_wall.make_dig_site_wall(site, 2) + small_wall.gets_drilled() + var/obj/item/strange_rock/rock = locate() in small_spot + TEST_ASSERT_NOTNULL(rock, "mining a small-find wall left no strange rock") + TEST_ASSERT_EQUAL(rock?.band, 2, "the strange rock was not stamped with its site's band") + TEST_ASSERT_EQUAL(site.small_left, 0, "the small find was not taken from the record") + + // A flagged wall whose record ran dry gives nothing. + var/turf/closed/mineral/spent_wall = small_spot.ChangeTurf(/turf/closed/mineral) + spent_wall.make_dig_site_wall(site, 2) + spent_wall.gets_drilled() + var/count = 0 + for(var/obj/item/strange_rock/extra in small_spot) + count++ + TEST_ASSERT_EQUAL(count, 1, "an emptied site gave another rock") + +/// Brushed at the right depth with the right field, the find comes out whole, stamped and with the +/// boulder's number; cut past it, nothing comes out at all. +/datum/unit_test/voidcrew_xenoarch_boulder/Run() + var/obj/structure/xenoarch_boulder/boulder = allocate(/obj/structure/xenoarch_boulder) + var/datum/dig_boulder_record/record = boulder.record + // Green: no ambush can wake in a test. + record.band = 1 + record.find_type = /obj/machinery/artifact + record.target_depth = 30 + record.dug = 30 + record.stabilised = TRUE + var/obj/machinery/artifact/find = boulder.free_find(null) + TEST_ASSERT(istype(find), "a stabilised boulder at depth gave no artifact") + TEST_ASSERT(QDELETED(boulder), "the boulder outlived its find") + TEST_ASSERT_EQUAL(find?.origin_band, 1, "the find was not stamped with the boulder's band") + TEST_ASSERT_EQUAL(find?.serial, record.serial, "the find did not keep the boulder's catalogue number") + TEST_ASSERT(!find?.damaged, "a stabilised find came out damaged") + qdel(find) + + var/obj/structure/xenoarch_boulder/overcut = allocate(/obj/structure/xenoarch_boulder) + overcut.record.band = 1 + overcut.record.find_type = /obj/machinery/artifact + overcut.record.target_depth = 30 + overcut.record.dug = 28 + overcut.cut(5, null) + TEST_ASSERT(QDELETED(overcut), "a boulder cut past its find survived") + TEST_ASSERT_NULL(locate(/obj/machinery/artifact) in run_loc_floor_bottom_left, "a boulder cut past its find still gave an artifact") + + // Without the right field it either comes apart or comes out damaged, never whole. + for(var/i in 1 to 6) + var/obj/structure/xenoarch_boulder/loose = allocate(/obj/structure/xenoarch_boulder) + loose.record.band = 1 + loose.record.find_type = /obj/machinery/artifact + loose.record.target_depth = 25 + loose.record.dug = 25 + loose.record.stabilised = FALSE + var/obj/machinery/artifact/unheld = loose.free_find(null) + TEST_ASSERT(QDELETED(loose), "an unstabilised boulder survived being freed") + if(unheld) + TEST_ASSERT(unheld.damaged, "an unstabilised find came out whole") + qdel(unheld) + + // It shrinks by depth cut, never by depth left. + var/obj/structure/xenoarch_boulder/shrinking = allocate(/obj/structure/xenoarch_boulder) + shrinking.record.dug = 25 + TEST_ASSERT_EQUAL(shrinking.cut_stage(), 0, "25 cm cut should still show the first stage") + shrinking.record.dug = 150 + TEST_ASSERT_EQUAL(shrinking.cut_stage(), 3, "150 cm cut should show the last stage") + + // A find dug in another band keeps that band wherever it is freed. + var/obj/machinery/artifact/yellow = xenoarch_spawn_large_find(/obj/machinery/artifact, run_loc_floor_bottom_left, 2, TRUE, "Tau-1001") + TEST_ASSERT_EQUAL(yellow?.origin_band, 2, "a yellow find was not stamped yellow") + TEST_ASSERT_EQUAL(yellow?.serial, "Tau-1001", "a find did not take the catalogue number it was given") + TEST_ASSERT(yellow?.damaged, "a find freed damaged came out whole") + qdel(yellow) + +/// A strange rock frees a stamped, marked curio at depth and nothing past it. +/datum/unit_test/voidcrew_xenoarch_strange_rock/Run() + var/obj/item/strange_rock/rock = allocate(/obj/item/strange_rock, run_loc_floor_bottom_left, 2, "house") + TEST_ASSERT_EQUAL(rock.band, 2, "a yellow rock was not stamped yellow") + TEST_ASSERT(rock.tier >= 1 && rock.tier <= 3, "rock tier [rock.tier] out of range") + rock.find_type = /obj/item/xenoarch_curio/bowl + rock.target_depth = 10 + rock.dug = 10 + var/obj/item/xenoarch_curio/bowl = rock.free_find(null) + TEST_ASSERT(istype(bowl), "a rock at depth gave no curio") + TEST_ASSERT_EQUAL(bowl?.origin_band, 2, "the curio was not stamped with its rock's band") + TEST_ASSERT_EQUAL(bowl?.dig_value, 80, "a bowl was not marked at the pottery price") + TEST_ASSERT_EQUAL(xenoarch_find_band(bowl), 2, "xenoarch_find_band() can't read a curio") + qdel(bowl) + + var/obj/item/xenoarch_curio/spawned = allocate(/obj/item/xenoarch_curio/shell) + TEST_ASSERT_NULL(xenoarch_find_band(spawned), "a curio that never came out of the ground reads as dug") + + var/obj/item/strange_rock/overcut = allocate(/obj/item/strange_rock, run_loc_floor_bottom_left, 1, "garden") + overcut.find_type = /obj/item/xenoarch_curio/shell + overcut.target_depth = 10 + overcut.dug = 11 + overcut.lose_find(null) + TEST_ASSERT(QDELETED(overcut), "a rock cut past its find survived") + var/count = 0 + for(var/obj/item/xenoarch_curio/shell/shell in run_loc_floor_bottom_left) + if(shell != spawned) + count++ + TEST_ASSERT_EQUAL(count, 0, "a rock cut past its find still gave a curio") + + // Every site kind and tier gives something real. + for(var/kind in list("garden", "animal", "house", "technical", "temple", "war")) + for(var/tier in 1 to 3) + var/find_type = xenoarch_small_find(kind, tier) + TEST_ASSERT(ispath(find_type, /atom/movable) || find_type == /datum/xenoarch_research_draw, "[kind] tier [tier] gives [find_type]") + +/// Large finds other than artifacts: shares by band, stamps, and what each one comes out as. +/datum/unit_test/voidcrew_xenoarch_large_finds/Run() + for(var/i in 1 to 400) + var/find_type = xenoarch_roll_large_find(1) + TEST_ASSERT(!ispath(find_type, /obj/vehicle/sealed/mecha), "a green site rolled an exosuit") + TEST_ASSERT(ispath(find_type, /atom/movable), "a green site rolled [find_type]") + + var/turf/spot = run_loc_floor_bottom_left + var/obj/machinery/power/charged_crystal/crystal = xenoarch_spawn_large_find(/obj/machinery/power/charged_crystal, spot, 3, TRUE) + TEST_ASSERT(istype(crystal), "no charged crystal came out") + TEST_ASSERT_EQUAL(crystal?.origin_band, 3, "the crystal was not stamped") + TEST_ASSERT_EQUAL(crystal?.charge_left, 27000, "a damaged crystal should hold half its charge") + TEST_ASSERT(!crystal?.can_run(), "an unwired, loose crystal would run") + crystal.wired = TRUE + crystal.set_anchored(TRUE) + TEST_ASSERT(!crystal.can_run(), "a crystal with no cable under it would run") + crystal.charge_left = 0 + TEST_ASSERT(crystal.is_dull(), "a spent crystal is not dull") + qdel(crystal) + + var/obj/machinery/artifact/bluespace_formation/formation = xenoarch_spawn_large_find(/obj/machinery/artifact/bluespace_formation, spot, 2, FALSE, "Sigma-1234") + TEST_ASSERT(istype(formation), "no formation came out") + TEST_ASSERT_EQUAL(formation?.origin_band, 2, "the formation was not stamped") + TEST_ASSERT_NULL(formation?.main_effect, "a formation rolled an artifact effect") + qdel(formation) + + var/obj/structure/closet/crate/zone_loot/research/case = xenoarch_spawn_large_find(/obj/structure/closet/crate/zone_loot/research, spot, 3) + TEST_ASSERT_EQUAL(case?.loot_zone, 3, "a sealed case was not tiered by where it was dug") + qdel(case) + + var/obj/vehicle/sealed/mecha/gygax/wreck = xenoarch_spawn_large_find(/obj/vehicle/sealed/mecha/gygax, spot, 3) + TEST_ASSERT(istype(wreck), "no exosuit came out") + TEST_ASSERT(wreck.get_integrity() <= wreck.max_integrity * 0.4, "the exosuit came out undamaged") + TEST_ASSERT(!wreck.cell || wreck.cell.charge <= wreck.cell.maxcharge * 0.2, "the exosuit came out with a full cell") + qdel(wreck) + +/// Every sprite state the dig code can ask for exists. +/datum/unit_test/voidcrew_xenoarch_dig_icons/Run() + var/site_icon = 'voidcrew/modules/xenoarch/icons/dig_site.dmi' + for(var/variant in 1 to 4) + for(var/stage in 0 to 3) + TEST_ASSERT(icon_exists(site_icon, "boulder[variant]_[stage]"), "missing boulder[variant]_[stage]") + TEST_ASSERT(icon_exists(site_icon, "boulder[variant]_[stage]_vein"), "missing boulder[variant]_[stage]_vein") + for(var/state in list("vein1", "vein2", "vein3", "stake_orange", "stake_red", "spoil1", "spoil2", "field", "holotag", "survey_pin")) + TEST_ASSERT(icon_exists(site_icon, state), "missing dig site state [state]") + var/tool_icon = 'voidcrew/modules/xenoarch/icons/dig_tools.dmi' + for(var/head in list(1, 2, 4, 6, 10)) + TEST_ASSERT(icon_exists(tool_icon, "hammer_[head]"), "missing hammer_[head]") + for(var/state in list("brush", "brush_quick", "depth_scanner", "depth_scanner_adv", "sampler", "sampler_full", "stabiliser", "stabiliser_lamp", "find_bag")) + TEST_ASSERT(icon_exists(tool_icon, state), "missing tool state [state]") + for(var/state in list("charged_crystal", "charged_crystal_active", "charged_crystal_dull", "charged_crystal_wires")) + TEST_ASSERT(icon_exists('voidcrew/modules/xenoarch/icons/dig_finds.dmi', state), "missing crystal state [state]") + for(var/state in list("spectrometer", "spectrometer_processing", "spectrometer_open")) + TEST_ASSERT(icon_exists('voidcrew/modules/xenoarch/icons/dig_machines.dmi', state), "missing spectrometer state [state]") + TEST_ASSERT(icon_exists('voidcrew/modules/xenoarch/icons/dig_scan.dmi', "xenoarch_site"), "missing scanner mark") + for(var/stage in 0 to 2) + TEST_ASSERT(icon_exists('voidcrew/modules/xenoarch/icons/curios.dmi', "strange_rock_[stage]"), "missing strange_rock_[stage]") + for(var/obj/item/xenoarch_curio/curio_type as anything in subtypesof(/obj/item/xenoarch_curio)) + var/obj/item/xenoarch_curio/curio = new curio_type(run_loc_floor_bottom_left) + for(var/state in curio.variants) + TEST_ASSERT(icon_exists(curio.icon, state), "[curio_type] has no sprite state [state]") + qdel(curio) diff --git a/voidcrew/modules/xenoarch/dig/ambush.dm b/voidcrew/modules/xenoarch/dig/ambush.dm index 0dcb6ab9b0e..3fa934fda1b 100644 --- a/voidcrew/modules/xenoarch/dig/ambush.dm +++ b/voidcrew/modules/xenoarch/dig/ambush.dm @@ -1 +1,67 @@ -// Waking local fauna when a large find comes out of a yellow or red dig site. +/** + * # Dig ambush + * + * Freeing a large find in a yellow or red site can wake whatever lives in the rock nearby + * (XENOARCH_AMBUSH_CHANCE). The ground rumbles first, then a pack crawls out a few tiles off, + * never on top of the crew. Reuses the field-expedition wave: a zone mob marker with a 1 to 2 + * count, wildlife or spiders, scaled by the site's own zone. + */ + +/// How long the rumble lasts before something comes out. +#define DIG_AMBUSH_WARNING (3 SECONDS) + +/// Rolls for an ambush at a freed find. Returns TRUE if one is coming. +/proc/xenoarch_dig_ambush(turf/site, band, mob/digger) + if(!isturf(site)) + return FALSE + var/list/chances = XENOARCH_AMBUSH_CHANCE + if(!prob(chances[XENOARCH_BAND_INDEX(band)])) + return FALSE + var/turf/lair = xenoarch_ambush_lair(site) + if(!lair) + return FALSE + playsound(lair, pick( + 'sound/ambience/earth_rumble/earth_rumble_distant1.ogg', + 'sound/ambience/earth_rumble/earth_rumble_distant2.ogg', + 'sound/ambience/earth_rumble/earth_rumble_distant3.ogg', + ), 70, FALSE, 4) + site.visible_message(span_warning("The ground shudders. Something answers the noise from deeper in the rock.")) + new /obj/effect/temp_visual/xenoarch_dust(lair, "#8f8a83", FALSE) + var/theme = pick_weight(list( + /obj/effect/zone_mobs/wildlife = 6, + /obj/effect/zone_mobs/bug = 4, + )) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(xenoarch_spring_ambush), lair, theme), DIG_AMBUSH_WARNING) + if(digger) + log_game("[key_name(digger)] woke a dig-site ambush at [AREACOORD(lair)].") + return TRUE + +/// Open ground 4 to 7 tiles from the find, preferably against the rock, for the pack to come from. +/proc/xenoarch_ambush_lair(turf/site) + var/list/by_rock = list() + var/list/open = list() + for(var/turf/open/tile in RANGE_TURFS(7, site)) + if(get_dist(tile, site) < 4 || isgroundlessturf(tile) || tile.is_blocked_turf(exclude_mobs = TRUE)) + continue + if(istype(tile, /turf/open/water) || istype(tile, /turf/open/lava)) + continue + if(!istype(tile.loc, /area/overmap_encounter/planetoid)) + continue + open += tile + for(var/direction in GLOB.cardinals) + if(ismineralturf(get_step(tile, direction))) + by_rock += tile + break + if(length(by_rock)) + return pick(by_rock) + return length(open) ? pick(open) : null + +/// The pack comes out, unless the planet went away in the meantime. +/proc/xenoarch_spring_ambush(turf/lair, theme) + if(!isopenturf(lair) || !istype(lair.loc, /area/overmap_encounter/planetoid)) + return + playsound(lair, 'sound/effects/break_stone.ogg', 60, TRUE) + new /obj/effect/temp_visual/xenoarch_dust(lair, "#8f8a83", FALSE) + new theme(lair, list(1, 2)) + +#undef DIG_AMBUSH_WARNING diff --git a/voidcrew/modules/xenoarch/dig/boulder.dm b/voidcrew/modules/xenoarch/dig/boulder.dm index 12b733953fb..a7322cb16e4 100644 --- a/voidcrew/modules/xenoarch/dig/boulder.dm +++ b/voidcrew/modules/xenoarch/dig/boulder.dm @@ -1 +1,405 @@ -// The boulder a large find sits in: depth, stabilising, chipping down and brushing out. +/** + * # Veined boulder + * + * What a large find sits in once its site wall is mined. Ported from NovaSector (5ce2bd2215c0) + * modular_nova/modules/xenoarchartifacts/artifacts/artifact.dm (/obj/structure/boulder), reworked: + * - All state lives in a /datum/dig_boulder_record held by the planet's dig reserve, so a rebuilt + * planet puts the boulder back exactly as the crew left it. + * - One depth scanner sounds and measures it (Nova's scanner plus tape measure); the advanced one + * reads the exact depth and the stabilising field on site, skipping the spectrometer trip. + * - One hammer with a fitted head, a pick or drill for rough work, and the brush to finish: + * brushing never overshoots, it only takes a centimetre at a time until the find shows. + * - Cutting past the find loses it. Freeing it without the right stabilising field loses it half + * the time and damages it otherwise. A lost find leaves nothing behind: no artifact is made and + * no burst fires (Nova spawned and deleted one, setting off its destroy effect). + * - It shrinks visibly by depth cut (never by depth left, which would give the find away), chips + * and dusts on every strike, and the find comes out of a settling cloud of grit. + */ +/obj/structure/xenoarch_boulder + name = "veined boulder" + desc = "A heavy block of veined rock, cut loose from the face. Something harder sits inside it." + icon = 'voidcrew/modules/xenoarch/icons/dig_site.dmi' + icon_state = "boulder1_0" + anchored = TRUE + density = TRUE + max_integrity = 150 + resistance_flags = FIRE_PROOF | LAVA_PROOF + /// Everything about this boulder; kept in its site's record while it is unopened + var/datum/dig_boulder_record/record + /// The dig site it came from (weakref to /datum/dig_site_record), if any + var/datum/weakref/site_ref + /// Next world.time a working tool makes its noise + var/next_work_noise = 0 + +/obj/structure/xenoarch_boulder/Initialize(mapload, datum/dig_boulder_record/new_record, datum/dig_site_record/site) + . = ..() + record = new_record || new /datum/dig_boulder_record(xenoarch_band_for_turf(src)) + if(site) + site_ref = WEAKREF(site) + color = record.rock_color || "#8f8a83" + if(record.tagged) + AddComponent(/datum/component/gps, "[record.serial] dig") + update_appearance() + +/obj/structure/xenoarch_boulder/Destroy() + var/datum/dig_site_record/site = site_ref?.resolve() + if(record && !record.settled && site && (record in site.loose)) + // Torn down with its planet: the record stays in the reserve and the next build puts the + // boulder back as it was. Deleted any other way on a live planet (an admin, a ship setting + // down on it), it is gone for good. + if(!xenoarch_boulder_kept_on_delete(loc)) + settle() + record_metric(METRIC_RESEARCH, XENOARCH_METRIC_LOST, subject = "[record.find_type]", zone = xenoarch_band_name(record.band), details = list("size" = "large", "reason" = "removed"), location = loc) + if(record && (record.settled || !site || !(record in site.loose))) + QDEL_NULL(record) + record = null + site_ref = null + return ..() + +/// Whether a boulder deleted at `place` keeps its record for the next build: its planet is being +/// torn down, or no planet holds it at all (nothing would rebuild it, so nothing is lost either). +/proc/xenoarch_boulder_kept_on_delete(atom/place) + var/turf/here = get_turf(place) + if(!here) + return TRUE + var/obj/structure/overmap/planet/planet = SSovermap_zones?.get_overmap_object_for_turf(here) + return !istype(planet) || planet.unloading + +/// How far it has been cut down, by depth cut: 0 untouched to 3 a stub of rock. +/obj/structure/xenoarch_boulder/proc/cut_stage() + switch(record?.dug) + if(140 to INFINITY) + return 3 + if(80 to 140) + return 2 + if(30 to 80) + return 1 + return 0 + +/obj/structure/xenoarch_boulder/update_icon_state() + icon_state = "boulder[record?.variant || 1]_[cut_stage()]" + return ..() + +/obj/structure/xenoarch_boulder/update_overlays() + . = ..() + if(!record) + return + . += mutable_appearance(icon, "boulder[record.variant]_[cut_stage()]_vein", appearance_flags = RESET_COLOR) + if(record.sounded) + . += mutable_appearance(icon, "survey_pin", appearance_flags = RESET_COLOR) + if(record.tagged) + . += mutable_appearance(icon, "holotag", appearance_flags = RESET_COLOR) + if(record.field_applied) + . += mutable_appearance(icon, "field", appearance_flags = RESET_COLOR) + +/obj/structure/xenoarch_boulder/examine(mob/user) + . = ..() + if(!record) + return + if(record.tagged) + . += "A holotag on it reads [record.serial]." + switch(record.sounded) + if(1) + . += "A survey tag pinned to it reads: find at [max(1, record.rough_depth - XENOARCH_SCAN_ERROR)] to [record.rough_depth + XENOARCH_SCAN_ERROR] cm. Cut [record.dug] cm." + if(2) + . += "A survey tag pinned to it reads: find at [record.target_depth] cm. Cut [record.dug] cm." + if(record.field_applied) + . += span_notice("A faint lattice of light holds it, set to the [lowertext(record.field_applied)].") + +// ===== Tools ===== + +/obj/structure/xenoarch_boulder/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!record || record.settled) + return NONE + if(istype(tool, /obj/item/mining_scanner) || istype(tool, /obj/item/t_scanner/adv_mining_scanner)) + return tag_with(tool, user) + if(istype(tool, /obj/item/excavation/hammer)) + return hammer_with(tool, user) + if(istype(tool, /obj/item/excavation/brush)) + return brush_with(tool, user) + if(istype(tool, /obj/item/excavation/depth_scanner)) + return sound_with(tool, user) + if(istype(tool, /obj/item/excavation/core_sampler)) + return sample_with(tool, user) + if(istype(tool, /obj/item/excavation/stabiliser)) + return stabilise_with(tool, user) + if(tool.tool_behaviour == TOOL_MINING) + return rough_cut_with(tool, user) + return NONE + +/// The holotag: a GPS signal under its catalogue number, so the crew (and anyone else) can find it again. +/obj/structure/xenoarch_boulder/proc/tag_with(obj/item/scanner, mob/living/user) + if(record.tagged) + balloon_alert(user, "already tagged") + return ITEM_INTERACT_BLOCKING + record.tagged = TRUE + AddComponent(/datum/component/gps, "[record.serial] dig") + playsound(src, 'sound/machines/beep/twobeep.ogg', 40, TRUE) + user.visible_message( + span_notice("[user] fixes a holotag to [src]."), + span_notice("You fix a holotag to [src]. It blinks to life: [record.serial]."), + ) + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/structure/xenoarch_boulder/proc/hammer_with(obj/item/excavation/hammer/hammer, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + var/cm = hammer.head + user.visible_message( + span_notice("[user] starts chipping at [src] with [hammer]."), + span_notice("You start chipping at [src] with the [cm] cm head."), + blind_message = span_hear("You hear steady chipping."), + ) + if(!do_after(user, cm * XENOARCH_HAMMER_TIME_PER_CM, src, extra_checks = CALLBACK(src, PROC_REF(work_noise), SFX_ROCK_TAP, 0.6 SECONDS, TRUE), interaction_key = DOAFTER_SOURCE_XENOARCH)) + // Stopping mid-swing is how a careful hand slips. + if(!QDELETED(src) && record && !record.settled && prob(50)) + to_chat(user, span_warning("Your hammer glances off and takes a chip out of [src].")) + cut(rand(1, 2), user) + return ITEM_INTERACT_BLOCKING + cut(cm, user) + return ITEM_INTERACT_SUCCESS + +/obj/structure/xenoarch_boulder/proc/rough_cut_with(obj/item/tool, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + user.visible_message( + span_notice("[user] starts breaking rock off [src] with [tool]."), + span_notice("You start breaking rock off [src]."), + blind_message = span_hear("You hear rock being broken."), + ) + if(!tool.use_tool(src, user, 2.5 SECONDS, volume = 50, extra_checks = CALLBACK(src, PROC_REF(work_noise), 'sound/effects/pickaxe/picaxe1.ogg', 0.8 SECONDS, TRUE))) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src) || record.settled) + return ITEM_INTERACT_BLOCKING + cut(rand(XENOARCH_PICK_CUT_MIN, XENOARCH_PICK_CUT_MAX), user) + return ITEM_INTERACT_SUCCESS + +/obj/structure/xenoarch_boulder/proc/brush_with(obj/item/excavation/brush/brush, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(!do_after(user, brush.brush_time, src, extra_checks = CALLBACK(src, PROC_REF(work_noise), SFX_RUSTLE, 0.9 SECONDS, FALSE), interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src) || record.settled) + return ITEM_INTERACT_BLOCKING + if(record.dug > record.target_depth) + lose_find(user, "cut too deep") + return ITEM_INTERACT_SUCCESS + if(record.dug == record.target_depth) + free_find(user) + return ITEM_INTERACT_SUCCESS + record.dug++ + dust_puff(small = TRUE) + if(record.sounded) + balloon_alert(user, "[record.dug] cm") + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/structure/xenoarch_boulder/proc/sound_with(obj/item/excavation/depth_scanner/scanner, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + playsound(src, 'sound/effects/industrial_scan/industrial_scan1.ogg', 35, TRUE) + if(!do_after(user, scanner.scan_time, src, interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src) || record.settled) + return ITEM_INTERACT_BLOCKING + record.sounded = max(record.sounded, scanner.exact ? 2 : 1) + playsound(src, 'sound/machines/beep/twobeep.ogg', 30, TRUE) + var/list/reading = list() + if(scanner.exact) + reading += "Find at [record.target_depth] cm." + else + reading += "Find at [max(1, record.rough_depth - XENOARCH_SCAN_ERROR)] to [record.rough_depth + XENOARCH_SCAN_ERROR] cm." + reading += "Cut [record.dug] cm." + if(scanner.reads_field) + reading += "Field: [record.field]." + to_chat(user, span_notice("[scanner] pins a survey tag to [src] and reads: [jointext(reading, " ")]")) + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/structure/xenoarch_boulder/proc/sample_with(obj/item/excavation/core_sampler/sampler, mob/living/user) + if(sampler.sample_serial) + balloon_alert(user, "sampler is full") + return ITEM_INTERACT_BLOCKING + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + playsound(src, 'sound/effects/rock/rocktap2.ogg', 40, TRUE) + if(!do_after(user, 2 SECONDS, src, interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src) || record.settled || sampler.sample_serial) + return ITEM_INTERACT_BLOCKING + sampler.take_core(record) + playsound(src, 'sound/machines/click.ogg', 40, TRUE) + user.visible_message( + span_notice("[user] drives [sampler] into [src] and draws out a core of rock."), + span_notice("You drive [sampler] into [src] and draw out a core of rock."), + ) + return ITEM_INTERACT_SUCCESS + +/obj/structure/xenoarch_boulder/proc/stabilise_with(obj/item/excavation/stabiliser/stabiliser, mob/living/user) + if(!stabiliser.selected_field) + balloon_alert(user, "no field set") + return ITEM_INTERACT_BLOCKING + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + user.visible_message( + span_notice("[user] sweeps [stabiliser] slowly over [src]."), + span_notice("You sweep [stabiliser] over [src], laying down the [lowertext(stabiliser.selected_field)]."), + blind_message = span_hear("You hear a low electrical hum."), + ) + playsound(src, 'sound/effects/magic/forcewall.ogg', 25, TRUE) + if(!do_after(user, 5 SECONDS, src, interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src) || record.settled) + return ITEM_INTERACT_BLOCKING + // Correct or not, the field looks the same. Only the spectrometer or a field reading can tell. + record.field_applied = stabiliser.selected_field + record.stabilised = (record.field_applied == record.field) + playsound(src, 'sound/machines/clockcult/steam_whoosh.ogg', 25, TRUE) + to_chat(user, span_notice("A faint lattice of light settles over [src].")) + update_appearance() + return ITEM_INTERACT_SUCCESS + +/// Plays a tool's noise, with a chip of rock and a puff of dust for striking tools, at most every `interval`. Always TRUE (a do_after check). +/obj/structure/xenoarch_boulder/proc/work_noise(noise, interval, striking) + if(world.time < next_work_noise) + return TRUE + next_work_noise = world.time + interval + playsound(src, noise, striking ? 45 : 30, TRUE) + if(striking) + dust_puff(small = TRUE) + Shake(1, 0, 0.15 SECONDS) + return TRUE + +// ===== Cutting down ===== + +/// Takes `cm` off the boulder. Past the find, the find is lost. +/obj/structure/xenoarch_boulder/proc/cut(cm, mob/living/user) + var/old_stage = cut_stage() + record.dug += cm + if(record.dug > record.target_depth) + lose_find(user, "cut too deep") + return + dust_puff(small = FALSE) + if(cut_stage() != old_stage) + // A whole slab comes away: the boulder is visibly smaller. + playsound(src, 'sound/effects/rock/rock_break.ogg', 40, TRUE) + visible_message(span_notice("A slab of rock breaks away from [src].")) + new /obj/effect/temp_visual/xenoarch_dust(get_turf(src), color, FALSE) + if(record.sounded && user) + balloon_alert(user, "[record.dug] cm") + update_appearance() + +/// A puff of dust off the cut face, tinted like the rock. +/obj/structure/xenoarch_boulder/proc/dust_puff(small = TRUE) + new /obj/effect/temp_visual/xenoarch_dust(get_turf(src), color, small) + +/// The settle: brushed clean at the right depth. Without the right field it may come apart anyway. +/obj/structure/xenoarch_boulder/proc/free_find(mob/living/user) + var/turf/spot = get_turf(src) + var/damaged = FALSE + if(!record.stabilised) + if(prob(XENOARCH_UNSTABLE_CRUMBLE_CHANCE)) + lose_find(user, "unstabilised") + return null + damaged = TRUE + var/find_type = record.find_type + var/band = record.band + var/serial = record.serial + settle() + playsound(spot, 'sound/effects/break_stone.ogg', 50, TRUE) + playsound(spot, 'sound/effects/rustle/rustle3.ogg', 40, TRUE) + new /obj/effect/temp_visual/xenoarch_dust(spot, color, FALSE) + new /obj/effect/decal/cleanable/rubble(spot) + var/atom/movable/find = xenoarch_spawn_large_find(find_type, spot, band, damaged, serial) + if(find) + // It comes up out of the grit. + find.alpha = 0 + find.pixel_z = -6 + animate(find, alpha = 255, pixel_z = 0, time = 1.2 SECONDS, easing = SINE_EASING | EASE_OUT) + visible_message(span_notice("The last of the rock sloughs away, and [find] stands clear of the dust.")) + if(damaged) + visible_message(span_warning("Cracks run through [find] where the rock let go of it badly.")) + record_metric(METRIC_RESEARCH, XENOARCH_METRIC_DUG, subject = "[find_type]", zone = xenoarch_band_name(band), details = list("size" = "large", "damaged" = damaged), actor = user, location = spot) + xenoarch_dig_ambush(spot, band, user) + qdel(src) + return find + +/** + * The find is gone: cut into, come apart unheld, smashed or blown up. Nothing is made and + * nothing goes off. `reason` is for the round metrics. + */ +/obj/structure/xenoarch_boulder/proc/lose_find(mob/living/user, reason, delete_self = TRUE) + var/turf/spot = get_turf(src) + var/find_type = record.find_type + var/band = record.band + settle() + switch(reason) + if("cut too deep") + visible_message( + span_warning("Something deep inside [src] gives a dull crunch, and the whole boulder splits along a fault. Whatever was in there is gravel now."), + blind_message = span_hear("You hear rock crunch and split."), + ) + if("unstabilised") + visible_message( + span_warning("As the last grit comes away, [src] shudders and slumps to pieces. Nothing was holding it together."), + blind_message = span_hear("You hear rock slump and slide."), + ) + else + visible_message( + span_warning("[src] bursts apart in a spray of grit."), + blind_message = span_hear("You hear rock burst apart."), + ) + playsound(spot, 'sound/effects/break_stone.ogg', 60, TRUE) + playsound(spot, 'sound/effects/rock/rock_break.ogg', 50, TRUE) + new /obj/effect/temp_visual/xenoarch_dust(spot, color, FALSE) + new /obj/effect/decal/cleanable/rubble(spot) + record_metric(METRIC_RESEARCH, XENOARCH_METRIC_LOST, subject = "[find_type]", zone = xenoarch_band_name(band), details = list("size" = "large", "reason" = reason), actor = user, location = spot) + if(delete_self) + qdel(src) + +/// Done with: out of its site's record for good. +/obj/structure/xenoarch_boulder/proc/settle() + if(record.settled) + return + record.settled = TRUE + var/datum/dig_site_record/site = site_ref?.resolve() + site?.settle_boulder(record) + +/obj/structure/xenoarch_boulder/atom_destruction(damage_flag) + if(record && !record.settled) + lose_find(null, damage_flag == BOMB ? "blast" : "smashed", delete_self = FALSE) + return ..() + +/** + * # Dig dust + * + * A short puff of grit tinted like the rock it came off. Small for each strike, big when a slab + * comes away or the boulder lets go. Reuses the mook leap cloud. + */ +/obj/effect/temp_visual/xenoarch_dust + name = "dust" + icon = 'icons/mob/simple/jungle/mook.dmi' + icon_state = "mook_leap_cloud" + layer = ABOVE_MOB_LAYER + plane = GAME_PLANE + pixel_x = -16 + pixel_y = -16 + base_pixel_x = -16 + base_pixel_y = -16 + duration = 1 SECONDS + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/obj/effect/temp_visual/xenoarch_dust/Initialize(mapload, dust_color, small = TRUE) + . = ..() + if(dust_color) + color = dust_color + var/matrix/size = matrix() + if(small) + size.Scale(0.35) + size.Translate(rand(-6, 6), rand(-2, 8)) + alpha = 170 + else + size.Scale(0.8) + transform = size + animate(src, alpha = 0, time = duration, easing = EASE_IN) diff --git a/voidcrew/modules/xenoarch/dig/curios.dm b/voidcrew/modules/xenoarch/dig/curios.dm index 71be66fe2f5..6e8edfb8bdf 100644 --- a/voidcrew/modules/xenoarch/dig/curios.dm +++ b/voidcrew/modules/xenoarch/dig/curios.dm @@ -1 +1,353 @@ -// Small finds: fossils, pottery, statuettes and other curios that come out of strange rocks. +/** + * # Curios and small finds + * + * What comes out of strange rocks: fossils, pottery, statuettes, instruments, scrap, now and then + * tg's strange object, a page of research notes or a single item from a sealed research case. + * Each dig site has a kind (Bay's: garden, animal, house, technical, temple, war), weighted by + * the planet's climate, which decides its small finds by tier. + * + * Every curio and strange object that comes out of the ground is stamped with the band it was + * dug in (origin_band). Buyers read that and `curio_class`; an unstamped one (admin spawned, + * experimentor copies, maintenance relics) is not a find. + * + * Sprites: Polaris (6cd19fe0dec6) icons/obj/xenoarchaeology.dmi small-find states, CC BY-SA 3.0. + */ + +// ===== Site kinds ===== + +/// Climate favourites: planet type -> site kinds that turn up more often there. +GLOBAL_LIST_INIT(xenoarch_site_kind_climates, list( + /datum/planet/beach = list(XENOARCH_SITE_GARDEN, XENOARCH_SITE_HOUSE), + /datum/planet/jungle = list(XENOARCH_SITE_GARDEN, XENOARCH_SITE_ANIMAL), + /datum/planet/snow = list(XENOARCH_SITE_ANIMAL, XENOARCH_SITE_HOUSE), + /datum/planet/wasteland = list(XENOARCH_SITE_TECHNICAL, XENOARCH_SITE_WAR), + /datum/planet/lava = list(XENOARCH_SITE_TEMPLE, XENOARCH_SITE_WAR), +)) + +/// Picks a site kind, favouring the ones the planet's climate suits. +/proc/xenoarch_pick_site_kind(planet_type) + var/list/weights = list() + for(var/kind in list(XENOARCH_SITE_GARDEN, XENOARCH_SITE_ANIMAL, XENOARCH_SITE_HOUSE, XENOARCH_SITE_TECHNICAL, XENOARCH_SITE_TEMPLE, XENOARCH_SITE_WAR)) + weights[kind] = 10 + var/list/favoured = GLOB.xenoarch_site_kind_climates[planet_type] + for(var/kind in favoured) + weights[kind] = 30 + return pick_weight(weights) + +/// A small find's tier, 1 to 3, by band. +/proc/xenoarch_roll_small_tier(band) + var/list/odds + switch(XENOARCH_BAND_INDEX(band)) + if(ZONE_RED) + odds = XENOARCH_SMALL_TIER_ODDS_RED + if(ZONE_YELLOW) + odds = XENOARCH_SMALL_TIER_ODDS_YELLOW + else + odds = XENOARCH_SMALL_TIER_ODDS_GREEN + return max(xenoarch_pick_weighted_index(odds), 1) + +/// Stands in a small find table for "one item from a sealed research case". +/datum/xenoarch_research_draw + +/// What a strange rock from a site of `site_kind` holds at `tier`: a type path. +/proc/xenoarch_small_find(site_kind, tier) + switch(site_kind) + if(XENOARCH_SITE_GARDEN) + switch(tier) + if(1) + return pick(/obj/item/xenoarch_curio/plant_fossil, /obj/item/xenoarch_curio/shell) + if(2) + return /obj/item/xenoarch_curio/seed_pod + else + return /obj/item/research_notes/excavation/tiny + if(XENOARCH_SITE_ANIMAL) + switch(tier) + if(1) + return pick(/obj/item/xenoarch_curio/bone, /obj/item/xenoarch_curio/skull) + if(2) + return /obj/item/xenoarch_curio/skeleton + else + return /obj/item/research_notes/excavation/tiny + if(XENOARCH_SITE_TECHNICAL) + switch(tier) + if(1) + return /obj/item/xenoarch_curio/scrap + if(2) + return /obj/item/relic/excavated + else + return /datum/xenoarch_research_draw + if(XENOARCH_SITE_TEMPLE) + switch(tier) + if(1) + return pick(/obj/item/xenoarch_curio/urn, /obj/item/xenoarch_curio/statuette) + if(2) + return pick(/obj/item/xenoarch_curio/instrument, /obj/item/xenoarch_curio/idol) + else + return /obj/item/relic/excavated + if(XENOARCH_SITE_WAR) + switch(tier) + if(1) + return /obj/item/xenoarch_curio/sidearm + if(2) + return /obj/item/relic/excavated + else + return /obj/item/research_notes/excavation/small + // House, and anything unknown + switch(tier) + if(1) + return pick(/obj/item/xenoarch_curio/bowl, /obj/item/xenoarch_curio/urn) + if(2) + return pick(/obj/item/xenoarch_curio/statuette, /obj/item/xenoarch_curio/instrument) + return /obj/item/relic/excavated + +/// Makes a small find at `destination` and stamps it with `band`. Returns it. +/proc/xenoarch_spawn_small_find(find_type, atom/destination, band) + if(find_type == /datum/xenoarch_research_draw) + return xenoarch_research_draw(destination, band) + if(!ispath(find_type, /atom/movable)) + return null + var/atom/movable/find = new find_type(destination) + xenoarch_stamp_find(find, band) + return find + +/// One item from the research loot theme, at the band's odds, as a sealed case would give it. +/proc/xenoarch_research_draw(atom/destination, band) + var/datum/loot_theme/theme = GLOB.loot_themes[/datum/loot_theme/research] + if(!theme) + return null + var/list/pools = list( + "[LOOT_TIER_COMMON]" = theme.loot_common?.Copy(), + "[LOOT_TIER_UNCOMMON]" = theme.loot_uncommon?.Copy(), + "[LOOT_TIER_PRIME]" = theme.loot_prime?.Copy(), + "[LOOT_TIER_UNIQUE]" = theme.loot_uniques?.Copy(), + ) + var/list/odds + switch(XENOARCH_BAND_INDEX(band)) + if(ZONE_RED) + odds = ZONE_LOOT_ODDS_RED + if(ZONE_YELLOW) + odds = ZONE_LOOT_ODDS_YELLOW + else + odds = ZONE_LOOT_ODDS_GREEN + var/loot_path = draw_loot_from_tier(pools, pick_weight(odds)) + if(!loot_path) + return null + return spawn_loot_prize(destination, loot_path) + +/// What outpost buyers pay for each curio class (design: credits only). +GLOBAL_LIST_INIT(xenoarch_curio_values, list( + XENOARCH_CURIO_SHELL = 40, + XENOARCH_CURIO_FOSSIL = 50, + XENOARCH_CURIO_POTTERY = 80, + XENOARCH_CURIO_STATUETTE = 150, + XENOARCH_CURIO_INSTRUMENT = 150, + XENOARCH_CURIO_STRANGE_OBJECT = 200, +)) + +/// Stamps a curio or strange object with the band it was dug in and marks it for buyers. +/// First stamp wins. Returns TRUE if `find` is a stampable small find. +/proc/xenoarch_stamp_find(atom/movable/find, band) + band = XENOARCH_BAND_INDEX(band) + if(istype(find, /obj/item/xenoarch_curio)) + var/obj/item/xenoarch_curio/curio = find + if(!isnull(curio.origin_band)) + return TRUE + curio.origin_band = band + xenoarch_dig_mark_find(curio, GLOB.xenoarch_curio_values[curio.curio_class]) + return TRUE + if(istype(find, /obj/item/relic/excavated)) + var/obj/item/relic/excavated/relic = find + if(!isnull(relic.origin_band)) + return TRUE + relic.origin_band = band + xenoarch_dig_mark_find(relic, GLOB.xenoarch_curio_values[XENOARCH_CURIO_STRANGE_OBJECT]) + return TRUE + return FALSE + +/** + * Marks a freshly dug small find as one outpost buyers will take, at `value` credits. Buyers refuse + * anything unmarked on purpose (maintenance relics, experimentor copies, admin spawns). The trade + * module's xenoarch_mark_curio() (trade/buyers.dm) does the marking; this is the only call site. + */ +/proc/xenoarch_dig_mark_find(obj/item/find, value) + if(!istype(find) || !value) + return + if(istype(find, /obj/item/xenoarch_curio)) + var/obj/item/xenoarch_curio/curio = find + curio.dig_value = value + else if(istype(find, /obj/item/relic/excavated)) + var/obj/item/relic/excavated/relic = find + relic.dig_value = value + +// ===== Research notes ===== + +/obj/item/research_notes/excavation + origin_type = "xenoarchaeology" + +/obj/item/research_notes/excavation/tiny + value = 100 + +/obj/item/research_notes/excavation/small + value = 250 + +// ===== Strange object ===== + +/// tg's strange object, as dug out of a site. Only these, stamped, count as finds. +/obj/item/relic/excavated + /// ZONE_* it was dug in, or null + var/origin_band + /// Credits an outpost buyer pays for it, once marked as dug + var/dig_value = 0 + +// ===== Curios ===== + +/obj/item/xenoarch_curio + name = "curio" + desc = "Something old, out of the ground." + icon = 'voidcrew/modules/xenoarch/icons/curios.dmi' + icon_state = "bone" + w_class = WEIGHT_CLASS_SMALL + resistance_flags = FIRE_PROOF + /// XENOARCH_CURIO_*: what a buyer files it under + var/curio_class = XENOARCH_CURIO_FOSSIL + /// ZONE_* it was dug in, or null if it never came out of the ground + var/origin_band + /// Credits an outpost buyer pays for it, once marked as dug + var/dig_value = 0 + /// Icon states to pick from + var/list/variants + /// Descriptions to pick from + var/list/descs + +/obj/item/xenoarch_curio/Initialize(mapload) + . = ..() + if(length(variants)) + icon_state = pick(variants) + if(length(descs)) + desc = pick(descs) + pixel_x = base_pixel_x + rand(-6, 6) + pixel_y = base_pixel_y + rand(-4, 4) + +/obj/item/xenoarch_curio/examine(mob/user) + . = ..() + if(!isnull(origin_band)) + . += "Grit from the dig is still packed into it." + +/obj/item/xenoarch_curio/plant_fossil + name = "plant fossil" + curio_class = XENOARCH_CURIO_FOSSIL + variants = list("plant1", "plant2", "plant3", "plant4") + descs = list( + "A flake of stone holding the print of a leaf. Nothing that grows now has leaves like it.", + "A slab of rock with a frond pressed into it, every vein still there.", + ) + +/obj/item/xenoarch_curio/shell + name = "fossil shell" + curio_class = XENOARCH_CURIO_SHELL + variants = list("shell") + descs = list( + "A coiled shell turned to stone, from a sea that dried up long ago.", + "A ribbed shell, stone all the way through.", + ) + +/obj/item/xenoarch_curio/seed_pod + name = "seed pod" + curio_class = XENOARCH_CURIO_STATUETTE + variants = list("green_lump") + descs = list( + "A hard green pod the size of a fist. Something rattles inside when you shake it.", + "A seed case, glassy and green, sealed shut for longer than anyone can guess.", + ) + +/obj/item/xenoarch_curio/bone + name = "fossil bone" + curio_class = XENOARCH_CURIO_FOSSIL + variants = list("bone") + descs = list( + "A long bone gone to stone, still bedded in a little rock.", + "A heavy limb bone from something big. It's all rock now.", + ) + +/obj/item/xenoarch_curio/skull + name = "fossil skull" + curio_class = XENOARCH_CURIO_FOSSIL + variants = list("skull", "hskull") + descs = list( + "A skull set in stone. The eye sockets face the wrong way for anything you know.", + "A horned skull, half freed from its rock.", + ) + +/obj/item/xenoarch_curio/skeleton + name = "fossil skeleton" + curio_class = XENOARCH_CURIO_STATUETTE + w_class = WEIGHT_CLASS_BULKY + variants = list("uskel", "skel") + descs = list( + "A whole skeleton of some long-dead creature, wired upright on a block of its own stone.", + "Every bone of a small animal, still lying where it died.", + ) + +/obj/item/xenoarch_curio/bowl + name = "clay bowl" + curio_class = XENOARCH_CURIO_POTTERY + variants = list("bowl") + descs = list( + "A shallow bowl of fired clay, rim chipped, glaze still bright inside.", + "A hand-thrown bowl. Somebody's thumbprint is baked into the base.", + ) + +/obj/item/xenoarch_curio/urn + name = "urn" + curio_class = XENOARCH_CURIO_POTTERY + variants = list("urn", "urn1", "urn2", "urn3") + descs = list( + "A narrow-necked urn, sealed with something that has gone hard as stone.", + "A painted urn. The pattern repeats all the way round and never quite matches up.", + ) + +/obj/item/xenoarch_curio/statuette + name = "statuette" + curio_class = XENOARCH_CURIO_STATUETTE + variants = list("statuette", "statuette1", "statuette2", "statuette3") + descs = list( + "A small carved figure. Too many joints in the arms.", + "A figure worn smooth by handling, long before it was buried.", + ) + +/obj/item/xenoarch_curio/instrument + name = "strange instrument" + curio_class = XENOARCH_CURIO_INSTRUMENT + variants = list("instrument") + descs = list( + "Bells, reeds or both, in a frame of dark metal. It makes no sound when shaken.", + "Something meant to be played, though not by hands like yours.", + ) + +/obj/item/xenoarch_curio/idol + name = "idol" + curio_class = XENOARCH_CURIO_STATUETTE + variants = list("unknown1") + descs = list( + "A banded column of gold-coloured metal, stacked like a spine.", + "A ritual column of metal rings. It's warm, though it was buried.", + ) + +/obj/item/xenoarch_curio/scrap + name = "corroded mechanism" + curio_class = XENOARCH_CURIO_POTTERY + variants = list("unknown2", "unknown3", "unknown4", "box", "sliver1", "pen1") + descs = list( + "A lump of machinery fused by age. Whatever it did, it stopped doing it a long time ago.", + "A fitting of some kind, its threads clogged with ancient grit.", + ) + +/obj/item/xenoarch_curio/sidearm + name = "spent sidearm" + curio_class = XENOARCH_CURIO_POTTERY + w_class = WEIGHT_CLASS_NORMAL + variants = list("egun1", "egun3", "egun5", "egun9", "gun1", "gun3", "gunold") + descs = list( + "A sidearm from a war nobody remembers. The works inside have rotted solid.", + "A grip and a barrel, more or less. It will never fire again.", + ) diff --git a/voidcrew/modules/xenoarch/dig/designs.dm b/voidcrew/modules/xenoarch/dig/designs.dm index 8c33114a8bc..c5cf8defb64 100644 --- a/voidcrew/modules/xenoarch/dig/designs.dm +++ b/voidcrew/modules/xenoarch/dig/designs.dm @@ -1 +1,135 @@ -// Printer designs for the excavation kit and dig tools. +/** + * # Excavation designs + * + * The hand kit prints on any autolathe from round start (like the R&D kit). The stabiliser and the + * spectrometer board belong to the "Xenoarchaeology" research node; the advanced depth scanner and + * quick brush to "Advanced Excavation". Those nodes list these ids: + * + * - Xenoarchaeology: "xenoarch_stabiliser", "xenoarch_spectrometer" + * - Advanced Excavation: "xenoarch_depth_scanner_adv", "xenoarch_brush_quick" + */ + +// ===== Autolathe, from round start ===== + +/datum/design/xenoarch_kit + name = "Excavation Kit" + desc = "Webbing with a rock hammer, brush, depth scanner, core sampler and a find satchel." + id = "xenoarch_kit" + build_type = AUTOLATHE + materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT * 3, /datum/material/glass = SHEET_MATERIAL_AMOUNT, /datum/material/plastic = SHEET_MATERIAL_AMOUNT) + build_path = /obj/item/storage/belt/excavation/full + category = list( + RND_CATEGORY_INITIAL, + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/xenoarch_hammer + name = "Rock Hammer" + id = "xenoarch_hammer" + build_type = AUTOLATHE + materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT) + build_path = /obj/item/excavation/hammer + category = list( + RND_CATEGORY_INITIAL, + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/xenoarch_brush + name = "Excavation Brush" + id = "xenoarch_brush" + build_type = AUTOLATHE + materials = list(/datum/material/plastic = SMALL_MATERIAL_AMOUNT * 2, /datum/material/iron = SMALL_MATERIAL_AMOUNT) + build_path = /obj/item/excavation/brush + category = list( + RND_CATEGORY_INITIAL, + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/xenoarch_depth_scanner + name = "Depth Scanner" + id = "xenoarch_depth_scanner" + build_type = AUTOLATHE + materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 3, /datum/material/glass = SMALL_MATERIAL_AMOUNT * 3) + build_path = /obj/item/excavation/depth_scanner + category = list( + RND_CATEGORY_INITIAL, + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/xenoarch_core_sampler + name = "Core Sampler" + id = "xenoarch_core_sampler" + build_type = AUTOLATHE + materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 4) + build_path = /obj/item/excavation/core_sampler + category = list( + RND_CATEGORY_INITIAL, + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/xenoarch_find_bag + name = "Find Satchel" + id = "xenoarch_find_bag" + build_type = AUTOLATHE + materials = list(/datum/material/plastic = SHEET_MATERIAL_AMOUNT) + build_path = /obj/item/storage/bag/excavation + category = list( + RND_CATEGORY_INITIAL, + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +// ===== Research: Xenoarchaeology ===== + +/datum/design/xenoarch_stabiliser + name = "Field Stabiliser" + desc = "A handheld projector that holds crumbling rock together while a find is freed." + id = "xenoarch_stabiliser" + build_type = PROTOLATHE | AWAY_LATHE + materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT, /datum/material/glass = HALF_SHEET_MATERIAL_AMOUNT, /datum/material/silver = SMALL_MATERIAL_AMOUNT * 3) + build_path = /obj/item/excavation/stabiliser + category = list( + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/board/xenoarch_spectrometer + name = "Radiocarbon Spectrometer Board" + desc = "The circuit board for a radiocarbon spectrometer." + id = "xenoarch_spectrometer" + build_path = /obj/item/circuitboard/machine/xenoarch_spectrometer + category = list( + RND_CATEGORY_MACHINE + RND_SUBCATEGORY_MACHINE_RESEARCH, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE + +// ===== Research: Advanced Excavation ===== + +/datum/design/xenoarch_depth_scanner_adv + name = "Advanced Depth Scanner" + desc = "Reads a find's exact depth and the field its rock needs, on site." + id = "xenoarch_depth_scanner_adv" + build_type = PROTOLATHE | AWAY_LATHE + materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 3, /datum/material/glass = SMALL_MATERIAL_AMOUNT * 3, /datum/material/gold = SMALL_MATERIAL_AMOUNT * 2, /datum/material/uranium = SMALL_MATERIAL_AMOUNT * 2) + build_path = /obj/item/excavation/depth_scanner/advanced + category = list( + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO + +/datum/design/xenoarch_brush_quick + name = "Quick Brush" + desc = "An excavation brush with a vibrating head." + id = "xenoarch_brush_quick" + build_type = PROTOLATHE | AWAY_LATHE + materials = list(/datum/material/plastic = SMALL_MATERIAL_AMOUNT * 2, /datum/material/iron = SMALL_MATERIAL_AMOUNT * 2, /datum/material/gold = SMALL_MATERIAL_AMOUNT * 2) + build_path = /obj/item/excavation/brush/quick + category = list( + RND_CATEGORY_TOOLS + RND_SUBCATEGORY_TOOLS_MINING, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO diff --git a/voidcrew/modules/xenoarch/dig/dig_reserve.dm b/voidcrew/modules/xenoarch/dig/dig_reserve.dm index 7d284f8333e..5afea9cf217 100644 --- a/voidcrew/modules/xenoarch/dig/dig_reserve.dm +++ b/voidcrew/modules/xenoarch/dig/dig_reserve.dm @@ -7,6 +7,9 @@ * * 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. + * + * A boulder already cut out of the rock keeps its whole state here (depth, field, scan, tag) until + * it is opened or lost, so a crew that flies off for a stabiliser finds it as they left it. */ /datum/planet_dig_reserve /// ZONE_* of the planet when the reserve was made; finds are stamped with it @@ -24,17 +27,139 @@ QDEL_LIST(sites) return ..() +/// Makes one record per site, the first time the planet is built. `planet_type` picks the site kinds. +/datum/planet_dig_reserve/proc/seed(planet_type) + if(seeded) + return FALSE + seeded = TRUE + var/list/large = XENOARCH_LARGE_PER_SITE + var/list/small = XENOARCH_SMALL_PER_SITE + var/index = XENOARCH_BAND_INDEX(band) + for(var/i in 1 to XENOARCH_SITES_PER_PLANET) + var/datum/dig_site_record/site = new(band, xenoarch_pick_site_kind(planet_type)) + site.large_left = large[index] + site.small_left = small[index] + sites += site + return TRUE + +/// Sites with anything left in them, in placement order. +/datum/planet_dig_reserve/proc/sites_with_finds() + . = list() + for(var/datum/dig_site_record/site as anything in sites) + if(site.finds_left() > 0) + . += site + +/// Every find still to come out of the planet, boulders already cut loose included. +/datum/planet_dig_reserve/proc/finds_left() + . = 0 + for(var/datum/dig_site_record/site as anything in sites) + . += site.finds_left() + /// 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 + /// Which kind of site it is (XENOARCH_SITE_*) + var/site_kind = XENOARCH_SITE_HOUSE + /// Where the site sat on the last build, from the planet footprint's low corner, so a rebuild + /// tries the same spot first. Terrain is regenerated on a rebuild, so it may not fit again. var/x var/y + /// ZONE_* every find from here is stamped with + var/band = ZONE_GREEN + /// Boulders cut out of the rock but not yet opened, kept so a rebuild puts them back as they were + var/list/datum/dig_boulder_record/loose = list() + +/datum/dig_site_record/New(band, site_kind) + . = ..() + src.band = XENOARCH_BAND_INDEX(band) + if(site_kind) + src.site_kind = site_kind + +/datum/dig_site_record/Destroy(force) + QDEL_LIST(loose) + return ..() + +/// Finds still to come out of this site. +/datum/dig_site_record/proc/finds_left() + return large_left + small_left + length(loose) + +/// Takes one find of `kind` (XENOARCH_FIND_*) out of the count. FALSE if none were left. +/datum/dig_site_record/proc/take_find(kind) + switch(kind) + if(XENOARCH_FIND_LARGE) + if(large_left <= 0) + return FALSE + large_left-- + return TRUE + if(XENOARCH_FIND_SMALL) + if(small_left <= 0) + return FALSE + small_left-- + return TRUE + return FALSE + +/// A boulder was cut out of the face: rolls what it holds and keeps it until it is opened or lost. +/datum/dig_site_record/proc/cut_boulder() + var/datum/dig_boulder_record/boulder = new(band) + boulder.site_kind = site_kind + loose += boulder + return boulder + +/// A loose boulder was opened or lost. It is gone from the site for good. +/datum/dig_site_record/proc/settle_boulder(datum/dig_boulder_record/boulder) + loose -= boulder + +/** + * Everything about one boulder: what is inside, how deep, which field holds it, and how far the + * crew has got. The boulder structure only draws it. + */ +/datum/dig_boulder_record + /// ZONE_* the find is stamped with + var/band = ZONE_GREEN + /// The catalogue number on its holotag, which an artifact keeps when it comes out + var/serial + /// What is inside (a type path), rolled by band + var/find_type + /// Depth of the find's surface, in cm + var/target_depth = XENOARCH_BOULDER_DEPTH_MIN + /// What the plain depth scanner reads: the true depth give or take XENOARCH_SCAN_ERROR + var/rough_depth = XENOARCH_BOULDER_DEPTH_MIN + /// The one stabilising field that holds it (one of GLOB.xenoarch_stabilising_fields) + var/field + /// Radiometric age, in years, for the spectrometer report + var/age = 0 + /// cm cut so far + var/dug = 0 + /// 0 not sounded, 1 sounded roughly, 2 sounded exactly + var/sounded = 0 + /// The field the crew projected on it, if any + var/field_applied + /// The projected field matches + var/stabilised = FALSE + /// A holotag (GPS) has been fixed to it + var/tagged = FALSE + /// Sprite variant, 1 to 4 + var/variant = 1 + /// Colour of the rock it was cut from + var/rock_color + /// The kind of site it came from, for the spectrometer report + var/site_kind + /// Opened or lost: nothing more to do with it + var/settled = FALSE + +/datum/dig_boulder_record/New(band) + . = ..() + src.band = XENOARCH_BAND_INDEX(band) + serial = xenoarch_new_serial() + find_type = xenoarch_roll_large_find(src.band) + target_depth = rand(XENOARCH_BOULDER_DEPTH_MIN, XENOARCH_BOULDER_DEPTH_MAX) + rough_depth = max(1, target_depth + rand(-XENOARCH_SCAN_ERROR, XENOARCH_SCAN_ERROR)) + field = pick(GLOB.xenoarch_stabilising_fields) + age = rand(4, 900) * 1000000 + variant = rand(1, 4) /obj/structure/overmap/planet /// What this planet's dig sites still hold; made on first use diff --git a/voidcrew/modules/xenoarch/dig/dig_sites.dm b/voidcrew/modules/xenoarch/dig/dig_sites.dm index bfa4033bfe5..1b9ee4b94f0 100644 --- a/voidcrew/modules/xenoarch/dig/dig_sites.dm +++ b/voidcrew/modules/xenoarch/dig/dig_sites.dm @@ -1 +1,365 @@ -// Dig sites: the planet generator pass that places site walls, stakes and spoil heaps. +/** + * # Dig sites + * + * A planet-generator pass that runs after the anomalies, once per surface build. Each site is a + * short run of mountain wall facing open ground: the walls get a vein overlay and one find each, + * and two or three survey stakes and a spoil heap go on the ground in front, so a site reads from + * across the map. Copies place_budgeted_anomalies(): random draws, then an exhaustive scan, + * keeping off rivers, the landing strip and ruins, and spacing sites XENOARCH_SITE_SPACING apart. + * + * What a site holds comes from the planet's /datum/planet_dig_reserve, never from the build, so a + * planet rebuilt after everyone left has only what was not yet dug. A planet whose overmap object + * cannot be found gets no sites at all, since nothing could keep count of them. + * + * Hooked by chaining populate_terrain() from this file (included after PlanetGenerator.dm): the + * original runs first, then the dig pass. The cave area's own population call is skipped; its + * walls are reached from the surface ground that faces them. + */ + +/// Random tries for a site's spot before the exhaustive scan. +#define DIG_SITE_RANDOM_ATTEMPTS 250 +/// Tiles of plain planet a site keeps between itself and ruins or the landing strip. +#define DIG_SITE_CLEARANCE 4 +/// How far from its anchor tile a site's walls may run. +#define DIG_SITE_REACH 3 + +/// Wall to build an outcrop from on a planet with no usable cliff, by planet type. +GLOBAL_LIST_INIT(xenoarch_outcrop_walls, list( + /datum/planet/beach = /turf/closed/mineral/random/beach, + /datum/planet/jungle = /turf/closed/mineral/random/jungle, + /datum/planet/lava = /turf/closed/mineral/random/volcanic/voidcrew, + /datum/planet/snow = /turf/closed/mineral/random/snow, + /datum/planet/wasteland = /turf/closed/mineral/random/high_chance/wasteland, +)) + +/datum/map_generator/planet_generator/populate_terrain(list/turfs, area/generate_in, zone_band) + . = ..() + if(istype(generate_in, /area/overmap_encounter/planetoid/cave)) + return + place_dig_sites(turfs, generate_in) + +/** + * Places every site that still holds something. Returns how many were placed. + */ +/datum/map_generator/planet_generator/proc/place_dig_sites(list/turfs, area/overmap_encounter/planetoid/surface) + if(!length(turfs) || !istype(surface)) + return 0 + var/obj/structure/overmap/planet/planet = SSovermap_zones?.get_overmap_object_for_turf(turfs[1]) + if(!istype(planet)) + return 0 + var/datum/planet_dig_reserve/reserve = planet.get_dig_reserve() + reserve.seed(surface.planet_type) + var/list/to_place = reserve.sites_with_finds() + if(!length(to_place)) + return 0 + + var/datum/map_footprint/footprint = planet.footprint + var/list/placed_at = list() + var/placed = 0 + for(var/datum/dig_site_record/site as anything in to_place) + var/list/spot = find_dig_site_spot(turfs, site, placed_at, footprint) + if(!spot) + spot = build_dig_outcrop(turfs, site, placed_at, surface.planet_type) + if(!spot) + continue + var/turf/anchor = spot[1] + build_dig_site(site, anchor, spot[2]) + site.x = anchor.x - (footprint ? footprint.low_x : 0) + site.y = anchor.y - (footprint ? footprint.low_y : 0) + placed_at += anchor + placed++ + if(placed < length(to_place)) + log_world("[name] dig site shortfall: [placed]/[length(to_place)] sites placed.") + return placed + +/** + * Finds an anchor (the open tile a site is worked from) and its walls: the site's last spot if it + * still fits, then random draws, then every tile in turn. Returns list(anchor, walls) or null. + */ +/datum/map_generator/planet_generator/proc/find_dig_site_spot(list/turfs, datum/dig_site_record/site, list/placed_at, datum/map_footprint/footprint) + var/needed = max(site.large_left + site.small_left, 1) + if(!isnull(site.x) && !isnull(site.y) && length(turfs)) + var/turf/first = turfs[1] + var/turf/last_spot = locate((footprint ? footprint.low_x : 0) + site.x, (footprint ? footprint.low_y : 0) + site.y, first.z) + var/list/walls = dig_site_walls_at(last_spot, needed, placed_at) + if(walls) + return list(last_spot, walls) + var/random_attempts = length(turfs) ? DIG_SITE_RANDOM_ATTEMPTS : 0 + for(var/attempt in 1 to random_attempts + length(turfs)) + SSovermap.worldgen_yield(throttled) + var/turf/candidate = attempt <= random_attempts ? pick(turfs) : turfs[attempt - random_attempts] + var/list/walls = dig_site_walls_at(candidate, needed, placed_at) + if(walls) + return list(candidate, walls) + return null + +/// The walls a site anchored on `anchor` would use, or null if the spot is no good. +/datum/map_generator/planet_generator/proc/dig_site_walls_at(turf/anchor, needed, list/placed_at) + if(!dig_site_ground_ok(anchor)) + return null + for(var/turf/taken as anything in placed_at) + if(taken.z == anchor.z && get_dist(anchor, taken) < XENOARCH_SITE_SPACING) + return null + var/list/walls = collect_dig_site_walls(anchor, needed) + if(!walls || !dig_site_clear_of_ruins(anchor)) + return null + return walls + +/// Open ground the generator laid down: not water, lava, a river, a ruin or the landing strip. +/proc/dig_site_ground_ok(turf/ground) + if(!isopenturf(ground) || isgroundlessturf(ground) || ground.planet_river) + return FALSE + if(istype(ground, /turf/open/water) || istype(ground, /turf/open/lava)) + return FALSE + if(ground.turf_flags & NO_RUINS) + return FALSE + var/datum/biome/biome = ground.generating_biome + if(!biome || !(ground.type in biome.open_turf_types)) + return FALSE + if(ground.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + return TRUE + +/// Plain mountain rock that faces open ground and isn't already part of a site or a special vein. +/proc/dig_site_wall_ok(turf/closed/mineral/wall) + if(!istype(wall) || wall.dig_find) + return FALSE + if(istype(wall, /turf/closed/mineral/gibtonite) || istype(wall, /turf/closed/mineral/strong)) + return FALSE + if(wall.spawned_boulder || (wall.mineralType && !is_ordinary_ore(wall.mineralType))) + return FALSE + if(wall.turf_flags & NO_RUINS) + return FALSE + if(!istype(wall.loc, /area/overmap_encounter/planetoid)) + return FALSE + for(var/direction in GLOB.cardinals) + var/turf/side = get_step(wall, direction) + if(isopenturf(side) && !isgroundlessturf(side) && !side.planet_river && !istype(side, /turf/open/lava)) + return TRUE + return FALSE + +/// A connected run of usable face walls within DIG_SITE_REACH of the anchor, or null if too short. +/proc/collect_dig_site_walls(turf/anchor, needed) + var/list/queue = list() + for(var/direction in GLOB.cardinals) + var/turf/closed/mineral/wall = get_step(anchor, direction) + if(dig_site_wall_ok(wall)) + queue += wall + var/list/walls = list() + var/list/seen = list() + while(length(queue) && length(walls) < needed) + var/turf/closed/mineral/wall = queue[1] + queue.Cut(1, 2) + if(seen[wall]) + continue + seen[wall] = TRUE + walls += wall + for(var/direction in GLOB.alldirs) + var/turf/closed/mineral/next = get_step(wall, direction) + if(!next || seen[next] || get_dist(next, anchor) > DIG_SITE_REACH) + continue + if(dig_site_wall_ok(next)) + queue += next + return length(walls) >= needed ? walls : null + +/// Nothing within DIG_SITE_CLEARANCE belongs to a ruin, the landing strip or the world border. +/proc/dig_site_clear_of_ruins(turf/anchor) + for(var/turf/near as anything in RANGE_TURFS(DIG_SITE_CLEARANCE, anchor)) + if(near.turf_flags & NO_RUINS) + return FALSE + if(!istype(near.loc, /area/overmap_encounter/planetoid)) + return FALSE + return TRUE + +/** + * A planet with no cliff to dig into (a thin beach) gets a short outcrop of its own rock, raised + * on open ground. Returns list(anchor, walls) or null. + */ +/datum/map_generator/planet_generator/proc/build_dig_outcrop(list/turfs, datum/dig_site_record/site, list/placed_at, planet_type) + var/needed = max(site.large_left + site.small_left, 1) + var/wall_type = GLOB.xenoarch_outcrop_walls[planet_type] || /turf/closed/mineral/random + var/width = needed + 2 + for(var/attempt in 1 to DIG_SITE_RANDOM_ATTEMPTS) + SSovermap.worldgen_yield(throttled) + var/turf/anchor = pick(turfs) + if(!dig_site_ground_ok(anchor)) + continue + var/too_close = FALSE + for(var/turf/taken as anything in placed_at) + if(taken.z == anchor.z && get_dist(anchor, taken) < XENOARCH_SITE_SPACING) + too_close = TRUE + break + if(too_close || !dig_site_clear_of_ruins(anchor)) + continue + // A row of rock just north of the anchor, centred on it. + var/list/row = list() + var/start_x = anchor.x - round(width / 2) + for(var/offset in 0 to width - 1) + var/turf/tile = locate(start_x + offset, anchor.y + 1, anchor.z) + if(!dig_site_ground_ok(tile)) + row = null + break + row += tile + if(!row) + continue + for(var/turf/tile as anything in row) + var/turf/raised = tile.ChangeTurf(wall_type, flags = CHANGETURF_IGNORE_AIR) + raised.assemble_baseturfs(initial(raised.baseturfs) || raised.type) + // Rarely a raised wall rolls a special vein and the run comes up short. One outcrop is + // enough rock to leave on a planet: the site waits for the next build rather than another. + var/list/walls = collect_dig_site_walls(anchor, needed) + return walls ? list(anchor, walls) : null + return null + +/** + * Flags the walls, puts back any boulders already cut loose, and dresses the ground in front: + * stakes at the ends of the face and a spoil heap a step back. + */ +/datum/map_generator/planet_generator/proc/build_dig_site(datum/dig_site_record/site, turf/anchor, list/walls) + var/list/kinds = list() + for(var/i in 1 to site.large_left) + kinds += XENOARCH_FIND_LARGE + for(var/i in 1 to site.small_left) + kinds += XENOARCH_FIND_SMALL + shuffle_inplace(kinds) + for(var/i in 1 to min(length(kinds), length(walls))) + var/turf/closed/mineral/wall = walls[i] + wall.make_dig_site_wall(site, kinds[i]) + return dress_dig_site(site, anchor, walls) + +/// Places loose boulders, stakes and the spoil heap around a site. Returns the decorations placed. +/proc/dress_dig_site(datum/dig_site_record/site, turf/anchor, list/walls) + // Ground right against the face, where the work happens, and a ring a step back for the dressing. + var/list/front = list() + var/list/back = list() + for(var/turf/closed/mineral/wall as anything in walls) + for(var/direction in GLOB.cardinals) + var/turf/side = get_step(wall, direction) + if(dig_site_dressing_ok(side)) + front |= side + for(var/turf/near as anything in RANGE_TURFS(2, anchor)) + if(!(near in front) && dig_site_dressing_ok(near)) + back += near + var/rock_color = "#8f8a83" + var/turf/closed/mineral/first_wall = walls[1] + if(istype(first_wall)) + rock_color = first_wall.dig_rock_color() + + . = list() + for(var/datum/dig_boulder_record/boulder as anything in site.loose) + var/turf/spot = length(front) ? pick_n_take(front) : (length(back) ? pick_n_take(back) : null) + if(!spot) + break + if(!boulder.rock_color) + boulder.rock_color = rock_color + . += new /obj/structure/xenoarch_boulder(spot, boulder, site) + + // Stakes go on the two back tiles furthest apart, so the face is bracketed by them. + if(length(back) >= 2) + var/turf/stake_a + var/turf/stake_b + var/best = -1 + for(var/turf/one as anything in back) + for(var/turf/two as anything in back) + var/distance = get_dist(one, two) + if(distance > best) + best = distance + stake_a = one + stake_b = two + back -= stake_a + back -= stake_b + var/tape = pick("orange", "red") + . += new /obj/structure/xenoarch_stake(stake_a, tape) + . += new /obj/structure/xenoarch_stake(stake_b, tape) + if(length(back) && prob(50)) + . += new /obj/structure/xenoarch_stake(pick_n_take(back), tape) + if(length(back)) + . += new /obj/structure/xenoarch_spoil_heap(pick_n_take(back)) + +/// A tile the dressing can stand on: open, walkable, unclaimed ground. Plants on it are cleared. +/proc/dig_site_dressing_ok(turf/tile) + if(!isopenturf(tile) || isgroundlessturf(tile) || tile.planet_river) + return FALSE + if(istype(tile, /turf/open/water) || istype(tile, /turf/open/lava)) + return FALSE + if(locate(/obj/structure/xenoarch_boulder) in tile) + return FALSE + if(locate(/obj/structure/xenoarch_stake) in tile) + return FALSE + if(locate(/obj/structure/xenoarch_spoil_heap) in tile) + return FALSE + for(var/obj/structure/flora/plant in tile) + qdel(plant) + return !tile.is_blocked_turf(exclude_mobs = TRUE) + +/** + * # Survey stake + * + * A wooden stake with flagging tape, left by whoever marked the site out. Pull it up by hand. + */ +/obj/structure/xenoarch_stake + name = "survey stake" + desc = "A wooden stake hammered into the ground, with a strip of flagging tape knotted near the top." + icon = 'voidcrew/modules/xenoarch/icons/dig_site.dmi' + icon_state = "stake_orange" + anchored = TRUE + density = FALSE + layer = BELOW_MOB_LAYER + max_integrity = 30 + resistance_flags = FLAMMABLE + +/obj/structure/xenoarch_stake/Initialize(mapload, tape_color) + . = ..() + if(tape_color) + icon_state = "stake_[tape_color]" + pixel_x = base_pixel_x + rand(-5, 5) + pixel_y = base_pixel_y + rand(-3, 4) + +/obj/structure/xenoarch_stake/attack_hand(mob/living/user, list/modifiers) + . = ..() + if(.) + return + user.visible_message( + span_notice("[user] works [src] loose from the ground."), + span_notice("You start working [src] loose."), + ) + if(!do_after(user, 2 SECONDS, target = src)) + return TRUE + playsound(src, 'sound/effects/shovel_dig.ogg', 30, TRUE) + user.visible_message(span_notice("[user] pulls up [src] and tosses it aside."), span_notice("You pull up [src].")) + qdel(src) + return TRUE + +/** + * # Spoil heap + * + * Earth and broken rock shovelled out of the dig. Scenery; a shovel clears it. + */ +/obj/structure/xenoarch_spoil_heap + name = "spoil heap" + desc = "A heap of earth and broken rock shovelled out of a dig." + icon = 'voidcrew/modules/xenoarch/icons/dig_site.dmi' + icon_state = "spoil1" + anchored = TRUE + density = FALSE + layer = BELOW_OBJ_LAYER + max_integrity = 60 + +/obj/structure/xenoarch_spoil_heap/Initialize(mapload) + . = ..() + icon_state = "spoil[rand(1, 2)]" + pixel_x = base_pixel_x + rand(-4, 4) + pixel_y = base_pixel_y + rand(-2, 3) + +/obj/structure/xenoarch_spoil_heap/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(tool.tool_behaviour != TOOL_SHOVEL) + return NONE + if(!tool.use_tool(src, user, 3 SECONDS, volume = 50)) + return ITEM_INTERACT_BLOCKING + user.visible_message(span_notice("[user] spreads [src] flat."), span_notice("You spread [src] flat.")) + qdel(src) + return ITEM_INTERACT_SUCCESS + +#undef DIG_SITE_RANDOM_ATTEMPTS +#undef DIG_SITE_CLEARANCE +#undef DIG_SITE_REACH diff --git a/voidcrew/modules/xenoarch/dig/finds.dm b/voidcrew/modules/xenoarch/dig/finds.dm index bd3223cbfcd..11e5052b624 100644 --- a/voidcrew/modules/xenoarch/dig/finds.dm +++ b/voidcrew/modules/xenoarch/dig/finds.dm @@ -1,9 +1,352 @@ -// Large finds other than artifacts: bluespace formations, charged crystals, sealed cases and -// exosuit wrecks. +/** + * # Large finds + * + * What a boulder holds, and the finds other than alien artifacts: the bluespace formation, the + * charged crystal, a sealed research case and, in red sites only, a wrecked combat exosuit. + * + * Shares (design): artifact 88%, bluespace formation 6%, charged crystal 4%, sealed case 2%. + * In red the exosuit wreck takes XENOARCH_FIND_SHARE_EXOSUIT_RED out of the artifact share. + */ -/// A crystal that holds a finite charge once wired in. Never saved with a ship (it would refill). +/// Rolls what a boulder in a site of this band holds. Returns a type path. +/proc/xenoarch_roll_large_find(band) + band = XENOARCH_BAND_INDEX(band) + var/roll = rand(1, 100) + var/threshold = XENOARCH_FIND_SHARE_FORMATION + if(roll <= threshold) + return /obj/machinery/artifact/bluespace_formation + threshold += XENOARCH_FIND_SHARE_CRYSTAL + if(roll <= threshold) + return /obj/machinery/power/charged_crystal + threshold += XENOARCH_FIND_SHARE_CASE + if(roll <= threshold) + return /obj/structure/closet/crate/zone_loot/research + if(band == ZONE_RED) + threshold += XENOARCH_FIND_SHARE_EXOSUIT_RED + if(roll <= threshold) + return pick_weight(GLOB.xenoarch_exosuit_wrecks) + return /obj/machinery/artifact + +/// Combat exosuits that turn up wrecked in red dig sites: the two standard frames. +GLOBAL_LIST_INIT(xenoarch_exosuit_wrecks, list( + /obj/vehicle/sealed/mecha/gygax = 3, + /obj/vehicle/sealed/mecha/durand = 2, +)) + +/** + * Makes a freed large find of `find_type` at `spot`, stamped with `band`. `damaged` is a find that + * came out without the right field. An artifact keeps the boulder's catalogue number. + */ +/proc/xenoarch_spawn_large_find(find_type, turf/spot, band, damaged = FALSE, serial) + band = XENOARCH_BAND_INDEX(band) + if(ispath(find_type, /obj/machinery/artifact)) + var/obj/machinery/artifact/artifact = new find_type(spot, band) + if(serial) + artifact.serial = serial + if(damaged) + artifact.damaged = TRUE + return artifact + if(ispath(find_type, /obj/machinery/power/charged_crystal)) + var/obj/machinery/power/charged_crystal/crystal = new find_type(spot, band) + if(damaged) + crystal.crack() + return crystal + if(ispath(find_type, /obj/structure/closet/crate/zone_loot)) + var/obj/structure/closet/crate/zone_loot/case = new find_type(spot) + // Tiered by where it was dug, wherever it is opened. + case.loot_zone = band + return case + if(ispath(find_type, /obj/vehicle/sealed/mecha)) + return xenoarch_wreck_exosuit(find_type, spot, damaged) + if(ispath(find_type, /atom/movable)) + return new find_type(spot) + return null + +/// The band a find was dug in, or null if it never came out of a dig site. +/proc/xenoarch_find_band(atom/find) + if(istype(find, /obj/machinery/artifact)) + var/obj/machinery/artifact/artifact = find + return artifact.origin_band + if(istype(find, /obj/item/xenoarch_curio)) + var/obj/item/xenoarch_curio/curio = find + return curio.origin_band + if(istype(find, /obj/item/relic/excavated)) + var/obj/item/relic/excavated/relic = find + return relic.origin_band + if(istype(find, /obj/machinery/power/charged_crystal)) + var/obj/machinery/power/charged_crystal/crystal = find + return crystal.origin_band + return null + +// ===== Exosuit wreck ===== + +/** + * A combat exosuit that has been in the rock a long time: a third of its armour, a nearly flat + * cell, no weapons, and caked in dust until someone washes it. Crews repair and arm it themselves. + */ +/proc/xenoarch_wreck_exosuit(mech_type, turf/spot, damaged = FALSE) + var/obj/vehicle/sealed/mecha/mech = new mech_type(spot) + mech.update_integrity(round(mech.max_integrity * (damaged ? 0.2 : 0.35))) + if(mech.cell) + mech.cell.charge = round(mech.cell.maxcharge * 0.15) + mech.add_atom_colour("#b8a88e", WASHABLE_COLOUR_PRIORITY) + return mech + +// ===== Bluespace formation ===== + +/** + * # Bluespace formation + * + * A growth of bluespace crystal on dark stone. Not a true artifact: it has no effects to study. + * Each hard knock throws people near it a short way and lets off a small EMP; broken, it leaves + * a few bluespace crystals. Rewrite of NovaSector (5ce2bd2215c0) artifact_bluespace_crystal.dm + * without the anomaly it spawned on destruction. + */ +/obj/machinery/artifact/bluespace_formation + rolls_effects = FALSE + shape = "shard" + max_integrity = 240 + /// Next world.time it can lash out again + var/next_lash = 0 + +/obj/machinery/artifact/bluespace_formation/Initialize(mapload, band) + . = ..() + name = "bluespace formation" + desc = pick( + "Pale blue crystal growing out of dark stone. Looking at it too long makes the edges of things swim.", + "A cluster of blue crystal on a black rock. The light inside it doesn't come from anywhere.", + ) + add_filter("formation_glow", 2, drop_shadow_filter(0, 0, 1.5, 0, "#4fb3ff70")) + add_shared_particles(/particles/artifact/bluespace) + set_light(2, 0.6, "#4fb3ff") + +/obj/machinery/artifact/bluespace_formation/Destroy() + remove_shared_particles(/particles/artifact/bluespace) + remove_filter("formation_glow") + return ..() + +/obj/machinery/artifact/bluespace_formation/take_damage(damage_amount, damage_type = BRUTE, damage_flag = "", sound_effect = TRUE, attack_dir, armour_penetration = 0) + . = ..() + if(. > 0 && !QDELETED(src) && world.time >= next_lash) + lash_out() + +/// Throws everyone close by a few tiles and lets off a small EMP. +/obj/machinery/artifact/bluespace_formation/proc/lash_out() + next_lash = world.time + 3 SECONDS + var/turf/here = get_turf(src) + if(!here) + return + visible_message(span_warning("[src] flares, and the space around it folds.")) + playsound(here, 'sound/effects/magic/teleport_diss.ogg', 50, TRUE) + do_sparks(3, FALSE, src) + empulse(here, 0, 2) + var/mob/culprit = last_disturbed_by?.resolve() + for(var/mob/living/victim in range(3, here)) + if(artifact_exposure(victim) <= 0) + continue + if(victim.buckled) + victim.buckled.unbuckle_mob(victim) + if(do_teleport(victim, get_turf(victim), 4, asoundin = 'sound/effects/phasein.ogg', channel = TELEPORT_CHANNEL_BLUESPACE)) + if(culprit && culprit != victim) + log_combat(culprit, victim, "displaced with a bluespace formation", src) + +/obj/machinery/artifact/bluespace_formation/atom_destruction(damage_flag) + var/turf/here = get_turf(src) + if(here) + new /obj/item/stack/ore/bluespace_crystal(here, rand(2, 4)) + return ..() + +// ===== Charged crystal ===== + +/** + * # Charged crystal + * + * A crystal holding a finite charge. Bolted onto a cable knot and wrapped in wire, it puts about + * one PACMAN's worth on the net (XENOARCH_CRYSTAL_POWER) until it has given XENOARCH_CRYSTAL_LIFETIME + * of it, then goes dull for good. Cut the wire to save what is left. Rewrite of NovaSector + * (5ce2bd2215c0) artifact_power_crystal.dm, which gave free power forever. + * + * Never saved with a ship (GLOB.outpost_checkpoint_excluded): a rebuilt one would be full again. + * Art: the core crystal sprite (Nova artifacts.dmi), recoloured amber, with a drawn wire wrap. + */ /obj/machinery/power/charged_crystal name = "charged crystal" - desc = "A heavy crystal humming with stored charge." + desc = "A heavy amber crystal. The air near it smells like a storm coming." + icon = 'voidcrew/modules/xenoarch/icons/dig_finds.dmi' + icon_state = "charged_crystal" density = TRUE anchored = FALSE + use_power = NO_POWER_USE + processing_flags = START_PROCESSING_MANUALLY + max_integrity = 200 + resistance_flags = FIRE_PROOF | ACID_PROOF + /// ZONE_* it was dug in, or null + var/origin_band + /// Wrapped in cable, ready to feed a powernet + var/wired = FALSE + /// Deciseconds of output left before it goes dull + var/charge_left = XENOARCH_CRYSTAL_LIFETIME + /// Came out of the rock badly: half the charge, and a crack to show for it + var/cracked = FALSE + /// Putting power on the net right now + var/running = FALSE + /// The hum while it runs + var/datum/looping_sound/hum + +/obj/machinery/power/charged_crystal/Initialize(mapload, band) + . = ..() + if(!isnull(band)) + origin_band = XENOARCH_BAND_INDEX(band) + update_appearance() + +/obj/machinery/power/charged_crystal/Destroy() + QDEL_NULL(hum) + return ..() + +/// Came out of the rock badly. +/obj/machinery/power/charged_crystal/proc/crack() + if(cracked) + return + cracked = TRUE + charge_left = round(charge_left / 2) + +/// Has any charge left to give. +/obj/machinery/power/charged_crystal/proc/is_dull() + return charge_left <= 0 + +/obj/machinery/power/charged_crystal/proc/can_run() + return wired && anchored && powernet && !is_dull() + +/// Starts or stops feeding the net to match what it can do now. +/obj/machinery/power/charged_crystal/proc/refresh_running() + var/should_run = can_run() + if(should_run == running) + return + running = should_run + if(running) + begin_processing() + if(!hum) + hum = new /datum/looping_sound/artifact_hum/electromagnetic(src) + hum.start() + playsound(src, 'sound/effects/magic/charge.ogg', 40, TRUE) + visible_message(span_notice("[src] catches, and light crawls up through it.")) + else + end_processing() + hum?.stop() + update_appearance() + +/obj/machinery/power/charged_crystal/process(seconds_per_tick) + if(!can_run()) + refresh_running() + return + add_avail(power_to_energy(XENOARCH_CRYSTAL_POWER)) + charge_left -= seconds_per_tick * (1 SECONDS) + if(is_dull()) + go_dull() + return + update_light_level() + +/// Spent: dark through and through, and no use to anyone. +/obj/machinery/power/charged_crystal/proc/go_dull() + charge_left = 0 + visible_message(span_notice("The last of the light drains out of [src], and it goes dark.")) + playsound(src, 'sound/effects/stealthoff.ogg', 40, TRUE) + refresh_running() + +/// Brighter while it has more to give. +/obj/machinery/power/charged_crystal/proc/update_light_level() + if(!running) + set_light(0) + return + var/fraction = clamp(charge_left / XENOARCH_CRYSTAL_LIFETIME, 0, 1) + set_light(2, 0.4 + fraction * 0.6, "#ffb347") + +/obj/machinery/power/charged_crystal/update_icon_state() + if(is_dull()) + icon_state = "charged_crystal_dull" + else if(running) + icon_state = "charged_crystal_active" + else + icon_state = "charged_crystal" + return ..() + +/obj/machinery/power/charged_crystal/update_overlays() + . = ..() + if(wired) + . += mutable_appearance(icon, "charged_crystal_wires") + +/obj/machinery/power/charged_crystal/update_appearance(updates = ALL) + . = ..() + update_light_level() + +/obj/machinery/power/charged_crystal/examine(mob/user) + . = ..() + if(is_dull()) + . += "It is dark all the way through." + else + var/fraction = charge_left / XENOARCH_CRYSTAL_LIFETIME + if(fraction > 0.66) + . += "Light moves deep inside it." + else if(fraction > 0.33) + . += "The light inside it has thinned." + else + . += "Only a faint spark is left in it." + if(cracked) + . += span_warning("A crack runs through it.") + if(wired) + . += "Copper wire is wound around its foot[running ? ", warm to the touch" : ""]." + if(anchored) + . += "It is bolted to the deck." + +/obj/machinery/power/charged_crystal/wrench_act(mob/living/user, obj/item/tool) + if(running) + balloon_alert(user, "cut the wire first") + return ITEM_INTERACT_BLOCKING + if(default_unfasten_wrench(user, tool, 3 SECONDS) != SUCCESSFUL_UNFASTEN) + return ITEM_INTERACT_BLOCKING + if(anchored) + connect_to_network() + else + disconnect_from_network() + refresh_running() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/power/charged_crystal/wirecutter_act(mob/living/user, obj/item/tool) + if(!wired) + return NONE + if(!tool.use_tool(src, user, 2 SECONDS, volume = 50)) + return ITEM_INTERACT_BLOCKING + wired = FALSE + new /obj/item/stack/cable_coil(drop_location(), 2) + user.visible_message(span_notice("[user] cuts the wire off [src]."), span_notice("You cut the wire off [src].")) + refresh_running() + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/power/charged_crystal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!istype(tool, /obj/item/stack/cable_coil)) + return NONE + if(wired) + balloon_alert(user, "already wired") + return ITEM_INTERACT_BLOCKING + var/obj/item/stack/cable_coil/coil = tool + if(coil.get_amount() < 2) + balloon_alert(user, "need more cable") + return ITEM_INTERACT_BLOCKING + user.visible_message(span_notice("[user] starts winding cable around [src]."), span_notice("You start winding cable around [src].")) + if(!do_after(user, 2 SECONDS, src) || wired || !coil.use(2)) + return ITEM_INTERACT_BLOCKING + wired = TRUE + playsound(src, 'sound/items/deconstruct.ogg', 40, TRUE) + refresh_running() + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/power/charged_crystal/atom_destruction(damage_flag) + var/turf/here = get_turf(src) + if(here) + visible_message(span_warning("[src] shatters!"), blind_message = span_hear("You hear a crystal shatter.")) + playsound(here, 'sound/effects/glass/glassbr3.ogg', 60, TRUE) + for(var/i in 1 to 2) + new /obj/item/shard(here) + return ..() diff --git a/voidcrew/modules/xenoarch/dig/site_walls.dm b/voidcrew/modules/xenoarch/dig/site_walls.dm index be9b4bd111a..98f43995c5e 100644 --- a/voidcrew/modules/xenoarch/dig/site_walls.dm +++ b/voidcrew/modules/xenoarch/dig/site_walls.dm @@ -1 +1,103 @@ -// Site walls: rock flagged as part of a dig site, holding a boulder or a strange rock. +/** + * # Dig site walls + * + * Ordinary mountain rock flagged as part of a dig site. The wall keeps its own turf type (so it + * smooths, digs and leaves the same ground as its neighbours) and gains a vein overlay, a mining + * scanner mark, and one find. Mining it frees that find: a boulder for a large one, a strange rock + * for a small one, taken out of its site's record in the planet's dig reserve. + * + * Site walls drop no ore, so NPC planet miners (ambient_ore_wall_ok()) leave them alone. They + * never set mineralType to anything but null: upstream gets_drilled() builds whatever that is. + * + * Vein marks are Baystation12 excavation_overlays.dmi overlay_archaeo1-3, recoloured. + */ + +/turf/closed/mineral + /// The dig site this wall belongs to (weakref to /datum/dig_site_record) + var/datum/weakref/dig_site_ref + /// XENOARCH_FIND_* this wall gives up when mined, or null for plain rock + var/dig_find + /// The vein overlay drawn on it, so it can be taken off again + var/mutable_appearance/dig_vein_overlay + +/// Rock tints by wall sprite, so a boulder looks like the face it came out of. +GLOBAL_LIST_INIT(xenoarch_rock_tints, list( + "icons/turf/walls/mountain_wall.dmi" = "#eef2f6", + "icons/turf/walls/icerock_wall.dmi" = "#d5e1ec", + "icons/turf/walls/rock_wall.dmi" = "#9c968e", + "icons/turf/walls/sandstone_wall.dmi" = "#d8bf8e", +)) + +/// Turns this rock into a dig site wall holding one find of `find_kind` for `site`. +/turf/closed/mineral/proc/make_dig_site_wall(datum/dig_site_record/site, find_kind) + dig_site_ref = WEAKREF(site) + dig_find = find_kind + mineralType = null + mineralAmt = 0 + spawned_boulder = null + scan_state = "xenoarch_site" + if(dig_vein_overlay) + cut_overlay(dig_vein_overlay) + // Drawn on the 32x32 tile, not the wall's oversized 40x40 sprite. + dig_vein_overlay = mutable_appearance('voidcrew/modules/xenoarch/icons/dig_site.dmi', "vein[rand(1, 3)]", appearance_flags = RESET_TRANSFORM) + add_overlay(dig_vein_overlay) + +/// Clears the site flag without giving anything up. +/turf/closed/mineral/proc/clear_dig_site_wall() + dig_site_ref = null + dig_find = null + scan_state = "" + if(dig_vein_overlay) + cut_overlay(dig_vein_overlay) + dig_vein_overlay = null + +/// The colour a boulder cut from this face should take. +/turf/closed/mineral/proc/dig_rock_color() + if(color) + return color + return GLOB.xenoarch_rock_tints["[icon]"] || "#8f8a83" + +/turf/closed/mineral/examine(mob/user) + . = ..() + if(dig_find) + . += span_notice("Pale veins run through the rock here, flecked with something that isn't stone.") + +/turf/closed/mineral/gets_drilled(mob/user, exp_multiplier = 0) + if(dig_find) + release_dig_find(user) + return ..() + +/** + * Frees this wall's find onto its own tile, just before the wall is scraped away. + * Returns the boulder or strange rock, or null if the site's record had nothing left. + */ +/turf/closed/mineral/proc/release_dig_find(mob/user) + var/datum/dig_site_record/site = dig_site_ref?.resolve() + var/kind = dig_find + var/rock_color = dig_rock_color() + clear_dig_site_wall() + if(!site || !site.take_find(kind)) + return null + var/atom/movable/freed + switch(kind) + if(XENOARCH_FIND_LARGE) + var/datum/dig_boulder_record/boulder_record = site.cut_boulder() + boulder_record.rock_color = rock_color + freed = new /obj/structure/xenoarch_boulder(src, boulder_record, site) + visible_message( + span_notice("The face splits around something harder, and a veined boulder comes away whole."), + blind_message = span_hear("You hear rock split with a deep crack."), + ) + playsound(src, 'sound/effects/rock/rock_break.ogg', 50, TRUE) + if(XENOARCH_FIND_SMALL) + freed = new /obj/item/strange_rock(src, site.band, site.site_kind) + if(user) + to_chat(user, span_notice("A lump of veined rock drops out of the face.")) + return freed + +/// The mining scanner's pulse marks a site wall with its own sign rather than an ore's. +/obj/effect/temp_visual/mining_overlay/Initialize(mapload) + . = ..() + var/turf/closed/mineral/rock = loc + if(istype(rock) && rock.dig_find) + icon = 'voidcrew/modules/xenoarch/icons/dig_scan.dmi' diff --git a/voidcrew/modules/xenoarch/dig/spectrometer.dm b/voidcrew/modules/xenoarch/dig/spectrometer.dm index 3b387f5c53d..32ec639e2f9 100644 --- a/voidcrew/modules/xenoarch/dig/spectrometer.dm +++ b/voidcrew/modules/xenoarch/dig/spectrometer.dm @@ -1 +1,193 @@ -// The spectrometer that reads a core sample and prints the stabilising field. +/** + * # Radiocarbon spectrometer + * + * A bench machine aboard ship. Feed it a core sampler holding a boulder's core and it dates the + * rock, notes what is mixed into it, and names the stabilising field its matrix holds under, on a + * printed report. Then it hands the sampler back empty. + * + * Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/machines/ + * geosample_analyzer.dm, reworked: a timer instead of sleep(), the sampler comes back instead of + * being kept (or destroyed on a power cut), and the report reads like lab paperwork. + * Sprites: Nova machinery.dmi spectrometer states, with a drawn open panel. + */ +/obj/item/circuitboard/machine/xenoarch_spectrometer + name = "Radiocarbon Spectrometer" + greyscale_colors = CIRCUIT_COLOR_SCIENCE + build_path = /obj/machinery/xenoarch_spectrometer + req_components = list( + /datum/stock_part/scanning_module = 2, + /obj/item/stack/sheet/glass = 1, + ) + +/obj/machinery/xenoarch_spectrometer + name = "radiocarbon spectrometer" + desc = "A bench spectrometer for rock cores. It dates the rock and reads the field its matrix will hold under." + icon = 'voidcrew/modules/xenoarch/icons/dig_machines.dmi' + icon_state = "spectrometer" + base_icon_state = "spectrometer" + density = TRUE + circuit = /obj/item/circuitboard/machine/xenoarch_spectrometer + use_power = IDLE_POWER_USE + idle_power_usage = BASE_MACHINE_IDLE_CONSUMPTION + active_power_usage = BASE_MACHINE_ACTIVE_CONSUMPTION + /// The sampler being read + var/obj/item/excavation/core_sampler/sampler + /// The reading in progress + var/scan_timer + /// How long a reading takes; better scanning modules shorten it + var/scan_time = 10 SECONDS + +/obj/machinery/xenoarch_spectrometer/Destroy() + if(scan_timer) + deltimer(scan_timer) + scan_timer = null + if(sampler) + sampler.forceMove(drop_location()) + sampler = null + return ..() + +/obj/machinery/xenoarch_spectrometer/RefreshParts() + . = ..() + var/rating = max(total_part_rating(/datum/stock_part/scanning_module), 2) + // Two tier-1 modules read in 10 s; each tier past that takes off a second, down to 4 s. + scan_time = max(4 SECONDS, (12 - rating) SECONDS) + +/obj/machinery/xenoarch_spectrometer/update_icon_state() + if(panel_open) + icon_state = "[base_icon_state]_open" + else if(scan_timer) + icon_state = "[base_icon_state]_processing" + else + icon_state = base_icon_state + return ..() + +/obj/machinery/xenoarch_spectrometer/examine(mob/user) + . = ..() + if(scan_timer) + . += "It is reading a core." + +/obj/machinery/xenoarch_spectrometer/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!istype(tool, /obj/item/excavation/core_sampler)) + return NONE + var/obj/item/excavation/core_sampler/new_sampler = tool + if(panel_open) + balloon_alert(user, "close the panel") + return ITEM_INTERACT_BLOCKING + if(!is_operational) + balloon_alert(user, "no power") + return ITEM_INTERACT_BLOCKING + if(sampler) + balloon_alert(user, "already reading") + return ITEM_INTERACT_BLOCKING + if(!new_sampler.sample_serial) + balloon_alert(user, "sampler is empty") + return ITEM_INTERACT_BLOCKING + if(!user.transferItemToLoc(new_sampler, src)) + return ITEM_INTERACT_BLOCKING + sampler = new_sampler + user.visible_message( + span_notice("[user] slots [new_sampler] into [src]."), + span_notice("You slot [new_sampler] into [src]."), + ) + begin_reading() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/xenoarch_spectrometer/attack_hand(mob/living/user, list/modifiers) + . = ..() + if(. || !sampler) + return + // Pulls the sampler out, abandoning a reading. + stop_reading() + eject_sampler(user) + return TRUE + +/obj/machinery/xenoarch_spectrometer/proc/begin_reading() + update_use_power(ACTIVE_POWER_USE) + playsound(src, 'sound/effects/industrial_scan/industrial_scan3.ogg', 40, TRUE) + scan_timer = addtimer(CALLBACK(src, PROC_REF(finish_reading)), scan_time, TIMER_STOPPABLE | TIMER_DELETE_ME) + update_appearance() + +/obj/machinery/xenoarch_spectrometer/proc/stop_reading() + if(scan_timer) + deltimer(scan_timer) + scan_timer = null + update_use_power(IDLE_POWER_USE) + update_appearance() + +/obj/machinery/xenoarch_spectrometer/proc/finish_reading() + scan_timer = null + update_use_power(IDLE_POWER_USE) + if(QDELETED(src) || !sampler) + update_appearance() + return + if(!is_operational) + visible_message(span_warning("[src] whines down and pushes the sampler back out.")) + eject_sampler() + update_appearance() + return + print_report(sampler) + sampler.clear_core() + eject_sampler() + update_appearance() + +/// Power lost mid-reading: the sampler comes back with its core still in it. +/obj/machinery/xenoarch_spectrometer/on_set_is_operational(old_value) + . = ..() + if(!is_operational && scan_timer) + stop_reading() + visible_message(span_warning("[src] whines down and pushes the sampler back out.")) + eject_sampler() + +/obj/machinery/xenoarch_spectrometer/proc/eject_sampler(mob/living/user) + if(!sampler) + return + var/obj/item/excavation/core_sampler/out = sampler + sampler = null + out.forceMove(drop_location()) + if(user) + user.put_in_hands(out) + +/obj/machinery/xenoarch_spectrometer/Exited(atom/movable/gone, direction) + . = ..() + if(gone == sampler) + sampler = null + stop_reading() + +/// Prints the lab report for the core in `core_sampler`. +/obj/machinery/xenoarch_spectrometer/proc/print_report(obj/item/excavation/core_sampler/core_sampler) + var/obj/item/paper/report = new(drop_location()) + report.name = "spectrometer report ([core_sampler.sample_serial])" + report.add_raw_text(xenoarch_spectrometer_text(core_sampler.sample_serial, core_sampler.sample_age, core_sampler.sample_field, core_sampler.sample_site_kind)) + var/datum/asset/spritesheet_batched/sheet = get_asset_datum(/datum/asset/spritesheet/simple/paper) + report.add_stamp(sheet.icon_class_name("stamp-ok"), rand(180, 260), rand(330, 380), rand(-15, 15), "stamp-ok") + report.update_appearance() + playsound(src, 'sound/machines/printer.ogg', 40, FALSE) + visible_message(span_notice("[src] chatters and feeds out a report.")) + return report + +/// What a site's rock is mixed with, as the spectrometer reads it. +GLOBAL_LIST_INIT(xenoarch_core_inclusions, list( + XENOARCH_SITE_GARDEN = "carbonised plant matter, pollen", + XENOARCH_SITE_ANIMAL = "mineralised bone, collagen traces", + XENOARCH_SITE_HOUSE = "fired clay, hearth ash", + XENOARCH_SITE_TECHNICAL = "corroded alloy, trace rare earths", + XENOARCH_SITE_TEMPLE = "pigment, worked stone dust", + XENOARCH_SITE_WAR = "slag, spent propellant residue", +)) + +/// The report text: lab paperwork, nothing about how to use it. +/proc/xenoarch_spectrometer_text(serial, age, field, site_kind) + var/age_text + if(age >= 1000000000) + age_text = "[round(age / 1000000000, 0.1)] billion years" + else if(age >= 1000000) + age_text = "[round(age / 1000000)] million years" + else + age_text = "[round(age / 1000)] thousand years" + var/inclusions = GLOB.xenoarch_core_inclusions[site_kind] || "none of note" + . = "
RADIOCARBON SPECTROMETRY
Core sample [serial]

" + . += "Matrix age: about [age_text]
" + . += "Inclusions: [inclusions]
" + . += "Exotic signature: present, unresolved
" + . += "Matrix holds under: [field]
" + . += "
Bench copy. File with the find." diff --git a/voidcrew/modules/xenoarch/dig/stabiliser.dm b/voidcrew/modules/xenoarch/dig/stabiliser.dm index f3fda34d942..e0fa1f118cf 100644 --- a/voidcrew/modules/xenoarch/dig/stabiliser.dm +++ b/voidcrew/modules/xenoarch/dig/stabiliser.dm @@ -1 +1,68 @@ -// The field stabiliser set to one of eight fields before a boulder is cut down. +/** + * # Field stabiliser + * + * A handheld projector that lays a stabilising field over a boulder before its find is freed. + * Every boulder's rock needs one of eight fields; only the right one holds the find together as + * the last of the rock comes away. A wrong field looks exactly like a right one. The spectrometer + * (or an advanced depth scanner) says which field a boulder needs. + * + * Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/obj/ + * artifact_stabilizer.dm: the field list (Phoron renamed), picked from a radial instead of an + * input box. Sprite: Nova tools.dmi anoscanner_borg. + */ + +/// The eight fields a boulder's rock may need, with the colour the stabiliser shows for each. +GLOBAL_LIST_INIT(xenoarch_stabilising_fields, list( + "Diffracted carbon dioxide laser" = "#ff8f6b", + "Nitrogen tracer field" = "#7fb2ff", + "Potassium refrigerant cloud" = "#b98bff", + "Mercury dispersion wave" = "#c9d3dc", + "Iron wafer conduction field" = "#d08a5c", + "Calcium binary deoxidizer" = "#f2f0d8", + "Chlorine diffusion emissions" = "#b6f06a", + "Plasma saturated field" = "#ff6fd0", +)) + +/obj/item/excavation/stabiliser + name = "field stabiliser" + desc = "An old handheld field projector, built to hold crumbling rock together. Its dial is marked with eight field settings." + icon_state = "stabiliser" + inhand_icon_state = "analyzer" + w_class = WEIGHT_CLASS_NORMAL + custom_materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT, /datum/material/glass = HALF_SHEET_MATERIAL_AMOUNT, /datum/material/silver = SMALL_MATERIAL_AMOUNT * 3) + /// The field it projects, or null before one is dialled in + var/selected_field + +/obj/item/excavation/stabiliser/examine(mob/user) + . = ..() + if(selected_field) + . += "The dial is set to [selected_field]." + else + . += "The dial is at rest." + +/obj/item/excavation/stabiliser/attack_self(mob/user, modifiers) + . = ..() + if(.) + return + var/list/choices = list() + for(var/field in GLOB.xenoarch_stabilising_fields) + var/image/swatch = image('voidcrew/modules/xenoarch/icons/dig_site.dmi', "field") + swatch.color = GLOB.xenoarch_stabilising_fields[field] + choices[field] = swatch + var/picked = show_radial_menu(user, src, choices, require_near = TRUE, tooltips = TRUE) + if(!picked || !user.is_holding(src) || !(picked in GLOB.xenoarch_stabilising_fields)) + return TRUE + selected_field = picked + playsound(src, 'sound/machines/click.ogg', 30, TRUE) + balloon_alert(user, "set to [lowertext(picked)]") + update_appearance() + return TRUE + +/obj/item/excavation/stabiliser/update_overlays() + . = ..() + if(!selected_field) + return + // A small lamp on the dial shows the setting's colour. + var/mutable_appearance/lamp = mutable_appearance(icon, "stabiliser_lamp") + lamp.color = GLOB.xenoarch_stabilising_fields[selected_field] + . += lamp diff --git a/voidcrew/modules/xenoarch/dig/strange_rock.dm b/voidcrew/modules/xenoarch/dig/strange_rock.dm index 77870dd65d7..ffb539892dd 100644 --- a/voidcrew/modules/xenoarch/dig/strange_rock.dm +++ b/voidcrew/modules/xenoarch/dig/strange_rock.dm @@ -1 +1,179 @@ -// Strange rocks: small finds chipped out by hand. +/** + * # Strange rock + * + * A lump of veined rock that drops out of a site wall, holding one small find. Worked by hand + * like a boulder in miniature: sound it, chip it down with the hammer, brush the last of it away. + * Cut too deep and the find goes with the rock. + * + * Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarch/code/modules/research/ + * xenoarch/strange_rock.dm, reworked: finds come from the site's kind and band (curios.dm), not + * Nova's loot tables; one scanner sounds and measures; the find is stamped with its band. Sprite + * from Polaris (6cd19fe0dec6) icons/obj/xenoarchaeology.dmi "strange", carved down in two stages. + */ +/obj/item/strange_rock + name = "strange rock" + desc = "A lump of veined rock, heavier than it looks." + icon = 'voidcrew/modules/xenoarch/icons/curios.dmi' + icon_state = "strange_rock_0" + w_class = WEIGHT_CLASS_NORMAL + force = 6 + throwforce = 8 + /// ZONE_* its find is stamped with + var/band = ZONE_GREEN + /// XENOARCH_SITE_* it came out of + var/site_kind + /// 1 to 3 + var/tier = 1 + /// What is inside (a type path) + var/find_type + /// Depth of the find, in cm + var/target_depth = 10 + /// What the plain depth scanner reads + var/rough_depth = 10 + /// cm chipped off so far + var/dug = 0 + /// 0 not sounded, 1 roughly, 2 exactly + var/sounded = 0 + /// Next world.time a working tool makes its noise + var/next_work_noise = 0 + +/obj/item/strange_rock/Initialize(mapload, band, site_kind) + . = ..() + src.band = XENOARCH_BAND_INDEX(isnull(band) ? xenoarch_band_for_turf(src) : band) + src.site_kind = site_kind || pick(XENOARCH_SITE_GARDEN, XENOARCH_SITE_ANIMAL, XENOARCH_SITE_HOUSE, XENOARCH_SITE_TECHNICAL, XENOARCH_SITE_TEMPLE, XENOARCH_SITE_WAR) + tier = xenoarch_roll_small_tier(src.band) + find_type = xenoarch_small_find(src.site_kind, tier) + target_depth = rand(6, 14) + tier * XENOARCH_ROCK_DEPTH_PER_TIER + var/error = round(XENOARCH_SCAN_ERROR / 3) + rough_depth = max(1, target_depth + rand(-error, error)) + pixel_x = base_pixel_x + rand(-6, 6) + pixel_y = base_pixel_y + rand(-4, 4) + +/obj/item/strange_rock/update_icon_state() + switch(dug) + if(20 to INFINITY) + icon_state = "strange_rock_2" + if(10 to 20) + icon_state = "strange_rock_1" + else + icon_state = "strange_rock_0" + return ..() + +/obj/item/strange_rock/examine(mob/user) + . = ..() + var/error = round(XENOARCH_SCAN_ERROR / 3) + switch(sounded) + if(1) + . += "A survey tag on it reads: find at [max(1, rough_depth - error)] to [rough_depth + error] cm. Cut [dug] cm." + if(2) + . += "A survey tag on it reads: find at [target_depth] cm. Cut [dug] cm." + +/obj/item/strange_rock/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(istype(tool, /obj/item/excavation/hammer)) + return hammer_with(tool, user) + if(istype(tool, /obj/item/excavation/brush)) + return brush_with(tool, user) + if(istype(tool, /obj/item/excavation/depth_scanner)) + return sound_with(tool, user) + return NONE + +/obj/item/strange_rock/proc/hammer_with(obj/item/excavation/hammer/hammer, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(!do_after(user, hammer.head * XENOARCH_HAMMER_TIME_PER_CM, src, extra_checks = CALLBACK(src, PROC_REF(work_noise), SFX_ROCK_TAP, 0.6 SECONDS), interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src)) + return ITEM_INTERACT_BLOCKING + dug += hammer.head + if(dug > target_depth) + lose_find(user) + return ITEM_INTERACT_SUCCESS + if(sounded) + balloon_alert(user, "[dug] cm") + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/item/strange_rock/proc/brush_with(obj/item/excavation/brush/brush, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(!do_after(user, brush.brush_time, src, extra_checks = CALLBACK(src, PROC_REF(work_noise), SFX_RUSTLE, 0.9 SECONDS), interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src)) + return ITEM_INTERACT_BLOCKING + if(dug > target_depth) + lose_find(user) + return ITEM_INTERACT_SUCCESS + if(dug == target_depth) + free_find(user) + return ITEM_INTERACT_SUCCESS + dug++ + if(sounded) + balloon_alert(user, "[dug] cm") + update_appearance() + return ITEM_INTERACT_SUCCESS + +/obj/item/strange_rock/proc/sound_with(obj/item/excavation/depth_scanner/scanner, mob/living/user) + if(DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + playsound(src, 'sound/effects/industrial_scan/industrial_scan2.ogg', 30, TRUE) + if(!do_after(user, scanner.scan_time, src, interaction_key = DOAFTER_SOURCE_XENOARCH)) + return ITEM_INTERACT_BLOCKING + if(QDELETED(src)) + return ITEM_INTERACT_BLOCKING + sounded = max(sounded, scanner.exact ? 2 : 1) + playsound(src, 'sound/machines/beep/twobeep.ogg', 30, TRUE) + var/error = round(XENOARCH_SCAN_ERROR / 3) + var/depth_text = scanner.exact ? "[target_depth] cm" : "[max(1, rough_depth - error)] to [rough_depth + error] cm" + to_chat(user, span_notice("[scanner] pins a survey tag to [src] and reads: Find at [depth_text]. Cut [dug] cm.")) + return ITEM_INTERACT_SUCCESS + +/// A tool's noise at most every `interval`. Always TRUE (a do_after check). +/obj/item/strange_rock/proc/work_noise(noise, interval) + if(world.time < next_work_noise) + return TRUE + next_work_noise = world.time + interval + playsound(src, noise, 35, TRUE) + return TRUE + +/// Brushed clean at the right depth: the find comes loose, into the hand that held the rock if any. +/obj/item/strange_rock/proc/free_find(mob/living/user) + var/atom/drop_spot = drop_location() + var/mob/living/holder = ismob(loc) ? loc : null + var/atom/movable/find = xenoarch_spawn_small_find(find_type, drop_spot, band) + playsound(drop_spot, 'sound/effects/rock/rock_break.ogg', 40, TRUE) + new /obj/effect/temp_visual/xenoarch_dust(get_turf(drop_spot), "#9c8c78", TRUE) + if(find) + var/atom/speaker = holder || src + speaker.visible_message( + span_notice("The last crust of rock falls away from [find]."), + blind_message = span_hear("You hear rock crumble."), + ) + record_metric(METRIC_RESEARCH, XENOARCH_METRIC_DUG, subject = "[find.type]", zone = xenoarch_band_name(band), details = list("size" = "small", "tier" = tier), actor = user, location = drop_spot) + qdel(src) + if(!find) + return null + if(holder && isitem(find) && holder.put_in_hands(find)) + return find + // On the ground it hops out of the grit. + animate(find, pixel_z = 5, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(pixel_z = 0, time = 0.3 SECONDS, easing = BOUNCE_EASING) + return find + +/// Cut too deep, or smashed: the find goes with the rock. +/obj/item/strange_rock/proc/lose_find(mob/living/user, reason = "cut too deep") + var/atom/drop_spot = drop_location() + var/atom/speaker = ismob(loc) ? loc : src + speaker.visible_message( + span_warning("Something inside [src] crunches, and it falls apart into gravel. Nothing in it survived."), + blind_message = span_hear("You hear rock crunch."), + ) + playsound(drop_spot, 'sound/effects/break_stone.ogg', 40, TRUE) + new /obj/effect/temp_visual/xenoarch_dust(get_turf(drop_spot), "#9c8c78", TRUE) + record_metric(METRIC_RESEARCH, XENOARCH_METRIC_LOST, subject = "[find_type]", zone = xenoarch_band_name(band), details = list("size" = "small", "reason" = reason), actor = user, location = drop_spot) + qdel(src) + +/obj/item/strange_rock/ex_act(severity, target) + if(severity == EXPLODE_LIGHT && prob(50)) + return FALSE + lose_find(null, "blast") + return TRUE diff --git a/voidcrew/modules/xenoarch/dig/tools.dm b/voidcrew/modules/xenoarch/dig/tools.dm index c4e4839e004..8481809c7e4 100644 --- a/voidcrew/modules/xenoarch/dig/tools.dm +++ b/voidcrew/modules/xenoarch/dig/tools.dm @@ -1 +1,233 @@ -// Excavation tools: hammer, brush, depth scanner, core sampler and sample bag. +/** + * # Excavation tools + * + * The hand kit for working dig finds: a rock hammer with interchangeable heads, a brush, a depth + * scanner, a core sampler and a bag for strange rocks, sold as a belt kit from the ship autolathe. + * The stabiliser is in stabiliser.dm. + * + * Merged from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarch/.../xenoarch_tool.dm: one + * hammer instead of seven (Nova's adjustable hammer, with a radial), one scanner that also measures + * (Nova's scanner plus tape measure). Sprites from Nova's xenoarch_items.dmi and tools.dmi. + */ +/obj/item/excavation + name = "excavation tool" + icon = 'voidcrew/modules/xenoarch/icons/dig_tools.dmi' + lefthand_file = 'icons/mob/inhands/equipment/tools_lefthand.dmi' + righthand_file = 'icons/mob/inhands/equipment/tools_righthand.dmi' + w_class = WEIGHT_CLASS_SMALL + obj_flags = CONDUCTS_ELECTRICITY + +// ===== Hammer ===== + +/// Hammer heads, in cm taken off per strike. +#define XENOARCH_HAMMER_HEADS list(1, 2, 4, 6, 10) + +/** + * A rock hammer with a set of heads. The head fitted sets how much each strike takes off. + */ +/obj/item/excavation/hammer + name = "rock hammer" + desc = "A short rock hammer with a case of interchangeable heads, from a fine pick to a broad chisel." + icon_state = "hammer_4" + inhand_icon_state = "screwdriver_head" + force = 6 + throwforce = 6 + attack_verb_continuous = list("hammers", "chips", "taps") + attack_verb_simple = list("hammer", "chip", "tap") + hitsound = 'sound/effects/rock/rocktap1.ogg' + custom_materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT) + /// cm each strike takes off + var/head = 4 + +/obj/item/excavation/hammer/examine(mob/user) + . = ..() + . += "The [head] cm head is fitted." + +/obj/item/excavation/hammer/attack_self(mob/user, modifiers) + . = ..() + if(.) + return + var/list/choices = list() + for(var/size in XENOARCH_HAMMER_HEADS) + choices["[size] cm"] = image(icon, "hammer_[size]") + var/picked = show_radial_menu(user, src, choices, require_near = TRUE, tooltips = TRUE) + if(!picked || !user.is_holding(src)) + return TRUE + var/new_head = text2num(picked) + if(!new_head || new_head == head) + return TRUE + head = new_head + icon_state = "hammer_[head]" + playsound(src, 'sound/items/tools/ratchet.ogg', 30, TRUE) + balloon_alert(user, "[head] cm head fitted") + update_appearance() + return TRUE + +#undef XENOARCH_HAMMER_HEADS + +// ===== Brush ===== + +/obj/item/excavation/brush + name = "excavation brush" + desc = "A stiff little brush for sweeping grit off something you don't want to scratch." + icon_state = "brush" + inhand_icon_state = "screwdriver_head" + w_class = WEIGHT_CLASS_TINY + custom_materials = list(/datum/material/plastic = SMALL_MATERIAL_AMOUNT * 2, /datum/material/iron = SMALL_MATERIAL_AMOUNT) + /// One pass of the brush + var/brush_time = XENOARCH_BRUSH_TIME + +/obj/item/excavation/brush/quick + name = "quick brush" + desc = "An excavation brush with a vibrating head. It clears grit in a third of the time." + icon_state = "brush_quick" + custom_materials = list(/datum/material/plastic = SMALL_MATERIAL_AMOUNT * 2, /datum/material/iron = SMALL_MATERIAL_AMOUNT * 2, /datum/material/gold = SMALL_MATERIAL_AMOUNT * 2) + brush_time = XENOARCH_BRUSH_TIME_QUICK + +// ===== Depth scanner ===== + +/obj/item/excavation/depth_scanner + name = "depth scanner" + desc = "A handheld sounder that pins a survey tag to a find and reads how deep it sits and how much has been cut." + icon_state = "depth_scanner" + inhand_icon_state = "analyzer" + custom_materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 3, /datum/material/glass = SMALL_MATERIAL_AMOUNT * 3) + /// How long a sounding takes + var/scan_time = 2 SECONDS + /// Reads the true depth, not a range + var/exact = FALSE + /// Also reads which stabilising field holds a boulder + var/reads_field = FALSE + +/obj/item/excavation/depth_scanner/advanced + name = "advanced depth scanner" + desc = "A field sounder that reads a find's exact depth and the field its rock matrix needs, right there at the dig." + icon_state = "depth_scanner_adv" + custom_materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 3, /datum/material/glass = SMALL_MATERIAL_AMOUNT * 3, /datum/material/gold = SMALL_MATERIAL_AMOUNT * 2, /datum/material/uranium = SMALL_MATERIAL_AMOUNT * 2) + scan_time = 1 SECONDS + exact = TRUE + reads_field = TRUE + +// ===== Core sampler ===== + +/** + * Draws a core out of a boulder for the spectrometer. Holds one core at a time; the spectrometer + * empties it, or knock the core out by hand. + */ +/obj/item/excavation/core_sampler + name = "core sampler" + desc = "A hollow steel tube with a hardened bit, for drawing a core of rock out of a find." + icon_state = "sampler" + inhand_icon_state = "screwdriver_head" + custom_materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 4) + /// Catalogue number of the boulder the core came from, or null when empty + var/sample_serial + /// The field that holds that boulder + var/sample_field + /// Radiometric age of the rock + var/sample_age + /// XENOARCH_SITE_* it came from + var/sample_site_kind + +/obj/item/excavation/core_sampler/examine(mob/user) + . = ..() + if(sample_serial) + . += "It holds a core of veined rock, tagged [sample_serial]." + else + . += "It is empty." + +/// Fills it from a boulder's record. +/obj/item/excavation/core_sampler/proc/take_core(datum/dig_boulder_record/record) + sample_serial = record.serial + sample_field = record.field + sample_age = record.age + sample_site_kind = record.site_kind + update_appearance() + +/// Empties it. +/obj/item/excavation/core_sampler/proc/clear_core() + sample_serial = null + sample_field = null + sample_age = null + sample_site_kind = null + update_appearance() + +/obj/item/excavation/core_sampler/update_icon_state() + icon_state = sample_serial ? "sampler_full" : "sampler" + return ..() + +/obj/item/excavation/core_sampler/attack_self(mob/user, modifiers) + . = ..() + if(. || !sample_serial) + return + clear_core() + playsound(src, 'sound/effects/rock/rocktap3.ogg', 30, TRUE) + to_chat(user, span_notice("You knock the core out of [src]. It crumbles.")) + return TRUE + +// ===== Bag and belt ===== + +/// A satchel for strange rocks and small finds. +/obj/item/storage/bag/excavation + name = "find satchel" + desc = "A padded satchel with a pocket for every strange rock." + icon = 'voidcrew/modules/xenoarch/icons/dig_tools.dmi' + icon_state = "find_bag" + worn_icon_state = "satchel" + slot_flags = ITEM_SLOT_BELT | ITEM_SLOT_POCKETS + w_class = WEIGHT_CLASS_SMALL + resistance_flags = FLAMMABLE + storage_type = /datum/storage/bag/excavation + custom_materials = list(/datum/material/plastic = SHEET_MATERIAL_AMOUNT) + +/datum/storage/bag/excavation + max_slots = 12 + max_specific_storage = WEIGHT_CLASS_NORMAL + max_total_storage = 40 + +/datum/storage/bag/excavation/New(atom/parent, max_slots, max_specific_storage, max_total_storage, rustle_sound, remove_rustle_sound) + . = ..() + set_holdable(list( + /obj/item/strange_rock, + /obj/item/xenoarch_curio, + /obj/item/relic, + )) + +/// A webbing rig with loops for the dig kit. +/obj/item/storage/belt/excavation + name = "excavation webbing" + desc = "A chest rig with loops for hammer heads, brushes and a sampler, and a pocket for the field tags." + icon_state = "explorer2" + inhand_icon_state = "explorer2" + worn_icon_state = "explorer2" + storage_type = /datum/storage/excavation_belt + +/datum/storage/excavation_belt + max_slots = 8 + max_specific_storage = WEIGHT_CLASS_NORMAL + max_total_storage = 21 + +/datum/storage/excavation_belt/New(atom/parent, max_slots, max_specific_storage, max_total_storage, rustle_sound, remove_rustle_sound) + . = ..() + set_holdable(list( + /obj/item/excavation, + /obj/item/storage/bag/excavation, + /obj/item/mining_scanner, + /obj/item/t_scanner/adv_mining_scanner, + /obj/item/gps, + /obj/item/pickaxe/mini, + /obj/item/strange_rock, + /obj/item/flashlight, + /obj/item/paper, + /obj/item/pen, + )) + +/// The kit the autolathe prints: webbing with the hand tools and a satchel. +/obj/item/storage/belt/excavation/full + +/obj/item/storage/belt/excavation/full/PopulateContents() + new /obj/item/excavation/hammer(src) + new /obj/item/excavation/brush(src) + new /obj/item/excavation/depth_scanner(src) + new /obj/item/excavation/core_sampler(src) + new /obj/item/storage/bag/excavation(src) diff --git a/voidcrew/modules/xenoarch/icons/curios.dmi b/voidcrew/modules/xenoarch/icons/curios.dmi new file mode 100644 index 0000000000000000000000000000000000000000..9c0676c242e6c94cdc52d5e24c99a31ac5b8e84d GIT binary patch literal 17159 zcmZsCb8u!&^k$sA$;7rjv8^|@C)P}C+jwK!wq|16_QcM_wl?3dYO8j)_K&`O@9nNz zU470yc%IYYN(z#Q@ObcGU|@*SQb3ije*3HSz`=Z72?Okw!N9-?J=K0V1AjT0I9k{{ zTiDrxfw^ZTFODnpGowJyxV&L%v`hNb8}`C9it5c6fCyLzGzi*n3XhNWP(Ukrt!Mmm z@f=-5)K<@qo&pB@*XQ4DF$%5~1jqaPCDaNn5*Y`5sp;fP<}DbFRl>&{7u_Z4l^dxB zRBXlXQ5S@7Jw=og8>{B(r@{Ic7U@!(aMp~hW9uQu9nSzf)c%h4pq(HZP;5SlV6a6z zEv4aiUy7|w(Pt{`v1UtMIa1_(G+BHhh~^#7BT0<!`@LIH57F{jl0=H$hRWF*h)G z0jR9T`U+^b(gLC<;p$2{N}A9fYd7CfT2~Ku;G;a1Y6^Lu9=kT*V#)0; zus@yDmT#PSeBXHm!NX#P_jV>I3cMzRP>R0;xcJX-wtF0^HR z_}i^j_JYxi(qZf9uUXT?#l-n}`z6;F7aKhV1%+@3JmyqDX5yH7zG#SyFeY6Vf;zZ* znLU{rM1|50(0oSbYAUf1xaYGIkOQ;!IJrf_{ab-ftpOvj(o2M^%eOhleX#gI{&14E zf_26BA&4Px&V^axMm*d2pkY4*$W%D<52wj@e*bY1TbVvU7ClbrX=q>%SzF&T!`Hw+ zH`X`%h+1@225Aqr!^(>xgN(Fy6PUCdz8v8C^jek=NBnR^2c4Rx6<`CV1V&4opDiuT8v`4R1<***F|a z-9momt<59{wRkRZUmMEMdR=Az^0-Ph@XoE1_=F^EF44zAjoUIb!OZI;oV`d$yv#YC zCg>(*0>8=h;lV`3k%TNiA=67U+ti;L=a&utCM`DD54X}BX2@VS&y{xF-r^m>_m zM7-(X+N0%$zso#8ChA_k)A4wpyQ#e1%&>{z0FU@}pf~$h(SXR0lR!k`1C5m``wchG z#Y(EIKJ@yKh*{=Y%yivUH8Ah4a9Bu)__XUU=q=Fr-B$s)Y9=?~f?)Pnm*`VII6g6F z_EM2QI|?$hYj(N3+|{D=iRIsHC%w+J%&U472-o`7XWJCXZ-x8W@i*I@hxyj(^K|d# z3ANwK2p+R7%*?y_@pvum&8WPM$qirM-Jwg|@@NaLFq56Ew7CmTAbeqntssLM4Oe%R zJo74(Us^SI_V5}TP_S?0^h424SAmnKg$$`>4>iSB?JV# z)u)HSt{9hwj%dTc zQw|4s$=WO~Lwzjt9Dc@6vyF?{=^53lf4C%t(m+8|7~IR2rz zUHd3JK5lpXX^X&Kj;B%6INdR*uk^9^50>fa%BHEydMYydhUT8CKq#vQ6OF)d=3+1I z^7Z^t3=SFD%tP8jWq!`V{^$ZrD0s2-@mL`1u3dVvhKMJLa1WV}7R4YH-5*4+8IaNd zen8T=w}15O=DfB$Wwlf$kdI&Pz1E;OiL8$S7F8MXWZ>4ZPx};epM-7h$>YUpBs$7H z;C?LwjQH$35;)@_8!V~nsLCk=k(J>YI%)AK9*V!AkPyMdbffljrkbz`ZgJSvb!%;5 zOG_NX0?{r)bM!O5KYCd)$s)Nis(WSx+~dYj?qAUoQ@LTdCR@3#lnjv@7@-a4n&7tp zu3s>7EMxuQl|UWPCz8zZs;0_AXUM2O!# zW`qjmL2Oibs8=0v-)C*?M+=7ylg?Vv(6y$g$f9!v%j>RXJj5L2uz=orJI8JJ{NE4o zLOU^g`~FOSbX6jxxXh}iH}E&`6h7UN4vId9Z!HA|@2jX16IpDf7^Jx@`^eZ_kO$kQ z+d+@gHt5CQ!E1Iq2!Sk0WE38~nABVvc=|(Qo ztLeh5a?AB8 zrY55%-HB}+zr!pU>R<@aVr3b<7PS#n5wbju7c*Il*(&l3mbKGgrLHCxQ4jzJG!2j* zm!KZ+cmBXj54c|bZKKkuKuC^53pZq%*XrNJu#GI(xxHU6tkP>noL=C^GZ$oBjy<)+ ziy{%yZY1l9OHNk3hCcY)9%vKrQ#qdOuj+k9MP!zDk?*H&i&CzP2RCYFkQM}*Q5i}N z*&8YAuT^`ta1i(P@o-!=!@Kl?W)0=ZupJ|H?s}qBNohT6hKh_1DK(>}h}DEJQkxya zag;D{H=m?6GWXFtf6w_d+CB zw{T?m4_Z@Tp$%`&d^tRL+=J?>F~kiQ6Lo&MufHQ2+VzNtSe)jH#ta82xXAsc#w^R- zJ3BG95jyew2+%O}O%47ne^(=%_6q4lM#z7iz>Y5VEuHy-Zb@Z7UCFt|x2OSpgypZ% z*5>x7)01#NHYqVy3U-=l{B8Z{j?`Tke!FV)7Epe}{Guh#$r=}v6FQX4IAqocO93*Opc zZ{pOimOu2Wb7RU%lw=jN;)r&I9U!n!O3M~kSdBXE{=J~}^M~*HrX_~Su8{pgTu})D z+ftWzH6E(n7zKwo++L5l?8mnYpCp56v14yV8T>@~t}W&Mz$|DJ@f7;nM$0W@ zh>UYi3f1wH57NU9{~O1(8{&%y^9rx*7lRcC1sqlYZB>sJlOO$<9sj!sf2<05p?V9^ zlR4mnplshtERnTbX?7`JVWLtr1U(;3DFmKclrj$S@v9wL*as z^OFdIHxlgEv0s{Vlq{Sl;iokdV4}NhD&??vGUbg)NNs*d5S;^)6!DDccPq|sy;VdzulrRcJw~CMcjYq*rE8=Eh*zDGnjBai;2QV zF*roiOD4Q7oGqO3{2lt~2Lng1F+}h9g%>``+e`r88Wq!9tJ+S}CgMX`rp}*_unV{c znz_<_TOQ9X^z6X1oulaoi?{b|iILQTnxSCWO|i)r#hJ=|8qR+iAI7QUZsOz+V*o=r zRs|{(2>UCaqz7WpT;})d@ZGg>4R|1!0P6d@d_r8J-|ZnWvvV1cS9SY7|CBOEfmJIk z>JH(onC0rQ{Kb(Q;31$rCs-8Lty!xN^J;8tY=6I~xi>BJIq=o2HPLtr-`d$PjBWG< zPkv4Z=}5szVFDaQ(axmD9)}b#yfi!ls;x13^PM&4sGA>r|0q9ai74QrKp zy08U8r9D>dYG^I+dMW}IU%s!Xh=-2aK&^!xGd~Ng1S5X0{*?;UKqCT1M#SK)v*}6^ zc=2vj-)l!jX^o9lv_N~tQGjb~qLXY~QhdMENcf1f zx#FLBjFPAO9uB?BLG){czoMrN(^x;`ggIdgbCHp(nFb$nyN4s~wyHHnKSUHLrAJtf zvOfV%xjv%oM*L@_0pz@>dIa!i>;yQW8YW?d zA?1!InNNw_e>TUZEpkQ!%+j0k=_UKe`(Nsks3;Ag1Pex&+N>PzF6~!?`Ns+2Z==OV z%eM&R1Y9|n?R%0~+F1MqbZLMhMZjE%vD1Tp;n>!_k{$RUPc(=|6Ic`45I4L)bWdWe zcYr$uaRF>tX8mCQ@jrq7Qk;^>`Enp!!^aDKSUQgkMUjgj0G&a`Cq_mAOhxFVM8fii z?odDJ+~cvFhyqxbq5_z#7ss-_BL=q^Cg+{1dSlepv%IHCFnA#XMgD{m-m_JxuH zBmmH*EzwtF^a1zS0nc;%>d&Z`R0Bctu`J+85Eya8%+8GQSKPxKo93DNiC1yhC}?m8 zE>ccOh3(_r>e(zP+?l+FxL?XV_A-zw49tKSa`$?ZJqv36XHpocHiWML56Hbxa%*Dj zXCeFB`(I;kq*(DAVSeh;h(oX5xUfX%wZcLX;KI;B|H^mD#3W&xeo4Pdm4$DV_iR+F z)Yuw3&Mz?UZEtCB1cMk#g3o1H!P5#aYPFz;5B3yV2>ZdkqCMp9tl*AUyf6*{Wtyg8 zc}3Z_>FXR;s7?K1r)W&g6LUka?{Sr}3Z*77C|zG_S@9-pM~tDK^oJReVSN@2E!tDO zYLVVpB>}0vzFrPBtR|?%%DchXQcmPy$LEE$6ruXor>EBX-TlE71IsF1hG@F!&B_o9 zJ?`*f-$@uRQ3=UwBI0ZBosRfT4R5r>{15AXO`e`Lt0?8vkYB1=^8ffz^HYU?{>>%4 z0ppRKYh^{{sw;IJL3#}%&JBY=-B+?2>*#cufz((<1&vMCq_9uxO-UM!NRDr4F25eR zK(X^i+3PYN37PGFFTxzL$`bMh;5s@=_KD^H=Jib`G1f~knasbK6qc2qC%^nVU1pew zyI$0IV+lcP`YM_|v?JD;)(1hgMiFd7XM1}?p*|wurYkoZG?K-3e@Lr+#k>&o;>tR0 z`e1ta^B4ysL{B>Rw<~EmNeF2TC_Q5SH~~#17Dx6qXqBBHJh6&&-|*wKl>u7d-vfz1 zkIOD$&B2%@-IL;LnH%EpY@uIdR>L6-tW#n#pMDY^hhjE{R6rKASF&Gy6W00ZC26#C z-u=TpIWd~7EM9zEo_J?rXc#jFga2C3RSOoX-Ni3=%Aa*_|)>@o?vq@Z}+-Ixilsh`&fU z_}6&KUvCCYhVHTgf(~L;L!0VNifUR~Be6J8-uibA4KXjnwaZK9-y1@PROGAIp5ou& zaiZvrUv_UH5sH|D$zk_-Z*{B$(7KM8A)zt3D2a&D_1 z@OB=L-bxwNTZ@zLRtHo=F=IRJnY{Ae5&lvqjC#a%yE-(%pPagofDAU`dujoi#m{aY z_!Ta~H)l(u9qhexqsA_m9`+165qq563MsYtX+&SiMgbhzJABWn9 z%InJgN*S zy>qIq9gNrh3Ebwtd|}kXs71K-gmCYAbo$}G%MfIe{mvxMg+HzP@{y!M`2^3e{8~)dIb+z* zEMr!+ECImpWH_{>#NM*|g)TZH%N6oUvT(xtg*H|gGb;Cc*P&;~MR6{X25&t`}J&Yn~;m?v80z z7@E{tGz|Sm5B~^quNX%REUaO1^y30443R*jXTXxb&=^_ zi)UaxXujysQ<~!`^Bw6VR^5r%8N{|1X>o1bFnbp(Mn%az8K3Y&1H5{OFkO`xiyXUF zg^;%z#kM-#JDb-Hy&ezOvq%1SPviw(M#1f!+Nv>;Q zLG5SDFj6R;PXMq_l;Xsn2hub?$F(Mx-_l$@dndd!!-9=iUbJUf9SdTpeD{m zx4zn5e@;mi+WEdRgq=J6Gl#Q{;pu-%@)C<|(~oTErzTv&A4;CM8W<)*x!>1WVD6BS zj!TH{;>94osDY*?j6YwxXmocR0z2DCjDYlVy4tXC3)=f6+>M#^JKQ-g7DAATV-#3) z6p6l|SV@lZL}!=9TN_=rA3Mnyo0c}E;Sx9a`;_{l?i8WQ^V26x=uH>!%vsHs z5uD|%9ujI<#J=h_TX35@3R<#6LER4;S00B8<3g_6YfuP=f{0W9f%Ff4wo6uXTX9X4 z!XT?v`ukJiy1(;*bc){tBjEuI1ya`#tlO}S`dv5apW7X+IXCj@Nul6J4J|=Tqe@%I zu^sG<$?}77F^m$m*&Lem+YYJyLT-`}@-t|%jX@_~xr`ai<2F3g2-XB}>ufBD^Fo&~ zF^^*c-e$`EcK3yB)L0c!Ba3A5@;ClAB)!<0$}&J39jq!bjR`{anq}Z>k-r-?PS<-j zboYgfx9PlV+~cjE+fd|;%+LLNc&{L1U!g^M;z96pX<^Y9anCvz64j)IIAK*BWP*j< z>O@?4DW^Xu*XHq4^+Y-4n27u2#HtqRkt*RGjUZ43*e(XA$myQn)OBDRn2aLd)Uz@C z0&EhW3CKGp3RWdE7Cm+t@*G^H0UWYl%PU4o{9J%+WkT^5^>f6iX@>W_&?G7BW{$_X zrg3Bp#bH#h3+KM&&e!axrHIUO{^_jCS`@b07EF}^wRkYPQiku6#XunuGgOT?P|$1; zl9m9~qSPu;EM!ESR;9S<(5;k`I*Ne!0*2QMlS^<|My}H@RrtIKL<8R3aXvU9sWPw+ znNsP!4Bj|_DrO^8q)}%@a~!2ShhXW16PT!=AR!s>_`Dz?N$UP@3 z3^ZH|3!PsCSUsWoMfMxf{lVeqfcXv&k_1R=kBGsQ$y(T0npe zT_DBJw3+v+CAXLqnv5hCp{Z!y%odkwXw&xfRp3|${L*&SB9(dI5jm_dast7!lDuRQ z<*NF=c7`=m-TL`BDacFcXiM&|)6 zGufAIoai`+;S}qG{(}xhW<*tiA^-$yL^vQBhCkm!!n)}%DV4ENjlf%t^d``2cl~AI z=##6YGh_J`!Y|b>Pja!rej5Zoh{3T}U-8D}6$R+?3c~ zeCdK{DHS1ExYtVNsKQ&I`E3$ZQd#}0zNb>72G!las~S~F0mu&N)$n{4u3;32*B}2U z5A}KfTEOyh_6{A$PPP^9gJ>fRKXz**Lzgt%ULB=tlj0u(L7iKl7}AjgiyieE z;x6)SYCu4kc(YSf5G3>I zh||&!7uiuUkn=*4SIOsBgJ?o~V?R)iPmESUa#~B;tlK9~1~DQf`_rj$qgH#e2g74l z6Vm-3EN_`Zk4C6Rv%vaKySTrni_=Ms7~@MmqZbZ9XZC`2=OOi37FQ?Ja)mSvi4J`M zu~|M9;kqWA)$!V&!_Hw3g~HhW=;>q%ISEvvD&gk%oM+GK6Rd1&9H@@#dA$({%3t1@ zbj#-22nLD`R$=BI&`Jle>EWXnLK~NnS!t{7?)L5W?(~t9Y={k42I4Mq8q$*!qee9a zl?cUN6@QAYmrz<~Kg_ax88M4?9;Zdj{O4=Nc&x_?NMmb-+w9tNkuEJo0dgfvOHy4q zzSu|$i>HrR0fw5~RTyjNxWw3n^HNf#KCU^0!o8x$qeEN5>%^_d7Nf^gurI!1Ra@C< zPqVKzH#9sa+yBQm^u#{GdBE%6@Ys`at*bff_m_#~LtK>Tg*Yjx6{Xk86|_GMS`Z4E zoRgL*Oxg`n=K=Z5Ep4dvAoK2TX`rnFzI^6Rq(!soy--McM9}K$>_KRDU-;M8XN|zh zjjE5pN(EFMS*QFy^^_HWmP{A2is&{Rk{pxqOuW2!=WET&eqpt=Ghr%71aYRa8uY_~h9kEk$#D^=ORGd6mJ{?5EwT5IXNZgM7Dw)uoN zzPu#0@?1G7tGLPG$y&{|+_eW)>;$$Zh+_=!B}QkuxJtED&7NQp zJB@Lk_+gyjh?Zb7q+Kfa2P%o4*uYK(@p$F!`4$&7*>woz*|Nqu7D@5D_>uuO%5^Rh z3@srOm&jC;znS9{st+Fso5A zRlez`_|J6dHQu2C=YdC9^b}5MM%WY4d<-lnnz7U5I^JT!ft~ps_&m=14gvF2VSX+? zVvlzrxQ_3_xo9A7ps3@LJv0RwowT6kV|?3e-zb&hGmGkZe~RH)wp)1H6W_NiLBAnI zG>gx;-Rb{8IK`Z>sr+}Dr2{;0_d-?4DE!S#h9c)Av}5-ZDX3@2L6?}=dpA?Ow0;hhWma^Hvgh(Xsgv=nER(vN+U>(p+ETlA|iEKWTWYXkzSPivU?@GHmJs zf-Lt2CBfn`3JATNa~PXGt`{q`Sc{k!WCo~JYHHGKu_HS9J|UpL+$zj5uv9>#-8wI)~>T!+`F zoED{ZWYldPRG)As(Jz9P~Kd^R#sQzYyiaS~XO^*I>X66N3CIl(jqTI7HQrPbQ+ z5nb=Tzti-L9^89IGE@2j;Qo=3d{*5;(I{Alox}cN*^R*E=6aLAB4VpSDKKXN59d#$ zk~YN*!HtN)+S{}7V>v=lWD^32g5mkWDjfTFW_j-elD&ihTCOCdm{kOzpob+B4mt>KbaZf1wl)1(D)6@{SHAn}(&$F0i-;1g zQk<^8ca8ti{-fp#mcD%*SDX0rcF#j~qQiW$s-Yyx^9X7py-)Leei@4j*G&u>K)}gN zscw^&6y8c8Pa`^X`zM`{Qn})|L%nj{CbxMl#z?Q|Ilqd?jb{SDJMTjuI0(xm!|)hhMCN`Ulcs^C z&P~u)(ga6E2*DJN<067b96IDh<={!aNs|OQNf2c;k_jUkY zZD8MI`Fh5!OJpVlmWD&MW2fpP{Uj3oj%?Yng~3Bt&Rz zJ3ee|E-Ly5|M!b^FQvEJ5x{i`AMrp-rd{8Y1DDC^RA*IzV9-ScR>8>bJA2E`oRFWNg7%ZPB@RIjOTz3iJ-o4g5%jvxvv)L-fS6(w) zqsRSfE~%h^)YsRiq^ut^Oq3HG983&d*@fyiorJrarcCZ|RCX%&k%sD~fFWe?!nhg+ za}w+KagKFu?a1o8iq*6tvXlglS>4!DL_ zRpiJV)+W2i6+~RATcW$UJ6%Q=@VuM3l$Dbkh$ojc1eE`KYj}B9{ysRwu%?veDA%3c zzK!^2aS;%@gE&sDOw*8?13l9qBsb3z&K|D4F zwBSiTBL>w`JUaOe<0@UNHd0a0RcO+M4s8CY zE2QECucf^;zoG*E;^JZk2AY(Q5B~sD9R!E`>nN)`E{03r6OTL$dGOY0YarNxOY44{ z!VCjs<|g0!G+MiTAkH`2eTfM=UO>=WT?>m+?0!qaNzo3|%Jrcc7#@!7d>JU1sYHQT zM?Js}f$S!h=$nGtIgVZ%l;wyOZDGej9wabzN$eDDZMWM=ZaGcALZ;cX3;N+M4%SzaZ?ZpoD}u@^xCB#=veW6dAmaZz_)#|6D<#T zonfj2dxh_tsqCaSjf(8A-;@5)#({e9i^zWn(cTHN;wCOG&aliM+JmRX_DY!F{SrzT zgT-X%TgP(>`__k!9piva^9Br)9dXsCGaxW1XlG}q*Hluc&JbMR_xaa%gDwKMbqBt$ zTWZGcH^+|aA^d+hgot_$v=K6silx5WBQOra-9zX{&e8B#DFldEc3)@@{*s)Sh%LiZ zwru|mhhOBcgT|Ly@YZ&#?c?*1L_gx*x4%v{RAzOGGzzWfL-%e2k;{5+xZXZNv!zEU zPtXm+ACO)SW3W$`eI5n&?`G~T1_^cdN6goaZzR{dAL8z0`%ZTqmd(tMhfISv0v z1nQ+t`3aptwl+2J?z;!#1!gCV6B)cP5GPkAGB~!a=d5y>_jGhJ5lSj*pz8L|zNq7Q zB~6CO*QX;pzAs?_KEW6wVr+)mES3w~D$vq-LRNC@;V!Et`|69(zU#LV__VoL-8h2s(~dlK9-*klY-07Jc2H*PCYQ)#S_{-6t%2* zbB~-7`EDY!6U}?3$4h+f^&t6}w(0J)L}MNV)zhE&9-Pt89iM%JtUt~jhAgy35en_a zcFze74qZG7~;LOlXtzDh8=%{+-=JESm*9|pGV=5A=L-Ssc7T*B6nIxH=KD_=U zcW^Bd(ZK2-v)M*Hup1`a(BG1^Xg;Zz==*eo^LgFKK^XNU+T$a`qobd+TaG0s8mMkIoxM-te7nF54G6y{POpVUjg%s%j%VTCkpe7N>y&Ep=G^$ad1_Z< zrSzTK1J+p)soCYbR9;Y`P5Y{&(tK-cglFBRjc={C^07YbV;6+yyLSFowEig66c;^% z@7;aKZT}JUfSUiK%M|~jlYwYi+-#>dQL^6rV?2%128%xsf+bH=l0aq!LeGNdW{PNN zq_Ulin+9c7*zfz6P7w5X^L8)}EOI{9;^J(YpbME`M@8-58+;xkeGiwer>COut4Cyi ziM^PGB(?84U0L!YYlV`E1ia31<+-uQoi0%OM@Qsz6#{K;moX;8A0m$}+(jkXW{A@R zs?;pT`}bvKWx}B~c<8n)8?L(Vn?bvMM>UfF77MS2FU!-MxBO$PWi8K0Dtbfw)5p@Y9KPW>c01F1RB z>3I3f3d7iiXKLwAR2YP1l>EA_p*7qD`M%-bl~}i(aQ>t5ndIiy@vqPA&5dZ89b%I! zF{t?6Gqb9wzRiI(0oQV>TYh-c0?*usjNaIaa-MKU35oG_mv-(Ro@A_a@JFo2uYl$Bs$iW7ZtwaF z*(fR5kPB}*TVe<`4Fnv)E4+cBd*SL#*ntX_r)SvR5hg#QUJAd7S1RN%wzhn`C|I=V zV#70#QOipNkrc02%?I>O|Hu$FSy5&tzB4bOrDerV%{6mb|KHo;cpnywgM(iaMPI@V zme>m&8IgK=g%;kc5wTT3TZeoptojXRq4l~=3)54GG!twPx;}@2f%O(rC*#+Yt(p$g z?K5qpHEOEA?Vo;%@yLUV5pep45&DIrqdZ#VT%Z#YS{UkujZIB~cdUOcEIhM& z;gUK5?OKXd8uzjE^qZ7T4TBU#0-wP@{rJEyRO~xYh4*8WI=Ai+x|}e*DoKS%VcmA4 zh({?y)baP7k96PejByJgCj}8KYGP1khI$w!`h3c^L=a!OP3JYiR1NRsMrWSbYI)RPH27N)$C2N)YF^zyHC{COUTp7pV%K8 zZx+BFSJK|6i&tW$BlL*aEgSoG{JrIKok9GX^Dttx2cv_q+IsKd#JK^RQ^ABOTtO5? z0Z*nOA%5|1GSXtZ`w9YTHWE}}_kF|x4E!$Rd8Behd~q4GK`o(oc@zE~I-%&^L-jCwfN ztgNgn2m@1`&Otgmm;)%1Q-d#?o}F zA%i4LckteyccweZ9<;_PhDhu5vD<%eW8r%WaXQ_@$`}*}GmaJE{v73C`c)wbuea*? zmk{!=^X-Z*5$S%58fK$r`jzX#0)rZ|5Csx5r8tq6q?3A;1^|GJWW?q8)R@`hnCQp6 z7{$arzLs3JAHStxqiCys)c5&&4*`oGhyp5!&dL8IF)bNZc7v4D7wunJx@TD|5zt!7 zM=@=!Sg-csUlhXcsuN(Os7fgZ_R?+-!)7!}b}K}Du9jyhlGN4qPR85&Rrg|S+L6ht zd7%_LUx>Q&A1OnJkcZ^CqR}qWnf3iXn7WBka=kpjtwqQo7`>lcO+U+?;+w<8YEA-@ z7BDC%OMip?wAB}^X>YnV%U`P2o_o8Oh{6hbgWiWl>i?}CTmL4zWR2A+D#qgWu8o0> z-6OUaz?)AlVUQ8_Z`;6O?^pd*i*8UCX(Pt!dD|(>+8tw|+r5D{NbHwdq%)Xl#U&w)_TG^Z?3>ryUbo3HNueg`AA&j2SD` zWM>9Z?~DFu1B~!w7wlQ-Z@tl_F+07F&5_yTvBu+s^C9W2k^_NNZVpZ6h&8xz0Y?fg zq>iJciTZM6f0GqAN~wvt7!KeiNjNA*!_t`XPY;zJ$>UkIGDawf(0bDk# z14}%#)q_dU25{Mz9=f`$6oiGm_`cq1x8zD|)5Q8*&n9!3m`_Hvcd8ODbbTp}V-sx( zOb4#nh4xyxQ{3rE0q>Gi+pF|m1#L}8*c{n)yMA_m9BVoXh&kWyBDpU+F^z@&g)A#7 zl~uGuGbO$aURimqs$pkBUbAgI#l6!a=s(r!?K!pm)>g)V`gycZ#zu|(mj`;~VZ$*g zIhmY{1Xe^}xc2mjgpvdDqbp}(okDGSfq5{ozh4Z}jmzy&fPrSl=uovtyUA!E;}6*w zlF4h)+FuU+tk=e;mn^#%C`_J`c(#0C*In zybYC|fHEkB5K{N^oT}dE88r%L2D)VPK z0~Jx&(z4#l=OZl(9~Uw9pWm~jbZGP%-WPY5gx1~7nPBV1mO4;vFq*LH+Y8K?7^;@mQ5r z{Do%t8q+gsd8*Ww;hWjd!0q&Aky%DFJU6bhzkX;I($XXJ-^F#8AD+B7T;G&eh=hEM zgl_78&%)au6gQ1}S;3SWsOHEn!3^MT+x;5bQ%EVf!5O~Y@AayQ87gm_Id+x{2|r7+ zm8O-Prly?#yonk#)^}*-zBt6squ^)1n88do9{AS_ih&;**}rI=2sE&>d|gYcnsdLk?^oQzvt$DrnyHh@l8TQ5|_v1^llaL{u%h&fY0;a z13q*&SoKpc_GRBzCtl7)4^OV|%F8potKW4clru}5S>ntB_QyVvk`A9aJwPXXDG4wp zM$#xzNML=eW&^nB@XgyUMjhx@q$nsW!!hXEPoqka5ypmSJfhhgUJl5_KiIJmRVA8P ziYYDWCJBXl!b!_(+!R^)?HVCA!fg7)(U-Qf+!L<^_(!(wz<*2%>MQ@g3JTDH38v1gaA zYjZIf1UCgcWz@>*peg_r|6gX`ka**% zh6i6%5kseonXzPOJLHOt||&`4M$qgA6d7F*Tn3C~M{) z9vwivg-mx9AU1IAg0AGP5WFNSJS-eg4CI9<#IMT!I4g(sQXKZ$l;EA36)#iq4`3{I?sN{E@v; zWu3I?FH)i~B&kMMvwt-t>2Z_xR;d6W4h-YQ`mF>nM!pWz7VU!y4hzn+vUY1a)9qHs z`rI~O94+hXT+jO1v@NdxG~Xt(PN_?rlGOQO(JUgb5@V++Q- zK}NkRSF_eh?)$8B(nf&e^is}Ej}4xt3j@Ll(9@L@q9smV&jMLu*d2zIj6!C#Sjv8` zDOB<>iTW?K{4_uYu3o^1^9zv|XMtpp{pe&35!3&>kZCvVGAtc!>S=^|Uh)cvN zrh3Hp0i}3VkTDb~GYwWehsR6`#zJC4@2qIazfM*CUjv0tB;};2zbOx?8pU~@;fiWZ zh~vg+Waa13`3tmva)zw)``(-o?ZWy|(ODL#WP3iGA+^;A)YSGt1Ejuq!WsXl&wn_$ z9w*n5d98L1t5EP==n*c$)|d7r{07}0tP#@(Vr$}_51?T2WMMbI(Vq--6Cr-lJ#x!o zxol@Fg+FnM$d;Yqg4JF^l|aK&(rkM4`8}ly*`|buQUSw)&vZk-r==iLKN}_9PHWk? zy&87BzE!+5kwSFwc_j*)mPuwXvDVVE$x6rweAt^jC)d!#*H%!$h2YLLnY}~CI;nge zpOXYmP87!SACjj*JPgw~gL`)wauVq_h~_g((a6M&_mMz3k)r4o|HPw~Ccd{|kqYSV zFFsXKB=KO%tI8rUCB15sQpzl{kpbr^2Z229#M9Xg(qxjUh-vXXSbao;l-2P<`3$_1 zQO9&pQ06(XD4$#J?)f`qRP=czRlesn)Penaj_&TTjD8*!BR%XRITR(42@%rASy7ea zNiNg$+C&F=f+vSVumzOEqWok6uV}9btU*rAML*obCPEi@FA9rTbsGBdc|q_q*^E4` zQKA@jwuMVhE8DzD%vIH?&mID8cDRdsn&g}4z+ukHYTgf@jURADIl&koE6M81yV!Fp zC|t^0oi(xqs@n+#5EgujZTy6_kqMptj!@!b8rMvh5ma{Fv;EV`&?2=`3q*Xmnt5&m zR}VP(v^N~EDshsvC_wm-xY6Ox92Fd({(}#hDs_nAwJnE8pMe!aE7_q8s+`d_4lPB zJ)BDY3KF#3Y?m%GIP`wVIdQ2b53A4r3P}RC{r$xhpc<$}tp2>QPcA_|d0(?^`%tAk z9_rswK#4|b?=kZ?`3%CDIV!yXG#Y`miAjm$!hv3$LNR6eAFBGb0=Rr%P#R!M9=^Qv zGjOXc(D(5q_3;fZU~hdgKgmf$YG=H(bQqG{jALHd=y0ZT)E?!dG*JtQ9xe3ZA%_r^N%b1{sMHBTMeBgeE1;ngNnDTz;CXw8FvpJ5Qke6? z2GHlEO?Y6>|06JGQA@#I09n8dae_#mPNu*?vFL~-gBU2qKY8r)jZJD?u(iPDoiL_G z#v7w0{XCnSn3v*jd3lM53=L`Ig(VV}ixLqCTn(!-r0Q(USI!A?=Ou#ZRv{oLD2~

DqBhNtFRwhjS1>Z@V1;OFRZsmxJeJJQsg6i{f{W@ zT>q8G>p(@36@LF)OPP~Oi!=H-ATL`*>wro?3oz`q@xr2edWxA9QnxZv8-fQd)j*Ia z;G=_*;!xjA>N2Ku5S)(&NlQ**sb5gA-UD1>2k);6$3P{Zh4^8|X_HPMat_I1ua40= zozx+myui{6pa3W=01dYC=^h6nJ%`!@ru9H2pjF`rw|d)7;u!VwbX0GXrMpiF@=n=? t^=etHQHArM63_~`Lf0-%L=|Wg_G&A z%DFJ4gHs+nHEkZAh*@F>%)9=0|9^kib^q?a?tkv<`h2hZuJ4)C^0J3z0RVu!hr5fv zsAGPWjFjkSmfbihYGtV?bdpO%Vt7Jyd{XqaH~;`w6%Z7kkj(uV5YO3KH1>oVzaRS$ z&=Lq6w_ISFe(*9H0VF)g?Ko-aZ#ij+gj6vL2MSS;03(~Dt%mj|jyb%fsu-3$9vCby zJd2VmvRGt#nIOl!Z?#~b=KFY=jhK24HocQHtf+X12B~}!bN+j&u4suAmy#0c7h_>| zZdA4@&1?ZC20*t2?zh6m2LJ$>3=bDa6s~IRTln$AfWvko<^vX zejm{t$dfAvQ2a9Eu+)MqxT>ObI44iRhjq;XmJvD0y{y>adHZ72cHDNDSn`|Z=01eB zbFP-146#1XhxQsFA}r za{3|Te9RH;&OA)lOj?L%=IzDX4-Hfe)rtFOp&;@xz;N%$KkszqAW1~wMCR-&ndFnp zMgW^s0VUyjy$wu-W|R`}t%X_7(tDL_C|IRJ)<3>1TU5rXWZeWgH0^w{dHwNW*+GA? zrnq_D17ly@^QesvS6(i+yRyw1WKs9Fs!mmobql|TU-^N8oC5v6T01lo*jFfdi=LWJ zfM{7^j=C5)PLCKomp-WYoA#kf^D^_fdoX_87J4Y^h3??HySE#6qqLv8o~Pi3NAZ8G zhFk(F8^g#rt-*G`jMw3|zI*Upt8js>E~+NvF9j5%RAREe*Te}JL<`rXI09uqU?<>bzjS{O(C? z_KTRL82df2@YcOUj33i4S+>ESP{oTfR!7@f(@vaFt_nJk+e@rHfexq$t(m=8@hNyQ zZKzUDk$*UH@++^k*RPc||31AT-vL+ZcAVr{7%OF`t~9%q0>Caz&FZBl1r*cVAsJzf zhc?3cJnf+dm_t2-j2+VT(y2gkGlSCK3K8dV1?e*uF#ZUisH~_GJPFhzsXOQ`B+GU2 zp3Ste8Eep(KRD1hKigLdu+5YwiGN=l7>KHV|FWDJs*l;*_K{d!9o?KlNW5FE6|}GO zGwEqk^RKMPm&4jmH6MpizW&-)~Q^PM;09&O@%t~6bvPd}GAI=g{ei9zDF z=w4Ufbf9r|SeuBDn{jLOF)J;4J3sq@jac}@`&61SUbpYGpO>&?X(f|iHGLs{{4VfC z5WHaFG35L%?Xq`2p=m{3nNlk#S3%5?l*()5_F*`BMgBg*c76ciAyP(GsW@l1e08BdcIW z`=q0pmQ;xi=5s~hT)J^rPuX4_5{*ZSc5rE@dl*X?+CFf4X65%v9S~9Qt~on6&GgH7 zEkiv9TEtI?>rd|nyVm|F7+#Uq-KtFZW1iOyods(=11pg}j|-h>`hs6cprg8Ac}bj* zC$PMP$9@(OAI>}!*827LD$NScM4XW&{e%yBzi%Dc3 zg21;t#rqN8ywLdfN2&em9uRo)*Tb0^CC7EvZ1S-`|Ft%O*?Fg*Nju4(gj)+(ox+cW z3)}1@$23Em!Y<|g4Qh$xqE4Qc4ehwSeJ&>H`g$lwPh?|C$kTm>I5zxbi0ckS2#Zbl zYc^jDA=MyJ3hc1Def$0OB}<%h*#)wF2@e)aOG|BoGFXxeE3~zI^Fmov_p4Zz`dQ%H zw@Z`)b*PvK|2BQqSgBgF_@ez3@0IMmV#$frAv!(rgSC~_sh|vI5n&?LbcY2DEogY? z6OtdXt9w>C+8*JcM0VGcU24^L#3nMaQ($uhu;J?f%diKbwxFtbmhbKD9Y%a2N(0&+ zNBmb1Y2vTz|xr=+yM8pv{xS?V*Af-=W z_K$~ZX&>uXQE z%46_7WOE^TsBy2uC2CnSV{UHF?!-sLW-3+qf=|wh3~}%F7*jF4_5y%64;dax(d*)7L0~EP0#t{N+7DhbbdR zH0`@rCx0kowK}3LI+y?+ Mu4i1DoG_XH1>Lft%m4rY literal 0 HcmV?d00001 diff --git a/voidcrew/modules/xenoarch/icons/dig_machines.dmi b/voidcrew/modules/xenoarch/icons/dig_machines.dmi new file mode 100644 index 0000000000000000000000000000000000000000..9831561bd3262388db61c7a164da9b3153ffa44f GIT binary patch literal 1434 zcmV;L1!ek)P)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#a*U0*I5Sc+ z(=$pSoZ^zil2jm5skk6DxuhsRH?<_SNQsLxC9|j)C~U~ZnO2mTn+joLRUBVXl%Jeh zT%4Jgj$NNQP&73su@cBM(m{eajLR=b&BNsYWd&D17qIsLz_vtIT^nh+000ERNkly=&V*7{*@*)pk;YsU7TqOPAC`NEdgL$&e|X3Y`m?JQgx_${&y}oeEtFo(h4^ zSxX6})O2aa4o)bT8WJaq!A)bgB;{nw(Z}h-l6+_ReSeVXEXnssxeuL`_t7N|4-bKm zhoUEHwc3qH;d;HEFn-3WKP~`8u zxJyywkmx%WKt7-6E=7??qVHG$gczs*BxAY=>lX=`l#uD03czV}Ac_V;ecLcp1a8AX zZ2F4mTHtk(4P^SZ0$`-mX>FOwWK8ROyfo`-(p>+Q3&n->5@Cuf;0H`!M09^_4&MXR3tt0S)a>G z&!WC-0km2z?Z-Mia~n~xZF)^Hri0KzpwBG;r=e88--j@yQYl~ibrI<+VGeX!q!8&V zvYv-Hp(4{a6@dQ#P+7DQ>T|mwuM3AhHlE}=PT#|>ukbQDKX=cpQmJrji`)0Mwx0N^ zVVzFL_7Kbp8=KFIexUmRHxZd^#ZaGH0LkQV6u=&&@zy&vFPVLKA*TASEx_#$Zsqdw z(sQ3_+?B>%Ke8@pnkP5U`U_0%i>xCXQ~AlA3VvR zg-}01{s6gXq>@$n2p4GmO%`2*%AKA#8q15)GZ_DKa>6dOf@hn$R7{^~)1=ldy$f7f8@TKuGW$3)T50Z|sNO`kV6HzOM{kv~B4 o{0Za_kUW0^%^v`_$e6$2Kl2Hao6_%4IsgCw07*qoM6N<$g8L7iv;Y7A literal 0 HcmV?d00001 diff --git a/voidcrew/modules/xenoarch/icons/dig_scan.dmi b/voidcrew/modules/xenoarch/icons/dig_scan.dmi new file mode 100644 index 0000000000000000000000000000000000000000..236d0c8e74b5b839cf956bf44dc7ef9e8a1fa5f9 GIT binary patch literal 1351 zcmeAS@N?(olHy`uVBq!ia0y~yV0-|=9Be?5+AI5}0;&9}kcbkO)Z*l#%z~24{5%GR ziaEhy1x2ObzXTV2{Q5-8TUYDcne)LLLJck&KX{~b-beE!L!nN?oSrLZzVrxbtly!s zQAPAb*eZ{+r~P%j&UNUY)A0;_^vLI=juu;&bW!0lQPpS5oNk=rT34r+z|Bx=E&FuF z=~KTM7+5BIx;TbZ%z1m~peM7VNW;U}jU1~#JaOE8f$xZx&Oxmv*$nZj4=JK$f|c!! zi8EBPUB2`FwBy*V5_#kH`P(y7W&vG43WiDuGzgonuVp?^w>@0^`qMvOAHV)PBfGvz z_QQ7;38C9{c6pC0zJI8>T(?bsRcXcJmcGDhgFYw7jY+VVB_iBS{VCF zqH15wthIlg@|bvmHk?wkJwEKHd_2l3eD={8~7{W6he6(_}Ax zjFWwQPs-4MZTAAjjjFHr?wzmEY4}9_fWd?F6Znp9^Eo2t>>Ov7=$N$e-fgyRbGu%< zU)ulkw%3DtP6M6=cfaba8!AyU>Q0mpI8f5GkMXY9#KQ{<1%HBMJYD@<);T3K0RX21 B;3EJ4 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/xenoarch/icons/dig_site.dmi b/voidcrew/modules/xenoarch/icons/dig_site.dmi new file mode 100644 index 0000000000000000000000000000000000000000..f75c1279768d9766577e71ff2b4c58935a5452c6 GIT binary patch literal 9125 zcmZ9SXEYpM*Y-y*QG;NV(Mv=xVf4{^O`?+^A&4@19VJAf6C`?cQKR?XB7Z$bh&mW0 zOfZZx#_PV{_r2HsJYTML);a6!b3UAP?Y)2N#2XuGQIZ460RRA{j<&if0DyNl1gw)1 z-{~LKHsiYqo?vqeUv+1wqmQeXudC-903a|o?`Om7K`|QQ^HrL!cEEqD^mgwXED(G9sdr`zLjiUDWAi=VSxcc`6A@5a$w2 zJuWkm_;;>t`A<089+0HtN!INx(h6jFec6vAc7Gth5em|; z^Bb9BV^@U$2c#0`7~>bH{B!l$pM$fFzvyd)S)Ebq%*68jq$$z1D*w`;ZbD`ywQHgK zI(m#lM_yOom1gE?xeTQ%ZY*O@jc;5w*7b9&Y|@@N1PDp(6F!){wu4)H9)_LEy+T1z z?U?I+M1H_zkmp)A%FeB>d8+67>q7`BXAI4MR$&{b_PJO?EtN3< z8-QX>GS~x~Wp_1^!v)|lR0&V|9pWUC2QcH1FiLPoCNcLszOqKZM|pGm-0Uv(>Kc&j zH=W8?$!-yty70rjf}5$iC!E!eT@HH;KSk|uYdY4CP;Fhob1WF6$*JNw?$Zf@mstN2 z%sV*%XBORb>}`AU%_N4}=6VUfcR;OlHJ{!av3%2O!T{V;OEPJtWW~z^Fq0QAj)5dX zJeourKLCEL{A;DD)V#%GWy)B$o+iHGwsa4&I(jJH;eFH!BP>ihxXd=O2Jk z2@vkyIA1iIyj}Y+USOE&TqX#yxU1;^%sAo0S7?9o?Dp-HqOW<0zGL*C?e z&C*gN&p@XX72ilMJr>6yM&VUvM!7#0REJ1S!J+$Mg4_}An`ZDQc3D{wem>aRfG$aE z-9k$HgdV4>bTFqo8z+c}dLD0!^vn^nan>J!EuS<|soFSLGsBH&LW(Y_o8l8=+;g@* z6C9gz1POn{N$EtFc(@bJYmqzUJ>Mf0*w&dgE-v7mx=&O9s8+DQu~h+Lm?&V5Fnbwo7FQIf%7&juD?l2B21jgZ z+!n9r`W-QE^9i+)DM2vv>D1xEGsoWPF{(kW433DolJs1;>vV(OgY$Q9uLLLSbh?){ zRTfm6;zLanKo*GB-^ZV(p32Sm9SLpOn-Hk2&~a{*Vb-LA6n#*s{ptjI5~?IqiSWe= zyEyj4<16e`K>@Z&1KH`vW(|%@0-9<TqfGv~ zv~$o93W|*wKi8{jR92i5kopF4*mp@XG4)ZCx3)v>nQ^nso5A7o{J!3y`1?_7GyQ)2 zDIx0@>jIrolfreYrk(~BN9{1~rsRkjs#l=)FmB~3XRMGR4}A#s9wMvY_e^q4UFgU~ zZmDNe;c-Cdo!E>}t4$XNkCClg&5fiY&(|)mQ47FsM&`gn zuiKlou?{IFDq~iwAD5-z9vVMEj@c38n2fp04aql~gisBEa3YXr2fXrMUT(-$kmSB)*SNv#+6JW?BR{z}?f0kYJcE?1YHmogk&c+_>=E+l}V zzi(~yB8Wr_S|>S>rt8Ys^&0D0)@A1E({#AJ(oLoN8ihLo0Bw#uBw2kW;*dl#S_3-0xU@#fm^UOwL6EsX^)5%4ks-Yyl+JN7dmD7yWqWkn|oV~*oq z;-Ip9C2Wx|D7J%MS-Zj7~+IP(X?s#Cl1R+PQp@tm7FibDx-?>eIK-BTn!+5wfR-Xj9FFa8jRhA zsLCDk2A7tok(y$XWu$8T`V#8DDOF9i!i3YEkNwlws_3R?rit|GvfUYk>%Q;;p6fS# z%C*1V0Q#Mbh04Le-71HKT`OkCU><%Nz34~Uk?7`awyakx^cg8%>`AEJ;$(iq7Y_$s zSo2T3`KE>!La@NxJO6AdV)yhu+1Rc2imfZDw(Xzfo-+G!Kr`OKz>P8Cx*W*|_tK`G zJX6P?8Pl0#1~{2B)H}044WZu1iOUAl)*Nh(_>- zr^5W@nh^UEebQxqmC!56Y0|<a@&20r&+C z$*eEkusH(mGHi1|?@aRCQ*y>~1R7%(5<{r&Y=6T^=3Hi+wMtP_+kCPP$O96Kj8Gyl z6l>QZH%r7qSiu<0^A6!*v-H_PL) z;{42et*4;PKVAOQE{Lf}!)VhTwRNUWQY|Yqew_iThVP6SEcB4_g!N1AAZv<%{)7|8 zn5%%2gv0YG-0xlr4=@5fQp9t8y0nP7p0X}S)HSypl z@B{M9m#TMe z->>q&@1aS2$Q-jQFa2@=~7>x#c;G zP@q4ges2z-Dv$3;-^A8ypf&ALc)ZUy;#oI3pRqh|Nh&8F59_*CET3+o+yX>5FgAmii50sMXUe!va5eP#)jRlmOp5mwu9m zc}g$;o`>$rHb-{NOwDPYYIe6!sb~rtJ^bxPU@eMbrNB?K@&L#Bhs)r9n=wiNO~9=%m3jGYOXgxI^6e-EM5$*jc>H$sGWjiYq3NGd?lEe zkBpb=%}vj5m@+R{_KNZ^6{0VLZWz3?eg!vwTz`Jh=SA2+t%xS#N<*JWB}dthem2rOpH00u`?NSzVl@6r7pBKxFsmdk=7Q7Qw;VFlQiU zhcvoI-QUG@3L1EhX<@@Rr0I1dVS4ZD1I9ZfRWpjKo|}Ve+fw%LV8nl+?^CTmFQ*O^ zEHpgRt-+yl9hUoRXEGhExRwT$P;lf7XGa3z0vTGES~!pvuOjDoYK9q2N&^k&O;E9W zPTHW=>3<@NbjtR{WuG1Z&q~2~q6{tR8}>KfSAO+UzRqe5NDSBX@SAm{b6B`W~YBgPQSh2L-Z zes827c!9@q`?>-rzK$lg!w7q`+QLbno(_H{#Hr>m zkq>Lv;NCh&<;7@=cI&(|c&qO#*4 z;Poyeh(?H^6sg=HfcFoV-RtP!acNXG#TcGT(BG8RFp8Rt`%4UgVM3)_RGV)xS2or+ zy&@xZ3_ea;79nh7XNXa1#;kYyjs@x~>=nr1n8sB0?(Nn!B{@xUb$+pF9hJJla)C{& z7rKy2Hfzm5#qS{@{o|7roPN2ieVoDCo%1?zdh*&l#xH!P-`ulf=mV=UGZ9`s(hw(s zZ!uU!aq$g|PveyV1oM8~58;}Y#%fvEmlL|bZ_||nfD1j&VvKT?w7Sx>q`@UV}7di4-ArG+P2R47CV9S_-*}i z_>YOEU)TF_fdZR##m@7~1YsZL)2N?8Zc?~mHf@G1K1`as@v%L%+|l>b^Ox{+^U!xm zve*yr$}rkXk)CZ^ecvNuTJ$}G)!V#{PmRjInoOVWD95%OIp@GXdBi0#`{xO~{9{u^ z2YaQ}1Pm_ISgUn}eAF>arD-n!?6}R`gdHBje)q zgbBz|H*GRSJmMPa$SRVJEL{|^=MSy)+a#jZYg+o-ew+ZzV}maNdl-p+rBZrql0{nB z#;|I_yzHl<10Rl^yS~G&N=r<$Das6t?!5o3bm=O=U&4l`bUJ&Ahg@J|->)dhbd6in zx=L5$c{zwT#`19#ED_C;9y$vzOdhDQmTuzY zr)H5=aJG!mcOMOP+a0CM&#|cUMV8|YLUkeATLVGY`rr9>_f$ssBsT!fPrHzSVba2+ z`3Ec~`)Wn7KClmSK;v%o-Y;@>#CB|ulGm==BEGKaI>5HyyTAyth^47tni z;h+WnfV{Y_2>BCyB(vD+StmbxFvXTy;B{X9>U{+lLiQOU%?hmmPc*AFodZggmpT!V za*XuBPb2uQt0W2}{pSkN*2Eh818+wLK+QsItu+(J8d^c=6Cs3#xkg*muECM{#Yx5H zifH4CxsYSD+_BejSJd4{I)@x*d8ZwoRlV%HzAH~|NX#&< z|y09Ew&uAe3RjJ$k3fc6BdNglehh~l~y2*(M+96?y%)|g|0wC zGI1ebQJ%w*@Jx-ul9U+->bMR&O-A8$-&l0_7~DnzZhtWL_h_#w?JJSE*^vi=uq#z~ zUPPre@gvnlgGN&pu>m&`xtw~Pt?pMuAj{x6Gu}^~*k@``!0vjHA^W|{X7$VwIun6Q z)pGZo+^ARBE9o8j8kbmg<$%zg#wU#KVQ1PP;2Bgd8vM!22&k7bC6B?z9p0lM6t1Dl z-K0;&Rf~qR{K%cUujC+vcgWmTe$NYur1B8QWoKA0YFLP(;PPAhAxnQUsMv?)!M3cw%FDL54gL)b#LF`w|y*ocPE;-BjYDP@IW z0x?eYFqxM^iNfu534o_;ZDdw|RkYdu@gp=Y2X4{og();0>&aU*Z?T@N+tJg1fQhLt zmOaY5|5}0H#Z09WYdzLX^N=U*P>|s4ITpz^vG&o!DH-3`Uhz_>AYd8nsX!b<>ZFep zVQ9BMDCwt_3Zs8uMHut9P6XI@Q@Kj}zKxSj1*LXQ$OY9ceoTqj0QkyF3!5#eJ~$uZ zlQmli-%8V-%TqePbVC*(Qg?L?czCo~f@X|#oVQ-wi{$X8*CW{<2xPhPpCQw?#1vR_ zGEUYTQkavDmsum>rQ9*Og|f}rP?vq|XWi&gR)-@$P-W8eJtUx9Y~`WTLiM+iz`H^Wb&t=AwEPhCj2R}`-HET#-PZN(+4^s4G z9iq?m8&~_$2MUY;QBCU(jplDIszM^p+q0D6&8n48Qb^`u@4&iJticK*8C18l8}l0P zEVud{XQ#zGfUuDXH^`;2i0{oX^Vs_yWAM7%U~am~nk;%1wA|I=d*c|^r`b(u9~F@*Gn~c4@5I4LL3A4iXQK0 z)rPp7eBIwWRjy}ecI(jWwB<&$oPnenAQY}cTJvUc4>J}!D9vx;tS}Dpiya>%=iOL$ zs*m2t7gDa1=3Zf*Vrt#kHHm!J&9a9l8~#OM=BZv##QXeGs|YD>fpJ$<-p;iDV#ub^ z`*J+R%fC~2lxPNf@S}+&p}wQfZ)%3>)ELupDCX(M|6jiT|Ed1?pux3$fPDB8&I5$1 zagw|Cw>z)s%D?D4Z@_hnTHu@|XK)yshTLYXpwV`1tiqq>>HC$H*mj8KZVi9;b(ud&XK*#Bt*Pt|Lr4@OicjhOTHHfDzC-#sj8;iO=l0Pq<$M92?94r`fZYgfGH67EL zMwN;bvnSkMAe80!H3k0-<3sLnpqLob@38%wK<6z9eQSFbgFa46674bL#f7bxZthI+ zXF^F(rd}NA9bo8JU*GUW*KZb#pP)6aJfk=-9_dxmV>_X{t;cWwr@QAmRT*DM59D6fPl9tfzrVdLD-E?@< zwp08f`$gb8@L%%Pv;N-8k2cQxXaYF;wmDVxXm=Fhc5UBNw$vNe<#U0|nPu5aNzM!Q zJ^^Uglo+}&F0&D`tQEFTD*e-QxfFaIx1Tt*QDw`>;F_$A8=jT^$uNP) zDUg%=HgY@G&d66M58n^q+m|BY$m^;t4v?m3zm>PYjJ`cdur5#&I5K#Usjd9NZ|Jw$ zOYtgDv*oFV?^dpUWEWTL!qXRw2MMWwYZD>*j`F%1yE4~*h z7VJiWlz-V?Z;@3uF+XBab!H>l8OVKva=0u3$ha0kn&DNTu}tJa0hACkK$9$O8}TkrwK`d!zOA z6Tx)yYE#jkOQ|S9E|FS+hReG~ql41E;048|-z_2mhIn5ustL%MZ3{-2KEQVA$86*Q z;saq1DUxg2?~oDTw)iuid((4}@_h*&pd#`7Zi>>mL7S!cZ_}9BqO>&%o|0Bl4s@dO z&BH}nuY5}TRvq^@#mNkjG4xPCOELVP4p9ixpXPYDj8v$E3rYQU2&N#^VeY#ON*=G@ z4NAzyX-P`rkjO+AbR`$q3|p^^u}i96cBcKpA)(xKB~xg-wCr4oGM-s8uxRQ?zl;kOm%3}XobeBu8kr+|7x2a}Sk0~ItH}KHtzhD5fY$&+7FG2qQB#@Dkz3GLlu|hrhd7Q zc>P6R=put#pdHlBDF^dBH^-r#Q^D4VI3x$}?tx5cL@;eB1pI9hwep2e&P3GO zM@T3ATyv6V6ugZ;J9xHfabs|IXy_y!&9^@HiBh9g_4F>`qLb1;^qfQYw=pK35Z#7( zjRI_k--@`nl1th|GrjFeZDFWfti$X`&)p{bfW6`FCK67MT`3aSq^I=wFsJVm zVFO`%70Y~@7kRQTo51xvMKdu9PkOT~?S0biY%hkMV6-mn@sZM*( zq7ihC5$8Xk>24?Q9leQtDELhV$QI`b40U?N%}7b_*OjgA^AnH?^N z4VRs=zzag2ZxU9WnOD2%a-cN6P;~KoYl88A1%|C}h=2F6;^W5~?KZ;q;ayir0J%6a zB1r0%I>A90*7@`3|Vw1$AK1Ql)OUBF8kwwDJcJ%URY%L!wo z*WycXIUXGy-}Z-;t~N4zB;kw>vBwGakee)#V2|kwnH4up93Ne@7X&;H-wr_$pcuFP z;}x`(#qhJm9RiC|&#kqnI+Iij(o-=jkHZ`!Kwc;Ipx5pR!8q04@4cEg2UK2gB~%^l&5?qURNya z>Zou;q;4(E#2;IK&M>%&h5{t@-SKH+sRj!YzKbet<0!cRG!jF1zOVLu?I!jHsTD5C z?=ZnjkV`-sPj~dIJ#-^KZ{nA8Dq}O29x?a4jdIj+Q9+^n`4{Xfc>Yo#NCtztl8VZ3 zoG(HAx0h(7R)qhs6Rk;BLTzK1+2bhclQHWb|53ynZS3XeINP%iuC5ZHL5QAI{uro` z{Rh*h(`SN8hZS4G`R&msj6@F!5-Tax|6?cLToOhdYqPnBEk84a`F{K4IJ!`Eyn@7- zv99Ra;+bZ<9u%klqj0s`n>dWO9(SmwTTf$8!d}qxW)P++584YWnpkPQz1{DyM!C04 z#}+FKj&FuC@4~5{s6~$$1t$W^gcHp(B6>ZlhUp~cyr>+`cK~HK>|@wQSwR4&(>-56 z%p);hfaLdPIBsXY7pT8ekot*0w)d@j&^eTohEtM|ACJsZAsheBFsA?I8vhor`zbU7 zIx3I{v}ONy|G$IWNiHEz%-v#&M$BFGdlYQ{k-I3IcZ*K64YVqrUTXg;=Sn`a-;!)6 Xe-BzxeZu{(UpP7%hU&Ethp7JpBNoHU literal 0 HcmV?d00001 diff --git a/voidcrew/modules/xenoarch/icons/dig_tools.dmi b/voidcrew/modules/xenoarch/icons/dig_tools.dmi new file mode 100644 index 0000000000000000000000000000000000000000..625e047c193852db5935a69b706f924aac1a3fed GIT binary patch literal 4816 zcma)92UL^Gwhn{?h(Lni5d;iKRir400w$p&O^Q+kDT-26nunT@2q=OyK|pFi2p}y; zFPb0{b z0o>M&?BZp`%Ee3lnqt9`GFiE$prY_)b-$_!@14LX8OcZscKZ&@@jz2>BGuBO$lM0SgY>W9tu zStgaB`qWTho@V_pUd{UZ@mrSRv@DvxPQyC`1N{ElR&fRaZO`YSI60X>V&B`SMUYkXu=IYb`OSA9;`eqvUd4K;5u* zys?52iO3)`RBw8}W=%)*?n$KCdcqFYa}^4I%2vH>NoU*hEq@wk&$fp;)S=XvBT^S4 z>dVhY@7^A@$56)8uk}N4_S+9G-15d3mXr~}ulSzwFt)1McBQ5-pipQz7QzsCl4p^Z za|s68<1+A7R#&h1_|d{YAYimSMS<{n=e0bIn=NZmXKu51;&Lus`wdbbd{kKKe6gU! z8RPMkzGa2{A7d{r7r8&SO?+w|aB)1v_f#fHafZnq9>1#(;x{B6bYu+eA71}MdN)Qv zG9A}t&W)UAkX4Egz4ml<&G&BG+`L=6;1}}+gxvgN`d$>SA}e47x9{H-on_$f z0ocn{@neRVn4;WGQANr4?ZxP`NHqm2oJ9;6o0_W3iqZ7yO|GH0E%?EwUr%|$VAN~t zzZ#`5z)Qu)o|l70u>jGkN6QfF6?;R}8IjkMPt_E}weIJg!SXELGX4^s*JK~t(b2Kq z_OA6#7|vYemavgu@CXXnVxQ{`Q&aGTW^2pl9A{9l)_qw13fIx4d3*F`QSyO5b_s57 zZhurjee9Dio_%)dL+)-hU(H1CLR8o4W8<<>MrLBw`@WTcrB1Eaf5?082thFyZ?K6R zi<4C@k2M5ee&J&yy0K!%WGZIoT~RhU`I2m(>z8;eJw96BYo#-$oGiM*m4Y4gg<@<$ zm7xY^nM0d!AS6~7&KT1NErY4(5NPo|P|X`CW)7I`X-Gv28-SFFz_2~Ab;tJ${_tP9 z{oHMyFkWb}(Gc+C9$K2sv|2xTDoVcq*vpWrcV$S4L1pSw>k#WLqs8|5KTsH|K(6|>o63=G_aQ~HT3^uT12HEOEu$&un^GS=lN>2_pi9pd|dWXHhL+RkQC z0ZX-k$mpNRD!u&pn6HgXF1aNp4082|+TVWxSw`rqs2npv&T%!@=>f$SJw zE4}d;sz!LTvCsCayVA>_CQ((3mx+3S@K)5w9K5{-Q_xmV5$Ph%l;&8a3hrjtc-Vs~ z!>0qsDCN^Q>yIkEN+p++!}!5}F>e??j@sMKUYa-Av}+6ue7Xl6$`X*Z4$&)gu9VV)rqr~YCU zhG*o9KZpcn&UBomAeEO(S>*RMOxkOqmw#1vA1tv+J9fIeA)RAE4+;NF8qUOJPHzo5 zxw%AKFG*uy4&!#oALAk;OCqmF$`-jc=k;F6_vo1+Wj~LDG`Als=SPmQm(yF|lPICn zzCUbhFN9Gw+_rnZO=svdg{KiihRm1=9n8Y{@vY5we&#Gxr^-KCtj%|1>Jf&yH<|UF{4Jt|XUceVImmouoaOM`)@4F!UHy zs6V5g#0Yy-979eL83fIb+}7w@OSUV!O%1}v?iZ#`S z4276R`Wwmbti%=vCMd7s#J3REo3a(dk9s4q%Ssj>l*#DsS%jANmMdYx0xEfj7zbix z+mIi^D1h^r3mVebBMN(}76XgfAAt8!IcJqVu@fVZbU0r>*x@qJ1Klu(!_fo~_O zFwId}Muc-_Wd}DX5922D@MOIg3BZapg#HnI*!7_81A$+A;fjNjd!pmNUMbo#`uLI~ zVQyp8#`TIO5o!kg4;vm!2pz2!i|GEkAUZOc=)1~lyroZj0$Mihk;ABK&=F;OXJ(i4 zd%?^HBmeb7d5zR=4$+3 zl=!z3|1F3`=!>R<9wGEYBo=_YS`cBzRvl^-p%32~=FS80A;$>n8|oAtPetIs$OX{$ z^hs4=_Z4FIK_oPx>*FisRibP^gTBqBmlJ+!g(?S8O~c>0Hi1jFgt^($HV!#5HP1qf6cr0&$Z!_3qWr0p&wS_ z0G|6?Twor~WezM31{iqi|F@v*tNQo*d&)`bt%?Tv2B80xqGTW%XgXR3M%{;c&Tdba z(Sjiwa|@sNjA2DK^dODmPO4Szb3FA8UsGkP(}B2{5{?g}59BezH|;*<<+pDI%;(Au zF%pZH-kPrwdxVg-VzmY!7j2{b^USeS*nD$y^Gt+;gTpT5=+UD;QBR9k`OZQ#zziAN z%Gs{g+tY?#5Q25|w;$5u_j=mwW+HYj>oH!@x39ll{$>WD8Kb^Rk53<=ZH)?Zf>OL5 zq1&3P`(3;F#k`G0A8nk$i1Lq)k{Va9eg3eaF-N(>YOdxS>#-!E+8PttZVhFQ31hD? zD~L0`en{w9t7t_%xygty8n6(gTsqj=rA2Kob-yae6hhw*sn^EI61!87 zDz8sVVu(RH*{wJ$M zB*LRtFlJ?K>63Kp4A9Zr-qt!!AZ6!xo{f(Lj&|f1>magxZ-ZEom7=0n>eD&tH=pK_ zSN0BWHr!RMR`%`#nw=hMOzouC%J+0d?VIoA)4zWEC(I6r9H}Cg)T#2|UOvB%L!FQv8iq2p> zJ*|H?&Fl8B06xLu&i82*4NKl8go_!`;(bB$;XyxF9>cb|ush!bejT4n39T=hyG*@K z);2pL!$WiMqIi{TWex*OMQ`%gaHc)j`SyyX*x#I`=}^HdHsTa2iTW(-LEyy=o;#`^ zUHW`zX&^s^EqWfg05m;|(TFRf7py|`!ur>5@S#$-8lS4Vw)nBsJfYXU?unw{Du7X^u;5 z54VYgNY)T3R}Jl)X{`WiI+!P z8z3ki@E>u;DPsTj^yR0ugmnR4LL>|!G=jGA#^46Iw2f{h%*lsxB-U+s{g?qPPgI&S z^crRz3Lf+ceV#^pzZ+i7RQg^t=xqjBOWW|EgzVeAgJK3!VG|G{X=4}jC>oaU#BfI4 zMyYJTMfMQdIOu*~D0C*nR>ZDek z+~sMv$Jgu(vaV&+rMRf*PB74NyazRuv)e}}%b zJv=5;5Wo%IH5yuvyGS#`H`1xWjgJ(er+WwIY!adg(y9rb2(a%V*$?DT0eFKr-a@zR z(Tc`-0}WE5s8W;M!=xmEty^4gue_H`=??wLxrE$yie1RF<4l?~arr zkGDK`(V!D`Xw+A?R3c4E$8e;c4p25e-bF`bf8O6N04#5==DiAd`04A5UKa#! z2`rXgWhz?L(Ze=p{TTQ)9Kn6OqaxxhR!d_t!3(r2bsZX11!gA+9SD5)>lYtpyqIgx&4`_UO{o&ku!j= ztz6U2dAT+2LBTqh!Kc@DBq;0^@n}QsW2kdl{7yz(_0htWZdX3&te-NSXj8;2>Y6rk zfqRJHOUd(z(?6@cH%*Rc8Xct^;P`F0^Eg6j>HAxo4}ot1V1Hl{`|3GqoeWq`V^Yj# zzWMtaU|-;wU!C}gxC!;Lt_Z*snsDH Date: Sat, 7 Nov 2026 22:07:12 -0800 Subject: [PATCH 32/72] Review the Polaris artifact effects Harvested copies run from a carried battery: effects name what people can see (the artifact or its carrier), and harmful ones never turn on their carrier. The gravity wave never throws people into lava, chasms, open air or space and flings at most 40 loose objects. Berserk is logged. Slicks show their colour, alert text is plainer, and every burst has a visible moment. Tests cover battery holders, the ledge check and the electric field's targets. --- .../voidcrew_xenoarch_effects_polaris.dm | 87 ++++++++++++++++++- .../xenoarch/effects_polaris/animate.dm | 8 +- .../xenoarch/effects_polaris/berserk.dm | 4 +- .../xenoarch/effects_polaris/disarm.dm | 11 ++- .../effects_polaris/electric_field.dm | 11 +-- .../xenoarch/effects_polaris/extinguisher.dm | 18 ++-- .../xenoarch/effects_polaris/forcefield.dm | 2 +- .../modules/xenoarch/effects_polaris/gaia.dm | 12 +-- .../xenoarch/effects_polaris/gravity_wave.dm | 44 +++++++--- .../xenoarch/effects_polaris/life_drain.dm | 12 +-- .../xenoarch/effects_polaris/life_steal.dm | 4 +- .../xenoarch/effects_polaris/poltergeist.dm | 4 +- .../xenoarch/effects_polaris/resurrect.dm | 2 +- .../xenoarch/effects_polaris/spines.dm | 6 +- .../effects_polaris/status_effects.dm | 31 ++++++- .../modules/xenoarch/effects_polaris/sweat.dm | 15 ++-- 16 files changed, 208 insertions(+), 63 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm index 7461949a10b..9a1f262b78e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm @@ -97,6 +97,62 @@ for(var/obj/effect/decal/cleanable/artifact_ooze/puddle in range(4, home)) TEST_ASSERT(puddle.reagents?.total_volume <= 30, "an ooze puddle grew past a cupful") +/** + * Harvested copies run from a battery item, usually in someone's hand (the utilizer). Every Polaris + * effect runs from such a holder through the public API (wake, touch, tick) and the raw hooks + * without errors, and the harmful ones never turn on whoever carries the battery. A folder stands + * in for the battery: these tests must not depend on another part's types. + */ +/datum/unit_test/voidcrew_xenoarch_polaris_battery_holder/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + var/turf/home = get_turf(find) + var/turf/carrier_spot = locate(home.x + 2, home.y, home.z) + var/turf/target_spot = locate(home.x + 3, home.y, home.z) + var/mob/living/carbon/human/consistent/carrier = allocate(/mob/living/carbon/human/consistent, carrier_spot) + var/mob/living/carbon/human/consistent/target = allocate(/mob/living/carbon/human/consistent, target_spot) + carrier.mind_initialize() + target.mind_initialize() + var/obj/item/folder/battery = allocate(/obj/item/folder) + carrier.put_in_active_hand(battery) + TEST_ASSERT_EQUAL(artifact_effect_visible_source(battery), carrier, "a carried battery is not seen as coming from its carrier") + for(var/effect_type in voidcrew_xenoarch_polaris_catalogue()) + find.replace_effects(effect_type, null, 3) + var/datum/artifact_effect/copy = find.main_effect.make_harvest_copy(battery) + TEST_ASSERT_EQUAL(copy.holder, battery, "[effect_type]'s harvest copy is not held by the battery") + if(istype(copy, /datum/artifact_effect/gas_vent)) + var/datum/artifact_effect/gas_vent/vent = copy + vent.reserve = 0 + copy.wake(1 MINUTES) + copy.current_charge = copy.maximum_charges + copy.touch(target) + copy.current_charge = copy.maximum_charges + copy.tick(2) + copy.do_effect_touch(target) + copy.do_effect_aura(2) + copy.do_effect_pulse(copy.maximum_charges) + copy.deactivate(quiet = TRUE) + if(copy.harmful) + TEST_ASSERT(carrier.is_holding(battery), "[effect_type] run from a battery made its carrier drop it") + qdel(copy) + carrier.fully_heal(HEAL_ALL) + target.fully_heal(HEAL_ALL) + carrier.forceMove(carrier_spot) + target.forceMove(target_spot) + if(!carrier.is_holding(battery)) + carrier.put_in_active_hand(battery) + + // The harmful ones spare their carrier outright. + find.replace_effects(/datum/artifact_effect/electric_field, /datum/artifact_effect/disarm, 3) + var/datum/artifact_effect/electric_field/field = find.main_effect.make_harvest_copy(battery) + var/datum/artifact_effect/disarm/pry = find.second_effect.make_harvest_copy(battery) + var/obj/item/crowbar/bar = allocate(/obj/item/crowbar) + carrier.put_in_inactive_hand(bar) + TEST_ASSERT(!field.arc_to(carrier, 10, FALSE), "an electric field in a battery arced to its own carrier") + TEST_ASSERT(!pry.pry(carrier), "a disarm battery pried open its own carrier's hand") + TEST_ASSERT(carrier.is_holding(battery), "the carrier lost the battery") + qdel(field) + qdel(pry) + /// Resurrect: three lives, then spent; refuses the long dead, minds that moved on, and the absent. /datum/unit_test/voidcrew_xenoarch_resurrect/Run() var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) @@ -237,9 +293,38 @@ TEST_ASSERT(walker.stalk(), "a loose artifact did not follow someone in reach") TEST_ASSERT(get_dist(find, quarry) < 3, "the artifact did not close in on its quarry") +/// The gravity wave knocks people down but never throws them into space (or lava, or a chasm). +/datum/unit_test/voidcrew_xenoarch_gravity_wave_ledge/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/gravity_wave, null, 3) + var/datum/artifact_effect/gravity_wave/wave = find.main_effect + var/turf/start = get_turf(find) + var/mob/living/carbon/human/consistent/bystander = allocate(/mob/living/carbon/human/consistent, locate(start.x + 1, start.y, start.z)) + var/turf/ledge = locate(start.x + 3, start.y, start.z) + var/old_type = ledge.type + ledge.ChangeTurf(/turf/open/space/basic, flags = CHANGETURF_IGNORE_AIR) + TEST_ASSERT_EQUAL(wave.safe_distance(bystander, EAST, 5), 1, "the wave would throw someone into space") + TEST_ASSERT_EQUAL(wave.safe_distance(bystander, WEST, 1), 1, "the wave refused a safe throw") + ledge = locate(start.x + 3, start.y, start.z) + ledge.ChangeTurf(old_type, flags = CHANGETURF_IGNORE_AIR) + +/// The electric field only arcs to machines and to people holding something metal. +/datum/unit_test/voidcrew_xenoarch_electric_field/Run() + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) + find.replace_effects(/datum/artifact_effect/electric_field, null, 3) + var/datum/artifact_effect/electric_field/field = find.main_effect + var/mob/living/carbon/human/consistent/bystander = allocate(/mob/living/carbon/human/consistent, get_step(find, EAST)) + TEST_ASSERT(!field.arc_to(bystander, 10, FALSE), "the field arced to someone with empty hands") + TEST_ASSERT_EQUAL(bystander.getFireLoss(), 0, "an empty-handed bystander was burned") + var/obj/item/crowbar/bar = allocate(/obj/item/crowbar) + bystander.put_in_active_hand(bar) + TEST_ASSERT(field.arc_to(bystander, 10, FALSE), "the field did not arc to someone holding a crowbar") + TEST_ASSERT(bystander.getFireLoss() > 0, "the arc did not burn") + /** * Polaris effects on planet ground never give a planet turf private air: the gas vents, the - * extinguisher and the force walls. (A real flame writes the air itself, so none is lit here.) Uses the shared planet air room from voidcrew_planet_shared_air.dm. + * extinguisher and the force walls. (A real flame writes the air itself, so none is lit here.) + * Uses the shared planet air room from voidcrew_planet_shared_air.dm. */ /datum/unit_test/voidcrew_planet_shared_air/xenoarch_polaris_effects/Run() if(!planetary_atmos_shared_mix_enabled()) diff --git a/voidcrew/modules/xenoarch/effects_polaris/animate.dm b/voidcrew/modules/xenoarch/effects_polaris/animate.dm index 17d22f7b658..99606713e13 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/animate.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/animate.dm @@ -34,10 +34,12 @@ /datum/artifact_effect/animate/do_effect_aura(seconds_per_tick) stalk() +/// A burst is a short run of lurches, a beat apart. /datum/artifact_effect/animate/do_effect_pulse(power) - for(var/step in 1 to clamp(round(power / 12), 1, 3)) - if(!stalk()) - return + if(!stalk()) + return + for(var/step in 1 to clamp(round(power / 12), 1, 3) - 1) + addtimer(CALLBACK(src, PROC_REF(stalk)), step * 0.4 SECONDS, TIMER_DELETE_ME) /datum/artifact_effect/animate/do_effect_destroy() return diff --git a/voidcrew/modules/xenoarch/effects_polaris/berserk.dm b/voidcrew/modules/xenoarch/effects_polaris/berserk.dm index f0072e09cd5..f7fbe36903d 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/berserk.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/berserk.dm @@ -4,11 +4,13 @@ // makes every punch land, weapons hit a quarter harder and swings come faster; when it ends the // person is drained and slow for a while, and can't be enraged again until they recover. Polaris' // brain damage per dose is dropped: the crash afterwards is the price. Works on people only. +// Logged like harm, since the crash is forced on whoever it touches. /datum/artifact_effect/bestow/berserk label_name = "Rage" log_name = "Berserk" tier = ARTIFACT_TIER_UNCOMMON + harmful = TRUE possible_kinds = list(ARTIFACT_ENERGY_PSIONIC, ARTIFACT_ENERGY_ORGANIC) status_type = /datum/status_effect/artifact/berserk touch_duration = 30 SECONDS @@ -17,7 +19,7 @@ aura_chance = 6 /datum/artifact_effect/bestow/berserk/can_bestow(mob/living/target) - return iscarbon(target) && target.stat == CONSCIOUS + return ..() && iscarbon(target) && target.stat == CONSCIOUS /datum/artifact_effect/bestow/berserk/on_bestowed(mob/living/target) target.visible_message( diff --git a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm index fb2b5ca82fd..b6be89644fb 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm @@ -1,7 +1,8 @@ // Artifact effect: disarm. // Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/disarm.dm. Voidcrew: // pries open the hand people are using, so what they hold drops at their feet (never thrown, so -// nothing lands in a chasm or lava); items that can't be dropped stay put. Logged like a weapon. +// nothing lands in a chasm or lava); items that can't be dropped stay put. Run from a carried +// battery, it never pries open its carrier's hand. Logged like a weapon. /datum/artifact_effect/disarm label_name = "Makes people drop things" @@ -21,13 +22,15 @@ /datum/artifact_effect/disarm/do_effect_pulse(power) var/turf/center = source_turf() if(center) - new /obj/effect/temp_visual/circle_wave/artifact_mist/pry(center, max(range * 0.6, 1.5)) + new /obj/effect/temp_visual/circle_wave/artifact_ring/pry(center, max(range * 0.6, 1.5)) for(var/mob/living/target in living_in_range()) pry(target) /// Makes `target` drop what is in their active hand. Returns TRUE if it fell. /datum/artifact_effect/disarm/proc/pry(mob/living/target) - if(target.stat != CONSCIOUS || !prob(100 * artifact_exposure(target))) + if(target.stat != CONSCIOUS || target == artifact_effect_visible_source(holder)) + return FALSE + if(!prob(100 * artifact_exposure(target))) return FALSE var/obj/item/held = target.get_active_held_item() if(!held || !target.dropItemToGround(held)) @@ -42,6 +45,6 @@ return TRUE /// A faint violet ripple for the burst. -/obj/effect/temp_visual/circle_wave/artifact_mist/pry +/obj/effect/temp_visual/circle_wave/artifact_ring/pry color = "#e17ceb" max_alpha = 110 diff --git a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm index 4ca6380c00d..40925560bf2 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm @@ -4,7 +4,8 @@ // people holding something metal, so it can be read and avoided (empty hands, insulated gloves); // Polaris shocked everyone in range for up to 40 burn. Field arcs never stun, bursts only knock // down briefly, and every arc is logged. A hand on it grounds the toucher: the charge leaps to -// everyone else nearby instead, as in Polaris. Lights in range flicker as it charges. +// everyone else nearby instead, as in Polaris. Lights in range flicker as it charges. Run from a +// carried battery, it never arcs to its carrier. /// Lights one charge can flicker, so a room full of lamps costs no more than a few #define ARTIFACT_FIELD_MAX_FLICKERS 6 @@ -52,7 +53,8 @@ * Insulated gloves and a suit's insulation work as they do against any shock. */ /datum/artifact_effect/electric_field/proc/arc_to(mob/living/target, damage, knockdown) - if(target.stat == DEAD || !conducts(target)) + var/atom/source = artifact_effect_visible_source(holder) + if(target.stat == DEAD || target == source || !conducts(target)) return FALSE damage *= artifact_exposure(target) if(damage < 1) @@ -61,13 +63,12 @@ // Positional on purpose: only the carbon override has the jitter, stutter and stun arguments. if(!target.electrocute_act(damage, holder, 1, flags, 4 SECONDS, 2 SECONDS, 1 SECONDS)) return FALSE - var/atom/origin = get_atom_on_turf(holder) - origin?.Beam(target, icon_state = "lightning[rand(1, 12)]", time = 0.5 SECONDS) + source?.Beam(target, icon_state = "lightning[rand(1, 12)]", time = 0.5 SECONDS) if(issilicon(target) || (target.mob_biotypes & MOB_ROBOTIC)) to_chat(target, span_danger("ERROR: Electrical fault detected!")) target.adjust_stutter(3 SECONDS) else - to_chat(target, span_danger("A spark leaps from [holder] to you!")) + to_chat(target, span_danger("A spark leaps from [source] to you!")) log_harm(target) return TRUE diff --git a/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm b/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm index 66166f5a773..2b42ca52bc1 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/extinguisher.dm @@ -20,7 +20,7 @@ /datum/artifact_effect/extinguisher/do_effect_touch(mob/living/user) douse(user, 4) - to_chat(user, span_notice("Cold mist beads on your skin where you touched [holder].")) + to_chat(user, span_notice("Cold mist beads on your skin.")) playsound(source_turf(), 'sound/effects/extinguish.ogg', 25, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) /datum/artifact_effect/extinguisher/do_effect_aura(seconds_per_tick) @@ -41,22 +41,25 @@ var/turf/center = source_turf() if(!center) return - new /obj/effect/temp_visual/circle_wave/artifact_mist(center, max(range * 0.6, 1.5)) + new /obj/effect/temp_visual/circle_wave/artifact_ring/mist(center, max(range * 0.6, 1.5)) playsound(center, 'sound/effects/extinguish.ogg', 45, TRUE) quench_area(center) for(var/mob/living/target in living_in_range()) - douse(target, 3) + if(!douse(target, 3)) + to_chat(target, span_notice("A cold, damp mist rolls over you.")) /datum/artifact_effect/extinguisher/do_effect_destroy() return -/// Puts out `target` and leaves them damp. Tells them if they were burning. +/// Puts out `target` and leaves them damp. Returns TRUE (and tells them) if it put them out. /datum/artifact_effect/extinguisher/proc/douse(mob/living/target, wetness) var/was_burning = target.on_fire target.extinguish_mob() target.adjust_wet_stacks(wetness * artifact_exposure(target)) if(was_burning && !target.on_fire) to_chat(target, span_notice("A cold mist rolls over you, and the flames on you gutter out.")) + return TRUE + return FALSE /// Snuffs open flames and burning objects in range. Returns TRUE if it put anything out. /datum/artifact_effect/extinguisher/proc/quench_area(turf/center) @@ -95,15 +98,10 @@ animate(src, alpha = 0, pixel_y = pixel_y + 6, time = duration) /// A ring of mist rolling out from the artifact. -/obj/effect/temp_visual/circle_wave/artifact_mist +/obj/effect/temp_visual/circle_wave/artifact_ring/mist color = "#cfe9ff" max_alpha = 170 duration = 0.8 SECONDS -/obj/effect/temp_visual/circle_wave/artifact_mist/Initialize(mapload, scale) - if(isnum(scale)) - amount_to_scale = scale - return ..() - #undef ARTIFACT_EXTINGUISH_COOL_TO #undef ARTIFACT_EXTINGUISH_COOL_STEP diff --git a/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm b/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm index b50008e930e..7c5e1eac4be 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/forcefield.dm @@ -92,7 +92,7 @@ /datum/artifact_effect/forcefield/proc/drop_ring() if(!length(walls)) return - for(var/obj/effect/forcefield/artifact/wall as anything in walls) + for(var/obj/effect/forcefield/artifact/wall in walls) UnregisterSignal(wall, COMSIG_QDELETING) wall.fade_out() walls.Cut() diff --git a/voidcrew/modules/xenoarch/effects_polaris/gaia.dm b/voidcrew/modules/xenoarch/effects_polaris/gaia.dm index 0e8583eb7be..9bb2a63eec4 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/gaia.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/gaia.dm @@ -42,9 +42,11 @@ var/turf/center = source_turf() if(!center) return - new /obj/effect/temp_visual/circle_wave/artifact_mist/gaia(center, max(range * 0.6, 1.5)) + new /obj/effect/temp_visual/circle_wave/artifact_ring/gaia(center, max(range * 0.6, 1.5)) playsound(center, 'sound/effects/rustle/rustle1.ogg', 40, TRUE) tend_all(range, clamp(power / 5, 2, 8), 10) + if(prob(25)) + draw_butterfly(get_step(center, pick(GLOB.alldirs))) for(var/mob/living/carbon/human/sprout in living_in_range()) nourish(sprout, clamp(power / 4, 2, 8)) @@ -74,7 +76,7 @@ tray.adjust_pestlevel(-1) tray.adjust_toxic(-amount) tray.update_appearance() - if(prob(40)) + if(prob(30)) new /obj/effect/temp_visual/artifact_gaia_bloom(get_turf(tray)) return TRUE @@ -90,10 +92,10 @@ for(var/datum/weakref/butterfly_ref as anything in butterflies) if(!butterfly_ref.resolve()) butterflies -= butterfly_ref - if(length(butterflies) >= ARTIFACT_GAIA_MAX_BUTTERFLIES || !istype(spot)) + if(length(butterflies) >= ARTIFACT_GAIA_MAX_BUTTERFLIES || !istype(spot) || spot.is_blocked_turf()) return var/mob/living/basic/butterfly/lavaland/temporary/butterfly = new(spot) - butterfly.source = get_atom_on_turf(holder) + butterfly.source = artifact_effect_visible_source(holder) butterfly.max_distance = range + 3 butterflies += WEAKREF(butterfly) addtimer(CALLBACK(butterfly, TYPE_PROC_REF(/mob/living/basic/butterfly/lavaland/temporary, fadeout)), rand(60, 120) SECONDS, TIMER_DELETE_ME) @@ -111,7 +113,7 @@ animate(alpha = 0, time = 1.1 SECONDS) /// A soft green ring for the burst. -/obj/effect/temp_visual/circle_wave/artifact_mist/gaia +/obj/effect/temp_visual/circle_wave/artifact_ring/gaia color = "#8cd448" max_alpha = 140 diff --git a/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm b/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm index cd95625b693..fc45c550e35 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/gravity_wave.dm @@ -3,13 +3,16 @@ // also in VOREStation (9335d66b9fdf). Voidcrew: one huge knockback ring instead of Polaris' // singularity pull. It draws the air in for a moment first (a shimmer closing on it, a rising // hum), so anyone watching has time to brace or back off; then everything loose in reach is -// flung outward and people are knocked down, hardest near the middle. Anchored things stay put. +// flung outward and people are knocked down, hardest near the middle. Anchored things stay put, +// and nobody is thrown into lava, a chasm, open air or space. // Smashing it lets the wave go at once. Not harvestable; logged. /// How long it gathers before the wave #define ARTIFACT_WAVE_WINDUP (1.5 SECONDS) /// Furthest it ever reaches #define ARTIFACT_WAVE_MAX_RANGE 7 +/// Loose objects one wave can fling (people always feel it), so a junk pile can't lag the server +#define ARTIFACT_WAVE_MAX_OBJECTS 40 /datum/artifact_effect/gravity_wave label_name = "Shockwave" @@ -58,12 +61,18 @@ var/turf/center = source_turf() if(!center) return - new /obj/effect/temp_visual/circle_wave/artifact_mist/gravity_wave(center, range) + new /obj/effect/temp_visual/circle_wave/artifact_ring/gravity_wave(center, range) playsound(center, 'sound/effects/gravhit.ogg', 70, TRUE, extrarange = 4) playsound(center, 'sound/effects/magic/repulse.ogg', 60, TRUE) + var/atom/movable/carrier = artifact_effect_visible_source(holder) + var/objects_flung = 0 for(var/atom/movable/thing in range(range, center)) - if(thing == holder || thing == get_atom_on_turf(holder) || !can_fling(thing)) + if(thing == holder || thing == carrier || !can_fling(thing)) continue + if(!isliving(thing)) + if(objects_flung >= ARTIFACT_WAVE_MAX_OBJECTS) + continue + objects_flung++ fling(thing, center) /// Loose items, unanchored structures and the living; nothing anchored or immovable. @@ -83,17 +92,31 @@ if(throw_range < 1) return var/direction = (distance > 0) ? get_dir(center, thing) : pick(GLOB.alldirs) - var/turf/landing = get_ranged_target_turf(thing, direction, throw_range) - if(!landing) - return if(isliving(thing)) var/mob/living/victim = thing + throw_range = safe_distance(victim, direction, throw_range) shake_camera(victim, 3, 2) victim.Knockdown(2 SECONDS * exposure) - to_chat(victim, span_userdanger("A wall of force hits you and lifts you off your feet!")) + to_chat(victim, span_userdanger("A wall of force hits you and [throw_range ? "lifts you off your feet" : "slams you to the ground"]!")) log_harm(victim) - // Positional: some throw_at() overrides rename their arguments. - thing.throw_at(landing, throw_range, 2) + if(throw_range < 1) + return + var/turf/landing = get_ranged_target_turf(thing, direction, throw_range) + if(landing) + // Positional: some throw_at() overrides rename their arguments. + thing.throw_at(landing, throw_range, 2) + +/** + * How far `thing` can be thrown toward `direction`, up to `limit`, without crossing lava, a chasm, + * open air or space. A wave knocks people over; it never throws them off a ledge or out an airlock. + */ +/datum/artifact_effect/gravity_wave/proc/safe_distance(atom/movable/thing, direction, limit) + var/turf/spot = get_turf(thing) + for(var/step in 1 to limit) + spot = get_step(spot, direction) + if(!spot || isspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot)) + return step - 1 + return limit /// A shimmer closing in on the artifact before the wave. /obj/effect/temp_visual/artifact_wave_gather @@ -113,10 +136,11 @@ animate(alpha = 220, time = duration * 0.5) /// The wave itself. -/obj/effect/temp_visual/circle_wave/artifact_mist/gravity_wave +/obj/effect/temp_visual/circle_wave/artifact_ring/gravity_wave color = "#d8c3ff" max_alpha = 230 duration = 0.6 SECONDS #undef ARTIFACT_WAVE_WINDUP #undef ARTIFACT_WAVE_MAX_RANGE +#undef ARTIFACT_WAVE_MAX_OBJECTS diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm index c23a4099252..c80f798728e 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm @@ -47,7 +47,7 @@ /datum/artifact_effect/life_drain/proc/can_hook(mob/living/target) if(target.stat == DEAD || hooked?[REF(target)]) return FALSE - if(target == get_atom_on_turf(holder)) + if(target == artifact_effect_visible_source(holder)) return FALSE return artifact_exposure(target) >= 0.5 @@ -55,15 +55,15 @@ /datum/artifact_effect/life_drain/proc/hook(mob/living/target, damage) if(!can_hook(target)) return FALSE - var/atom/origin = get_atom_on_turf(holder) - if(!origin) + var/atom/origin = artifact_effect_visible_source(holder) + if(!isatom(origin)) return FALSE var/datum/beam/drain_beam = origin.Beam(target, icon_state = "drain_life", time = ARTIFACT_DRAIN_WINDUP + 0.5 SECONDS, maxdistance = range + 1) var/key = REF(target) hooked[key] = TRUE target.visible_message( - span_danger("A sickly beam lashes out from [holder] and hooks into [target]!"), - span_userdanger("A sickly beam lashes out from [holder] and hooks into you. Something cold starts to pull."), + span_danger("A sickly beam lashes out from [origin] and hooks into [target]!"), + span_userdanger("A sickly beam lashes out from [origin] and hooks into you. Something cold starts to pull."), ) playsound(target, 'sound/effects/magic/enter_blood.ogg', 35, TRUE) addtimer(CALLBACK(src, PROC_REF(finish_hook), WEAKREF(target), WEAKREF(drain_beam), key, damage), ARTIFACT_DRAIN_WINDUP, TIMER_DELETE_ME) @@ -93,7 +93,7 @@ target.apply_status_effect(/datum/status_effect/artifact/exhausted, 10 SECONDS) new /obj/effect/temp_visual/heal(get_turf(target), "#7a1d3a") target.visible_message( - span_danger("[holder] pulls something pale and shining out of [target]."), + span_danger("Something pale and shining is pulled out of [target]."), span_userdanger("Something is torn out of you. Your legs go weak."), ) playsound(target, 'sound/effects/magic/exit_blood.ogg', 40, TRUE) diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm b/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm index 70d0b1ff0cc..4ea9ab1a036 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/life_steal.dm @@ -16,14 +16,14 @@ /datum/artifact_effect/life_steal/do_effect_touch(mob/living/user) if(!steal_for(user, 10)) - to_chat(user, span_notice("Something in [holder] reaches for you, then lets go.")) + to_chat(user, span_notice("Something reaches into you, finds nothing it wants, and lets go.")) /datum/artifact_effect/life_steal/do_effect_pulse(power) steal_for(pick_beneficiary(), clamp(power * 0.4, 5, 15)) /// Whoever a burst feeds: the person carrying it, else the nearest hurt person in reach. /datum/artifact_effect/life_steal/proc/pick_beneficiary() - var/atom/carrier = get_atom_on_turf(holder) + var/atom/carrier = artifact_effect_visible_source(holder) if(isliving(carrier)) return carrier var/turf/center = source_turf() diff --git a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm index af1c9c251c9..38783c19867 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm @@ -46,7 +46,7 @@ var/best_distance = INFINITY var/best_has_mind = FALSE for(var/mob/living/candidate in living_in_range()) - if(candidate.stat != CONSCIOUS || candidate == get_atom_on_turf(holder)) + if(candidate.stat != CONSCIOUS || candidate == artifact_effect_visible_source(holder)) continue var/has_mind = !!candidate.mind var/distance = get_dist(center, candidate) @@ -84,7 +84,7 @@ missile.visible_message(span_warning("[missile] lifts off the floor and turns toward [target]!")) playsound(missile, 'sound/effects/magic/summonitems_generic.ogg', 30, TRUE) animate(missile, pixel_z = 10, time = ARTIFACT_POLTERGEIST_WINDUP, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) - var/atom/origin = get_atom_on_turf(holder) + var/atom/origin = artifact_effect_visible_source(holder) origin?.Beam(missile, icon_state = "kinesis", time = ARTIFACT_POLTERGEIST_WINDUP) addtimer(CALLBACK(src, PROC_REF(hurl), WEAKREF(missile), WEAKREF(target)), ARTIFACT_POLTERGEIST_WINDUP, TIMER_DELETE_ME) return TRUE diff --git a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm index a2e7887495f..88fdf320669 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm @@ -121,7 +121,7 @@ /// The light goes to work on `body`; it wakes after the rite if nothing has changed. /datum/artifact_effect/resurrect/proc/begin_rite(mob/living/carbon/human/body) working_on = WEAKREF(body) - var/atom/origin = get_atom_on_turf(holder) + var/atom/origin = artifact_effect_visible_source(holder) origin?.Beam(body, icon_state = "lichbeam", time = ARTIFACT_RESURRECT_RITE) body.visible_message(span_warning("Threads of red light reach out of [holder] and sink into [body]. The body twitches.")) playsound(body, 'sound/effects/magic/enter_blood.ogg', 45, TRUE) diff --git a/voidcrew/modules/xenoarch/effects_polaris/spines.dm b/voidcrew/modules/xenoarch/effects_polaris/spines.dm index 78fd5cf321e..33eabff7144 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/spines.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/spines.dm @@ -47,7 +47,7 @@ bristling = TRUE center.visible_message(span_danger("[holder] bristles with sharp bone!")) playsound(center, 'sound/effects/blob/blobattack.ogg', 50, TRUE) - var/atom/movable/shaker = get_atom_on_turf(holder) + var/atom/movable/shaker = artifact_effect_visible_source(holder) if(shaker) shaker.Shake(1, 1, ARTIFACT_SPINES_WINDUP) addtimer(CALLBACK(src, PROC_REF(fire_volley), spared ? WEAKREF(spared) : null), ARTIFACT_SPINES_WINDUP, TIMER_DELETE_ME) @@ -64,7 +64,7 @@ return 0 var/mob/living/spared = spared_ref?.resolve() var/spared_angle = (spared && get_dist(center, spared) <= 1 && spared.loc != center) ? get_angle(center, spared) : null - var/atom/movable/launcher = get_atom_on_turf(holder) + var/atom/movable/launcher = artifact_effect_visible_source(holder) var/step = 360 / volley var/offset = rand(0, step - 1) var/fired = 0 @@ -89,7 +89,7 @@ name = "bone spine" icon_state = "magspear" color = "#efe2c4" - damage = 8 + damage = 10 damage_type = BRUTE armor_flag = BULLET sharpness = SHARP_POINTY diff --git a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm index b451b89d0ed..4999ee237ee 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm @@ -38,7 +38,7 @@ /datum/status_effect/artifact/fortify/on_apply() RegisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(soften_blow)) - owner.add_filter("artifact_fortify", 2, outline_filter(1, "#a7b89a80")) + owner.add_filter("artifact_fortify", 2, outline_filter(1, "#a7b89aa8")) return TRUE /datum/status_effect/artifact/fortify/on_remove() @@ -88,7 +88,7 @@ /atom/movable/screen/alert/status_effect/artifact_faraday name = "Grounded" - desc = "There is a cold, still hush on your skin. Even your radio sounds far away." + desc = "A cold, still hush has settled on your skin." icon_state = "shock_immune" // ===== Sprint ===== @@ -170,7 +170,7 @@ /atom/movable/screen/alert/status_effect/artifact_berserk name = "Seeing Red" - desc = "Your blood is up. Every swing lands, and lands hard." + desc = "Your blood is up. You want to hit something." icon_state = "blooddrunk" /datum/movespeed_modifier/status_effect/artifact_exhausted @@ -239,6 +239,27 @@ desc = "Living things glow through the walls like coals through paper." icon_state = "moon_hide" +// ===== Shared helpers ===== + +/** + * What people see an effect coming from: the artifact itself, or, for an effect harvested into a + * battery, whoever or whatever carries that battery. Safe on a null or deleted holder. + */ +/proc/artifact_effect_visible_source(atom/holder) + if(!ismovable(holder)) + return holder + return get_atom_on_turf(holder) + +// ===== Shared visuals ===== + +/// A ring rolling out from an artifact. Pass the reach in tiles after the location. +/obj/effect/temp_visual/circle_wave/artifact_ring + +/obj/effect/temp_visual/circle_wave/artifact_ring/Initialize(mapload, scale) + if(isnum(scale)) + amount_to_scale = scale + return ..() + // ===== Effects that hand one out ===== /** @@ -291,8 +312,10 @@ log_harm(target) return TRUE -/// Who it works on. +/// Who it works on. A harmful one carried in a battery spares whoever carries it. /datum/artifact_effect/bestow/proc/can_bestow(mob/living/target) + if(harmful && target == artifact_effect_visible_source(holder)) + return FALSE return target.stat != DEAD /// Hook: `target` just got their first dose. diff --git a/voidcrew/modules/xenoarch/effects_polaris/sweat.dm b/voidcrew/modules/xenoarch/effects_polaris/sweat.dm index 9fbc0c6d2ca..5f4efce409c 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/sweat.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/sweat.dm @@ -33,14 +33,15 @@ /datum/artifact_effect/sweat/do_effect_touch(mob/living/user) var/turf/user_turf = get_turf(user) if(prob(25) && weep(user_turf, 6)) + var/atom/source = istype(holder, /obj/machinery/artifact) ? holder : null user.visible_message( - span_warning("[holder] shudders at [user]'s touch and spits a slick of fluid across [user.p_their()] feet!"), - span_warning("[holder] shudders at your touch and spits a slick of fluid across your feet!"), + span_warning("[source ? "[source] shudders at [user]'s touch and spits" : "Something spits"] a slick of fluid across [user]'s feet!"), + span_warning("[source ? "[source] shudders at your touch and spits" : "Something spits"] a slick of fluid across your feet!"), ) playsound(user_turf, 'sound/effects/splat.ogg', 35, TRUE) return weep(source_turf(), 2) - to_chat(user, span_notice("Your palm comes away slick. It is beaded with moisture where you touched it.")) + to_chat(user, span_notice("A cold, slick film beads on your skin.")) /datum/artifact_effect/sweat/do_effect_aura(seconds_per_tick) if(SPT_PROB(25, seconds_per_tick) && weep(source_turf(), 2)) @@ -57,7 +58,10 @@ for(var/i in 1 to min(3, length(neighbours))) spilled = weep(pick_n_take(neighbours), clamp(power / 8, 1, 5)) || spilled if(spilled) - center.visible_message(span_warning("[holder] shudders and spits fluid across the floor.")) + if(istype(holder, /obj/machinery/artifact)) + center.visible_message(span_warning("[holder] shudders and spits fluid across the floor.")) + else + center.visible_message(span_warning("Fluid spatters across the floor.")) playsound(center, 'sound/effects/splat.ogg', 40, TRUE) /// Adds `amount` of its liquid to a puddle on `spot`. Returns TRUE if any went in. @@ -75,7 +79,8 @@ if(room <= 0) return FALSE puddle.reagents.add_reagent(reagent_type, min(amount, room)) - puddle.color = initial(reagent_type.color) + // Just the hue: water's own colour is nearly clear and the slick would vanish. + puddle.color = copytext(initial(reagent_type.color), 1, 8) return TRUE /// A puddle of whatever an artifact wept. Scoop it up with a container, or mop it. From c4e3c71c0c824c1c04688bfed626bb262558877a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:13:35 -0800 Subject: [PATCH 33/72] Polish the dig reveal A warm glint over a find as the last grit comes off it, for boulders and strange rocks. The advanced depth scanner's design id matches the research node, the find satchel has an in-hand sprite, and the idol's description no longer hints at an effect it doesn't have. --- voidcrew/modules/xenoarch/dig/boulder.dm | 21 +++++++++++++++++++ voidcrew/modules/xenoarch/dig/curios.dm | 2 +- voidcrew/modules/xenoarch/dig/designs.dm | 6 +++--- voidcrew/modules/xenoarch/dig/strange_rock.dm | 1 + voidcrew/modules/xenoarch/dig/tools.dm | 3 +++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/voidcrew/modules/xenoarch/dig/boulder.dm b/voidcrew/modules/xenoarch/dig/boulder.dm index a7322cb16e4..ae092443e5d 100644 --- a/voidcrew/modules/xenoarch/dig/boulder.dm +++ b/voidcrew/modules/xenoarch/dig/boulder.dm @@ -317,6 +317,7 @@ find.alpha = 0 find.pixel_z = -6 animate(find, alpha = 255, pixel_z = 0, time = 1.2 SECONDS, easing = SINE_EASING | EASE_OUT) + new /obj/effect/temp_visual/xenoarch_glint(spot) visible_message(span_notice("The last of the rock sloughs away, and [find] stands clear of the dust.")) if(damaged) visible_message(span_warning("Cracks run through [find] where the rock let go of it badly.")) @@ -403,3 +404,23 @@ size.Scale(0.8) transform = size animate(src, alpha = 0, time = duration, easing = EASE_IN) + +/** + * # Find glint + * + * A warm glitter over a find the moment the last grit comes off it. tg's shield sparkles, tinted. + */ +/obj/effect/temp_visual/xenoarch_glint + name = "glint" + icon = 'icons/effects/effects.dmi' + icon_state = "shieldsparkles" + color = "#ffe6a8" + layer = ABOVE_MOB_LAYER + plane = GAME_PLANE + randomdir = FALSE + duration = 1.2 SECONDS + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/obj/effect/temp_visual/xenoarch_glint/Initialize(mapload) + . = ..() + animate(src, alpha = 0, time = duration, easing = EASE_IN) diff --git a/voidcrew/modules/xenoarch/dig/curios.dm b/voidcrew/modules/xenoarch/dig/curios.dm index 6e8edfb8bdf..fc84bc654d4 100644 --- a/voidcrew/modules/xenoarch/dig/curios.dm +++ b/voidcrew/modules/xenoarch/dig/curios.dm @@ -330,7 +330,7 @@ GLOBAL_LIST_INIT(xenoarch_curio_values, list( variants = list("unknown1") descs = list( "A banded column of gold-coloured metal, stacked like a spine.", - "A ritual column of metal rings. It's warm, though it was buried.", + "A column of metal rings, stacked and worn smooth where hands once held it.", ) /obj/item/xenoarch_curio/scrap diff --git a/voidcrew/modules/xenoarch/dig/designs.dm b/voidcrew/modules/xenoarch/dig/designs.dm index c5cf8defb64..60f00e399ff 100644 --- a/voidcrew/modules/xenoarch/dig/designs.dm +++ b/voidcrew/modules/xenoarch/dig/designs.dm @@ -6,7 +6,7 @@ * quick brush to "Advanced Excavation". Those nodes list these ids: * * - Xenoarchaeology: "xenoarch_stabiliser", "xenoarch_spectrometer" - * - Advanced Excavation: "xenoarch_depth_scanner_adv", "xenoarch_brush_quick" + * - Advanced Excavation: "xenoarch_scanner_advanced", "xenoarch_brush_quick" */ // ===== Autolathe, from round start ===== @@ -110,10 +110,10 @@ // ===== Research: Advanced Excavation ===== -/datum/design/xenoarch_depth_scanner_adv +/datum/design/xenoarch_scanner_advanced name = "Advanced Depth Scanner" desc = "Reads a find's exact depth and the field its rock needs, on site." - id = "xenoarch_depth_scanner_adv" + id = "xenoarch_scanner_advanced" build_type = PROTOLATHE | AWAY_LATHE materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 3, /datum/material/glass = SMALL_MATERIAL_AMOUNT * 3, /datum/material/gold = SMALL_MATERIAL_AMOUNT * 2, /datum/material/uranium = SMALL_MATERIAL_AMOUNT * 2) build_path = /obj/item/excavation/depth_scanner/advanced diff --git a/voidcrew/modules/xenoarch/dig/strange_rock.dm b/voidcrew/modules/xenoarch/dig/strange_rock.dm index ffb539892dd..5fa8abbe8d0 100644 --- a/voidcrew/modules/xenoarch/dig/strange_rock.dm +++ b/voidcrew/modules/xenoarch/dig/strange_rock.dm @@ -143,6 +143,7 @@ playsound(drop_spot, 'sound/effects/rock/rock_break.ogg', 40, TRUE) new /obj/effect/temp_visual/xenoarch_dust(get_turf(drop_spot), "#9c8c78", TRUE) if(find) + new /obj/effect/temp_visual/xenoarch_glint(get_turf(drop_spot)) var/atom/speaker = holder || src speaker.visible_message( span_notice("The last crust of rock falls away from [find]."), diff --git a/voidcrew/modules/xenoarch/dig/tools.dm b/voidcrew/modules/xenoarch/dig/tools.dm index 8481809c7e4..656b0f99e88 100644 --- a/voidcrew/modules/xenoarch/dig/tools.dm +++ b/voidcrew/modules/xenoarch/dig/tools.dm @@ -173,6 +173,9 @@ desc = "A padded satchel with a pocket for every strange rock." icon = 'voidcrew/modules/xenoarch/icons/dig_tools.dmi' icon_state = "find_bag" + inhand_icon_state = "satchel-explorer" + lefthand_file = 'icons/mob/inhands/equipment/backpack_lefthand.dmi' + righthand_file = 'icons/mob/inhands/equipment/backpack_righthand.dmi' worn_icon_state = "satchel" slot_flags = ITEM_SLOT_BELT | ITEM_SLOT_POCKETS w_class = WEIGHT_CLASS_SMALL From c9af08bd2dfe30c5945c4a91311d809fcd93d073 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:33:29 -0800 Subject: [PATCH 34/72] Add xenoarchaeology wiki page Covers dig sites, tools, digging, finds, moving and studying artifacts, using them, selling to Barnaby, Boffin and Vex, and the effect and trigger labels. --- wiki/content/xenoarchaeology.md | 328 ++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 wiki/content/xenoarchaeology.md diff --git a/wiki/content/xenoarchaeology.md b/wiki/content/xenoarchaeology.md new file mode 100644 index 00000000000..ce80def2484 --- /dev/null +++ b/wiki/content/xenoarchaeology.md @@ -0,0 +1,328 @@ +--- +title: Xenoarchaeology +category: Exploration +order: 3.5 +blurb: Dig old alien finds out of planet cliffs, work out what wakes them, then sell them to Barnaby, Boffin and Vex or bolt them down aboard. +--- + +Some planet cliffs hold old dig sites: veined rock with something that isn't stone inside it. Most of it is small stuff such as fossils and pottery. Now and then a whole boulder comes away with an alien artifact in it. Getting one out in one piece takes care, finding out what it does means poking it, and a correct label is what the buyers pay for. A dug artifact can be sold at a [trader outpost](trader-outposts.md), bolted down aboard for its effect, or drained into batteries for a handheld projector. + +## Where finds come from + +Dig sites are on planet surfaces only. Ruins, asteroid fields and outposts have none. See [Planets](planets.md). + +A site is a run of about four wall tiles in a cliff face, each threaded with pale veins. Two or three survey stakes with orange or red flagging tape and a heap of spoil stand on the ground in front, so a site shows from across the map. Examine a vein wall and it says pale veins run through the rock. A mining scanner marks the walls with a sign of its own, not an ore's. A world with no cliff to dig into gets a short outcrop of its own rock instead. + +Mine a vein wall with a pickaxe or drill. Each wall gives up one find. It is either a boulder with a large find inside, or a strange rock with a small one. NPC miners leave these walls alone. + +Every world holds three sites, and what is in them is fixed for the round. + +| Zone | Large finds per world | Small finds per world | +|---|---|---| +| Green | 3 | 9 | +| Yellow | 3 | 9 | +| Red | 6 | 6 | + +Sites do not refill when a planet unloads and comes back. A boulder you cut free but did not finish is put back as you left it, with its depth, tag and stabiliser field. + +The zone the planet is in decides how good the finds are. Higher zones give rarer and stronger effects, better small finds and bigger payouts. Every effect can turn up in every zone, so a green-only crew can dig, study and sell everything. Freeing a large find in yellow or red can also wake whatever lives in the rock (see below). Only red space can hold a wrecked exosuit. See [The Overmap](overmap.md) for the zones. + +## Tools + +Print the **Excavation Kit** at any autolathe from the start. It costs 3 iron, 1 glass and 1 plastic sheet and comes as a belt of webbing holding a rock hammer, brush, depth scanner, core sampler and find satchel. Each piece prints on its own too. You also need a pickaxe or drill for the walls, and a mining scanner if you want to tag boulders. Everything else comes from [research](research.md). + +| Tool | What it does | Where from | +|---|---|---| +| Rock hammer | Chips rock off a find. Use it in hand to fit a 1, 2, 4, 6 or 10 cm head. | Kit | +| Excavation brush | Takes one centimetre per pass and can never overshoot. | Kit | +| Depth scanner | Pins a survey tag to a find saying how deep it sits. | Kit | +| Core sampler | Draws a core of rock from a boulder. Holds one core. | Kit | +| Find satchel | Holds 12 strange rocks, curios and strange objects. | Kit | +| Field stabiliser | Holds a boulder together while its find is freed. Eight field settings. | Xenoarchaeology, protolathe | +| Radiocarbon spectrometer | Reads a core and prints which field a boulder needs. | Xenoarchaeology, circuit imprinter | +| Artifact analyser and scan pad | Read a find. Build both. | Xenoarchaeology, circuit imprinter | +| Specimen labeler | Prints the tag that goes on a large find. | Xenoarchaeology, protolathe | +| Specimen crate | Holds one large find safely. | Xenoarchaeology | +| Advanced depth scanner | Reads the exact depth and the field, on site. | Advanced Excavation, protolathe | +| Quick brush | Brushes three times as fast. | Advanced Excavation, protolathe | +| Exotic particle harvester | Drains an effect into a battery. | Exotic Particles, circuit imprinter | +| Exotic battery and exotic utilizer | Store a drained effect and let it out again. | Exotic Particles, protolathe | + +**Xenoarchaeology** costs 40 research points and follows Fundamental Science. **Advanced Excavation** costs 80 and follows Xenoarchaeology. **Exotic Particles** costs 120 and also follows Xenoarchaeology, and your crew needs 4 [reputation](zone-reputation.md) before it opens. + +## Digging + +### Strange rocks + +A strange rock is a lump of veined rock you can carry. It holds one small find, much shallower than a boulder's, 14 to 38 cm down. + +1. Optionally sound it with a depth scanner. It reads a ten-centimetre window. +2. Chip it down with the hammer. +3. Finish with the brush. + +No stabiliser is needed. Cut past the find and the find goes with the rock. A blast can destroy one too. When it comes free it drops into the hand that held the rock. + +### Boulders + +A boulder is the large find. It is anchored and heavy, and breaking it loses what is inside, so keep explosives away. Work it in this order. + +1. **Tag it.** Use a mining scanner on the boulder. It gets a holotag with a catalogue number such as Kappa-4412 and shows on GPS units as that number plus "dig". Anyone with a GPS can pick it up, other crews included. +2. **Sound it.** Use a depth scanner. It pins a survey tag saying how deep the find sits as a 30 cm window, and how much has been cut so far. Finds sit between 25 and 200 cm down. The advanced scanner gives the exact depth. +3. **Learn the field.** Every boulder needs one of eight stabilising fields. The advanced depth scanner reads it on site. With the plain scanner, push the core sampler into the boulder (two seconds), take the core to a radiocarbon spectrometer aboard ship (about ten seconds), and read the "Matrix holds under" line on the report it prints. The report carries the boulder's catalogue number, so you can keep several apart. +4. **Stabilise it.** Use the field stabiliser in hand to pick the field, then sweep it over the boulder for five seconds. A wrong field looks exactly like a right one. +5. **Cut down to the find.** Mix tools as you like. +6. **Brush it clean.** Once the cut equals the depth, one more pass frees the find. + +| Cutting tool | Takes off | Time | +|---|---|---| +| Pickaxe or drill | 18 to 30 cm | 2.5 seconds a swing | +| Rock hammer | The fitted head, 1 to 10 cm | Half a second per cm | +| Brush | 1 cm | 3 seconds a pass, 1 second with the quick brush | + +A sounded boulder tells you the running total after every strike. The pick and hammer can overshoot. The brush cannot, so stop short and brush the rest. Walk away from a hammer swing halfway through and it may chip a centimetre or two off the boulder. + +### Losing or cracking a find + +- **Cut past the find** and the boulder splits along a fault. Nothing is left. +- **Free it without the right field** and half the time it slumps to pieces and nothing is left. The other half it comes out cracked. +- A cracked find sells for half. A cracked charged crystal holds half its charge and a cracked exosuit is in worse shape. +- **Smashing or blasting a boulder** loses the find. + +In yellow or red space, freeing a large find can wake the local fauna: roughly one time in four in yellow and nearly one in two in red. The ground shudders, and about three seconds later a pack of animals or spiders crawls out four to seven tiles away. + +## What can come out + +| Find | What it is | +|---|---| +| Alien artifact | The usual large find. Comes in many shapes. One main effect, sometimes a milder second one. See Study and Using artifacts. | +| Bluespace formation | Pale blue crystal on black rock. It has no effect to study and no buyer wants it. Each time it takes a hit it teleports everyone within three tiles a short hop away and sets off a small EMP. Broken, it leaves 2 to 4 bluespace crystals, which traders buy (see [Mining & Salvage](mining.md)). | +| Charged crystal | An amber crystal holding a limited charge. It can run a ship's power for a while. | +| Sealed case | A sealed specimen transfer cache of lab and research gear, better the higher the zone it was dug in. It can hold a weapon. | +| Exosuit wreck | Very rare, red zone only. A Gygax or Durand, badly damaged, nearly flat on charge, caked in dust and carrying no weapons. Wash it, repair it and arm it yourselves. | +| Curios | Shells, plant fossils, bones, skulls, skeletons, bowls, urns, statuettes, instruments, idols, seed pods, corroded mechanisms and spent sidearms. They sell. | +| Research notes | A page worth 100 or 250 research points. | +| Strange object | A device of unknown purpose. Boffin buys them. | + +Small finds come out of strange rocks. Large finds come out of boulders. Every artifact, curio and strange object is stamped with the zone it was dug in, and that stamp is what pricing and research read, wherever you carry it afterwards. + +## Moving finds + +A large find is dense and unbolted, so you pull it. Pulling or bumping an artifact counts as touching it, at most once every three seconds. That wakes a touch-triggered artifact unless you wear latex gloves. + +!!! warning "Hauled artifacts can go off" + Latex gloves stop you waking a touch-triggered artifact. They do not stop one that is already awake from acting on you. + +A large find can be shut in a crate. A bolted-down find will not go in. While a find is shut in a crate it does nothing at all. Any ordinary crate will take it. The **specimen crate** is a padded, lead-lined crate built for one large find: it takes the find and small things, never people or other machines, and its lid shows the find's catalogue number. + +Every way into a trader outpost runs through a scan chamber. A loose artifact on the chamber floor makes the guard refuse the whole chamber ("Crate it first.") until someone takes it back out. A find in a shut crate passes. Going out is never a problem. Inside an outpost an artifact does nothing and cannot be bolted down. The lift between floors carries unbolted things standing in its alcove, so a crate rides up with you. + +## Study + +Large finds are read at an **artifact analyser** beside a **scan pad**. Both need power, must be bolted down and need their panels closed. Stand the find on the pad and open the analyser. + +### Scanning + +A scan takes about ten seconds. Better scanning modules shorten it. The analyser prints a report with the condition of the find and one line for each emission it has, giving three things: + +- the **energy kind**, which also sets the colour of the glow when the find wakes; +- how it is released: on contact, as a field, or in bursts; +- what it reacts to: physical contact, energetic contact, or its surroundings. + +A find with only one emission prints "None detected" for the secondary one, which tells you whether the second effect on a label should say None. The report never names the effect. The first completed scan of each dug find also prints research notes, once per find however many crews scan it: 100 points for a green find, 150 for yellow and 250 for red, and half as much again for the rarer effects. Take the notes to any R&D console. + +| Report says | Glow | +|---|---| +| Concentrated energy | Amber | +| Psionic wavefront | Violet | +| Electromagnetic energy | Pale blue | +| High-frequency particles | White | +| Organically reactive particles | Rose red | +| Bluespace phasing | Deep blue | +| Atomic synthesis | Green | + +Heat and cold report as energetic contact even though air temperature sets them off. Plasma, carbon dioxide and proximity report as surroundings. + +### Testing + +The report narrows an artifact down. To find the rest, apply stimuli to the find on the pad and watch the analyser's **tape**. Each stimulus prints a line such as "Water. Response." or "Touch. No response.", and each emission prints "emission rising" and "emission fading" as it wakes and sleeps. Air and proximity triggers print no stimulus line, only the rising and fading ones. The tape holds twelve lines. Tear it off for a paper slip. + +Touch the find, splash it, hit it, burn it, shock it, change the air around it. The trigger table below says what each word means. Put on bio gear first (see Hazards). + +A stimulus wakes any effect with that trigger for 45 to 90 seconds, and another stimulus restarts the clock. Air and proximity triggers keep the effect awake for as long as the condition lasts, plus a few seconds. An awake artifact glows, hums and gives off particles. + +### Labelling + +Open the **specimen labeler** in hand and pick three words from its lists: what the find does, what wakes it, and its second effect or None. Use it on the find to stick on the tag, which takes a second. Right-click peels a tag off. A new tag replaces the old one, so you can relabel as often as you like. Examining the find shows the tag as written. + +Nothing in the game tells you whether the tag is right until a buyer pays for it. + +## Using artifacts + +### Bolted down + +Anyone can bolt an artifact down with a wrench, or unbolt it, in four seconds. An artifact runs its effect wherever it stands, and bolting it down keeps it there. Some limits apply: + +- A field uses its charge three times as fast as the artifact refills it, so it runs for about a third of the time and rests the rest. A burst fires when the store is full. +- No one can take more than 30 points of brute and burn healing from artifacts in five minutes, all artifacts and utilizers added together. +- A cell charger effect works on at most two ordinary power cells at once, at about charger speed. Machines and their cells are left alone. +- Gas effects vent only indoors, never onto planet ground or into space. Heat and cold change the air only indoors too, but they still warm or chill people anywhere. +- An artifact does nothing in a crate or inside a trader outpost. + +A ship rebuilt from a [checkpoint](player-outposts.md#ship-bay) comes back without its artifacts, charged crystals, batteries or utilizers. + +### Charged crystals + +Bolt a charged crystal down on a cable and wind two lengths of cable around it, and it puts 15 kW on the wire. It runs until it has given 90 minutes of power in all, then goes dull for good. Cut the wire to stop it and keep what is left. A cracked crystal starts with half. + +### Harvester, battery and utilizer + +The **exotic particle harvester** draws one effect off an artifact into an **exotic battery**. + +1. Seat an empty battery under the dome. +2. Put the artifact on a tile next to the harvester and wake the effect you want. +3. Press Draw. A full battery takes about 80 seconds, down to about 40 with the best capacitors. + +Notes: + +- Each artifact allows three harvests in all. A harvest is spent the moment charge starts to flow, and stopping early refunds nothing. Each one takes a fifth off the find's sale price. +- It will not draw while two effects are awake at once. +- An empty battery takes any effect. After that it tops up only from the effect it already holds. Purge a battery to reuse it. +- These effects cannot be drained: Twists flesh, Noise, Lightning, Moves on its own, Force walls, Shockwave, Vents plasma, Vents sleeping gas, Raises the dead and Fires spines. + +The **exotic utilizer** is a handheld projector with a slot for the battery. Open it in hand to switch it on. + +- A field or burst effect runs until the battery is empty, about six and a half minutes from full. +- A contact effect waits for you to press the prongs against someone, yourself included. A full battery gives ten uses. +- A field or burst reaches everyone in range, and many effects do not spare the person holding it. +- It goes dark in a trader outpost. + +### Smashing + +An artifact is tough, but if it breaks, every effect that is not helpful lets off one last burst. + +## Selling + +Three outpost traders buy finds. Large finds sell only by hauling them to the trader. + +| Buyer | Outpost | Takes | +|---|---|---| +| Barnaby | Waystation Halcyon, general store | Large finds, curios | +| Boffin | The Skunkworks, Quartermain Depot | Large finds, curios, strange objects | +| Vex | Undertow Exchange, black market | Large finds. Pays one trade voucher on top for an artifact with one of the rarest main effects. | + +Sarge, at the Quartermain main counter, buys none and sends you round to Boffin. All three buyers pay the same for the same find. Selling earns no reputation. + +### Selling a large find + +1. Pull the find, or the shut crate holding it, to within three tiles of the trader. +2. Open Trade. The wanted ledger has a line for finds: "dig pieces" with Barnaby and Vex, "alien artifacts" with Boffin. Sell it. +3. The trader looks it over for a couple of seconds. Stay put. Walk away and the sale is off. +4. You get no quote and no second chance. The sale is final. Credits land on your ID's account, any voucher lands in your hand, and a paper receipt comes with them. + +Each buyer takes three large finds before the next supply convoy, and Barnaby and Boffin take six curios. A convoy arrives about every 22 minutes and brings demand part of the way back. + +### What it pays + +Four things set the price of a large find: + +- **The zone it was dug in**, not where you sell it. The base price is 250 credits for green, 450 for yellow and 700 for red. +- **Its main effect.** Rarer effects pay more, up to four times the base. +- **Your label.** The effect word is worth half of the price, the trigger word 30% and the second effect 20%. Add up what your label gets right. If it comes to half or more, you are paid that share of the price. Below half, or with no label, you are paid a fifth. +- **Condition.** A cracked find pays half. Each harvest takes off a fifth. + +A perfectly labelled artifact pays between 250 and 1,000 credits from green space, 450 and 1,800 from yellow, and 700 and 2,800 from red, depending on its main effect. The best of those in red also earns a voucher at Vex. Vouchers are covered on [Money & Trade](money-and-trade.md). + +### Curios + +Barnaby and Boffin pay by the piece. Vex buys none. + +| Credits | Items | +|---|---| +| 40 | Fossil shell | +| 50 | Plant fossil, fossil bone, fossil skull | +| 80 | Clay bowl, urn, corroded mechanism, spent sidearm | +| 150 | Statuette, strange instrument, idol, seed pod, fossil skeleton | +| 200 | Strange object (Boffin only) | + +Only finds that came out of a dig site sell. A strange object from maintenance or an experimentor copy is not one. Feeding a strange object to the destructive analyzer pays 60 research points, once per research server. + +## Labels and triggers + +Every artifact has one main effect, and some carry a milder second one. Only the milder effects turn up as a second effect. + +### What wakes it + +| Trigger word | What sets it off | +|---|---| +| Touch | A bare hand, or any glove that isn't latex. | +| Water | A splash of water or hydrogen. | +| Acid | A splash of any acid. | +| Volatile chemicals | A splash of thermite, liquid plasma, nitroglycerin, gunpowder or similar. | +| Toxins | A splash of any other toxin. | +| Impact | A hit from anything with force, a thrown object that hits hard, a thrown creature, a bullet that does damage, or a blast. | +| Electricity | A live stun baton or lit energy blade pressed to it, a laser or energy beam, or an EMP. | +| Heat | Air above 375 K (102 °C), a lit welder or any tool above 700 K, fire, or a blast. | +| Cold | Air below 225 K (-48 °C). | +| Plasma | Ten or more moles of plasma in the air on its tile. | +| Carbon dioxide | Ten or more moles of carbon dioxide on its tile. | +| Proximity | A living thing within three tiles that it can see. | + +### What it does + +A field acts on everything in range while it runs. A burst acts on everything in range when the store fills. A contact effect acts on whoever touches the awake artifact. Any release works unless the table says otherwise. "Logged" means the effect is recorded as an attack (see Hazards). "Drain" means a harvester can take it. + +| Label | What it does | Drain | Logged | +|---|---|---|---| +| Restores blood | Tops up the blood of people nearby, up to normal. | Yes | No | +| Drains blood | Bleeds people nearby, never to the point of passing out. | Yes | Yes | +| Twists flesh | Bursts only. Turns one limb of someone close into a living flesh limb. | No | Yes | +| Charges cells | Charges loose or carried power cells. | Yes | No | +| Drains cells | Drains the same cells. | Yes | Yes | +| Nausea | Makes people nearby sick. | Yes | Yes | +| EMP | Bursts only. Sets off EMPs. | Yes | Yes | +| Good mood | Lifts the mood of people nearby. | Yes | No | +| Dread | Unease, dizziness and a bad mood. | Yes | No | +| Vents gas | Oxygen, nitrogen or carbon dioxide into the room. Stops when the room is a little over pressure. | Yes | No | +| Gravity | Bursts only. Pulls, pushes or scatters loose items, loose structures and people. | Yes | Yes | +| Hallucinations | Hallucinations and a drugged haze. | Yes | Yes | +| Heals | Mends brute and burn on living people nearby. | Yes | No | +| Mends machines | The same for robots and robotic limbs. | Yes | No | +| Hurts | Brute and burn damage to living people nearby. | Yes | Yes | +| Damages machines | The same against robots and robotic limbs. | Yes | Yes | +| Light | Field only. A bright glow. | Yes | No | +| Darkness | Field only. Dims everything nearby. | Yes | No | +| Noise | Bursts only. Wails and groans. | No | No | +| Radiation | Radiation pulses. | Yes | Yes | +| Puts people to sleep | Drowsiness, then sleep. | Yes | Yes | +| Knocks people down | Stamina damage, confusion and falls. | Yes | Yes | +| Teleports | Hops people a few tiles at random. Never into space or lava. | Yes | Yes | +| Heat | Warms people and the room, stopping short of burning anyone. | Yes | No | +| Cold | Chills people and the room, stopping short of freezing anyone. | Yes | No | +| Lightning | Bursts only. Arcs lightning at everything nearby. | No | Yes | +| Moves on its own | Creeps after the nearest person. Held back by its bolts. | No | No | +| Rage | Hits land harder and faster for a while, then exhaustion. | Yes | Yes | +| Makes people drop things | Pries open a hand and drops what is in it. | Yes | Yes | +| Shocks | Zaps robots and anyone holding something metal, and flickers the lights. | Yes | Yes | +| Puts out fires | Douses burning people, flames and burning objects. | Yes | No | +| EMP shielding | Makes you immune to EMPs for a while. | Yes | No | +| Second sight | See living things through walls for a short time. | Yes | No | +| Force walls | A ring of force walls goes up around it for several seconds. | No | No | +| Toughens skin | Takes some of the sting out of brute and burn damage for a short time. | Yes | No | +| Feeds plants | Waters and tends planted trays nearby. Sometimes draws a butterfly. | Yes | No | +| Shockwave | Contact or bursts. After a short wind-up, throws everyone and everything loose outward and knocks people down. | No | Yes | +| Drains life | Hooks a beam into someone, then tears health and stamina away a few seconds later. | Yes | Yes | +| Steals life | Contact or bursts. Heals one person by hurting everyone else nearby. | Yes | Yes | +| Vents plasma | Plasma into the room, from a limited supply. | No | Yes | +| Throws things | Lifts loose objects and throws them at the nearest person. | Yes | Yes | +| Raises the dead | Revives one body beside it that died under five minutes ago, if the person is still there to come back. Three uses, then dull. | No | No | +| Vents sleeping gas | Nitrous oxide into the room, from a limited supply. | No | Yes | +| Fires spines | Contact or bursts. A volley of bone spines in every direction. | No | Yes | +| Speeds people up | Faster on their feet for a short time. | Yes | No | +| Weeps fluid | Puddles of water, salt water, sugar, oil or vinegar. | Yes | No | + +## Hazards and logging + +- **Artifacts act on you.** A field or burst reaches everyone in range, and a contact effect hits whoever touches it. Bio suit and hood together stop most of an effect. Latex gloves and science goggles add the rest. Full protection also blocks the good effects. +- **Harmful effects are logged as attacks.** The person answering for it is whoever last woke, hit, splashed or hauled the artifact. For a utilizer it is whoever switched it on. A field logs each victim at most twice a minute. Nothing stops an artifact hurting another crew, and admins can see who did it. +- **Tagging, labelling, harvesting and selling are logged too,** and so is freeing a find that wakes an ambush. +- **Bolted artifacts act on your ship.** One that vents gas, irradiates or hurts people does it in your corridor. From de9458b7cd5a7f54589049d1fd7b08f91a7b800c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:38:30 -0800 Subject: [PATCH 35/72] Join the xenoarchaeology parts - Dug curios and strange objects are marked for outpost buyers when they come out of the rock, and every curio type pays destructive-analyzer research once per techweb (20 to 60). Only a dug strange object pays. - Add the specimen crate design to the Xenoarchaeology node. A large find now gets into a specimen crate by being dragged onto the open crate or pushed onto it and the lid shut; before, no crate could take one, so no find could pass an outpost checkpoint. - Undug finds pay no research in the core value function. - A spent effect (Resurrect with no lives, a gas vent run dry) sells as common and earns no voucher. - The oxygen, nitrogen and carbon dioxide vent holds a finite reserve like the plasma and sleeping-gas vents; harvest copies share a vent's reserve instead of starting a fresh one. - Exotic Particles joins the zone research gate list (46 nodes). - Tests: spent pricing, gas reserves, one-sided heals, design routes, crate loading through the closing path, and the dig-to-buyer join. --- .../unit_tests/voidcrew_xenoarch_core.dm | 9 ++ .../unit_tests/voidcrew_xenoarch_dig.dm | 2 +- .../voidcrew_xenoarch_effects_polaris.dm | 20 ++++ .../unit_tests/voidcrew_xenoarch_study.dm | 30 ++++- .../unit_tests/voidcrew_xenoarch_trade.dm | 98 +++++++++++++++-- .../unit_tests/voidcrew_zone_research.dm | 3 +- .../modules/xenoarch/core/artifact_effect.dm | 9 ++ .../modules/xenoarch/core/artifact_pricing.dm | 10 +- voidcrew/modules/xenoarch/dig/curios.dm | 13 +-- voidcrew/modules/xenoarch/dig/site_walls.dm | 1 + voidcrew/modules/xenoarch/dig/tools.dm | 2 +- voidcrew/modules/xenoarch/effects/gas.dm | 30 ++++- .../xenoarch/effects_polaris/plasma_vent.dm | 7 +- .../xenoarch/effects_polaris/resurrect.dm | 3 + voidcrew/modules/xenoarch/study/research.dm | 18 ++- voidcrew/modules/xenoarch/trade/buyers.dm | 6 +- voidcrew/modules/xenoarch/trade/metrics.dm | 4 +- .../modules/xenoarch/trade/specimen_crate.dm | 104 ++++++++++++++++++ 18 files changed, 336 insertions(+), 33 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm index 08abd9a3cbc..f8168ec71b3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_core.dm @@ -27,7 +27,9 @@ var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) find.replace_effects(/datum/artifact_effect/heal, null, 2) TEST_ASSERT_EQUAL(artifact_sale_value(find), 0, "a find never dug sold for credits") + TEST_ASSERT_EQUAL(artifact_research_value(find), 0, "a find never dug paid research") find.stamp_origin(2) + TEST_ASSERT_EQUAL(artifact_research_value(find), 150, "a dug yellow uncommon find pays the wrong research") TEST_ASSERT(!find.stamp_origin(3), "a second stamp replaced the first") TEST_ASSERT_EQUAL(artifact_sale_value(find), 135, "an unlabelled yellow uncommon find") var/trigger_word = artifact_trigger_name(find.main_effect.trigger) @@ -169,6 +171,13 @@ TEST_ASSERT(healed >= 29.5, "artifact healing stopped short of the cap ([healed])") TEST_ASSERT_EQUAL(artifact_capped_heal(patient, 50), 0, "a fresh call slipped past the cap") + // A wound of one kind takes the whole heal, not half of it. + var/mob/living/carbon/human/consistent/bruised = allocate(/mob/living/carbon/human/consistent) + bruised.take_overall_damage(40, 0) + var/mended = artifact_capped_heal(bruised, 12) + TEST_ASSERT(mended >= 11.5 && mended <= 12.5, "a brute-only wound healed [mended] of 12") + TEST_ASSERT_EQUAL(artifact_capped_heal(allocate(/mob/living/carbon/human/consistent), 12), 0, "an unhurt body used up heal") + /// Deleting an artifact deletes its effects and leaves them no holder. /datum/unit_test/voidcrew_xenoarch_cleanup/Run() var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm index d060ea1ed8d..99b71429a33 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_dig.dm @@ -171,7 +171,7 @@ var/obj/item/xenoarch_curio/bowl = rock.free_find(null) TEST_ASSERT(istype(bowl), "a rock at depth gave no curio") TEST_ASSERT_EQUAL(bowl?.origin_band, 2, "the curio was not stamped with its rock's band") - TEST_ASSERT_EQUAL(bowl?.dig_value, 80, "a bowl was not marked at the pottery price") + TEST_ASSERT_EQUAL(xenoarch_curio_value(bowl), 80, "a bowl was not marked at the pottery price") TEST_ASSERT_EQUAL(xenoarch_find_band(bowl), 2, "xenoarch_find_band() can't read a curio") qdel(bowl) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm index 9a1f262b78e..12f42757e47 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm @@ -274,6 +274,26 @@ TEST_ASSERT_EQUAL(vent.vent(6), 0, "a dry plasma vent kept venting") var/list/plasma = here.return_air().gases[/datum/gas/plasma] TEST_ASSERT(plasma && plasma[MOLES] >= 9.5, "the vented plasma is not on the tile") + TEST_ASSERT(vent.is_spent(), "a dry plasma vent is not spent") + here.return_air().copy_from(original) + here.air_update_turf(FALSE, FALSE) + + // The plain gas vent holds a reserve too, under one gas contract (600 mol), and a harvest copy + // takes half of what is left rather than a fresh one. + find.replace_effects(/datum/artifact_effect/gas, null, 3) + var/datum/artifact_effect/gas/plain = find.main_effect + TEST_ASSERT(plain.reserve > 0 && plain.reserve < 600, "a gas vent rolled a reserve of [plain.reserve] mol") + plain.reserve = 100 + var/obj/item/anomaly_battery/battery = allocate(/obj/item/anomaly_battery) + var/datum/artifact_effect/gas/copy = plain.make_harvest_copy(battery) + TEST_ASSERT_EQUAL(copy.reserve + plain.reserve, 100, "a harvest copy made more gas than the vent held") + TEST_ASSERT_EQUAL(copy.gas_type, plain.gas_type, "a harvest copy vents a different gas") + qdel(copy) + plain.reserve = 5 + plain.max_pressure = 1000 + TEST_ASSERT(plain.vent(8), "the gas vent did not vent indoors") + TEST_ASSERT(!plain.vent(8), "a dry gas vent kept venting") + TEST_ASSERT(plain.is_spent(), "a dry gas vent is not spent") here.return_air().copy_from(original) here.air_update_turf(FALSE, FALSE) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm index 951fa455106..8bb305fae4a 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_study.dm @@ -187,8 +187,14 @@ TEST_ASSERT_EQUAL(SSresearch.techweb_node_by_id("xenoarch_exotic_particles").zone_permission, 1, "the harvester node should need yellow standing (ZONE_PERMISSION_YELLOW)") var/datum/design/board/analyser_board = SSresearch.techweb_designs["xenoarch_analyser"] TEST_ASSERT_EQUAL(analyser_board.build_path, /obj/item/circuitboard/machine/artifact_analyser, "the analyser design prints the wrong board") - var/list/curio_points = SSresearch.techweb_point_items[/obj/item/relic] + var/list/curio_points = SSresearch.techweb_point_items[/obj/item/relic/excavated] TEST_ASSERT_EQUAL(curio_points?[TECHWEB_POINT_TYPE_GENERIC], 60, "the strange object pays no research in the destructive analyzer") + TEST_ASSERT_NULL(SSresearch.techweb_point_items[/obj/item/relic], "a strange object from maintenance pays research like a dug one") + // Every curio a dig can give pays something, and nothing pays more than the cheapest node. + for(var/curio_type in subtypesof(/obj/item/xenoarch_curio)) + var/list/points = SSresearch.techweb_point_items[curio_type] + var/value = points?[TECHWEB_POINT_TYPE_GENERIC] + TEST_ASSERT(value >= 20 && value <= 60, "[curio_type] pays [value || 0] research in the destructive analyzer") /** * Every design the xenoarchaeology nodes list exists. The dig, use and trade parts define most of @@ -205,3 +211,25 @@ for(var/design_id in expected[node_id]) TEST_ASSERT_NOTNULL(SSresearch.techweb_designs[design_id], "[node_id] lists design [design_id], which no part defines") TEST_ASSERT(design_id in node?.design_ids, "[node_id] lost design [design_id]") + +/** + * Every xenoarchaeology design has exactly one way in: the hand kit from the autolathe at round + * start, or one research node. Nothing is both, and nothing sits in two nodes. + */ +/datum/unit_test/voidcrew_xenoarch_design_routes/Run() + var/list/node_count = list() + for(var/node_id in SSresearch.techweb_nodes) + var/datum/techweb_node/node = SSresearch.techweb_nodes[node_id] + for(var/design_id in node.design_ids) + node_count[design_id] = (node_count[design_id] || 0) + 1 + var/checked = 0 + for(var/design_id in SSresearch.techweb_designs) + if(!findtext(design_id, "xenoarch_", 1, 10)) + continue + var/datum/design/design = SSresearch.techweb_designs[design_id] + var/routes = node_count[design_id] || 0 + if(RND_CATEGORY_INITIAL in design.category) + routes++ + TEST_ASSERT_EQUAL(routes, 1, "design [design_id] has [routes] routes (nodes: [node_count[design_id] || 0])") + checked++ + TEST_ASSERT(checked >= 17, "only [checked] xenoarchaeology designs were found") diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm index 8bba8720162..1a46768f168 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_trade.dm @@ -162,6 +162,21 @@ TEST_ASSERT_EQUAL(vouchers, 1, "Vex didn't pay exactly one voucher for an extreme find") tidy(seller) + // A spent extreme is a curio now: priced as common, and no voucher. + var/obj/machinery/artifact/dull = allocate(/obj/machinery/artifact, get_step(seller, EAST), 3) + dull.replace_effects(/datum/artifact_effect/resurrect, null, 3) + TEST_ASSERT_EQUAL(dull.main_effect.tier, 4, "the resurrect effect is no longer extreme") + TEST_ASSERT_EQUAL(artifact_sale_value(dull), 560, "a fresh unlabelled red resurrect find is not worth 560") + var/datum/artifact_effect/resurrect/lives = dull.main_effect + lives.uses_left = 0 + TEST_ASSERT(lives.is_spent(), "a resurrect with no lives left is not spent") + TEST_ASSERT_EQUAL(artifact_sale_value(dull), 140, "a spent red resurrect find kept its extreme price") + TEST_ASSERT_EQUAL(shop.xenoarch_buyer.vouchers_for(dull), 0, "Vex pays a voucher for a spent extreme find") + TEST_ASSERT(!(shop.xenoarch_buyer.closing_line(dull) in shop.xenoarch_buyer.lines["live"]), "Vex calls a spent find live") + TEST_ASSERT_EQUAL(row.settle(dull, seller, account, null), 140, "Vex paid the wrong credits for a spent find") + TEST_ASSERT(!(locate(/obj/item/stack/trade_voucher) in near_seller(seller)), "Vex handed over a voucher for a spent find") + tidy(seller) + // The others pay credits only, extreme or not var/datum/outpost_shop/general/halcyon = allocate(/datum/outpost_shop/general) var/obj/machinery/artifact/other = allocate(/obj/machinery/artifact, get_step(seller, EAST), 3) @@ -185,10 +200,11 @@ seller.stop_pulling() var/obj/structure/closet/crate/specimen/crate = allocate(/obj/structure/closet/crate/specimen, beside) - if(crate.opened) - crate.close() - find.forceMove(crate) + if(!crate.opened) + crate.open() + TEST_ASSERT(crate.load_find(find, seller), "the find would not heave into the open crate") TEST_ASSERT(find.loc == crate, "the find would not go in the crate") + TEST_ASSERT(!crate.opened, "the lid did not shut on the find") TEST_ASSERT(findtext(jointext(crate.examine(seller), " "), find.serial), "the crate lid doesn't show the find's tag") seller.start_pulling(crate) TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 1, "a find in a pulled crate did not count") @@ -197,7 +213,7 @@ TEST_ASSERT_EQUAL(length(row.sellable_finds(seller)), 0, "an open crate still counted as holding the find") seller.stop_pulling() - // A plain crate works too + // A plain crate a find got into some other way (an exosuit clamp, an admin) counts too var/obj/structure/closet/crate/plain = allocate(/obj/structure/closet/crate, beside) if(plain.opened) plain.close() @@ -246,6 +262,74 @@ TEST_ASSERT_EQUAL(barnaby_row.demand, 5, "a curio sale did not use up demand") TEST_ASSERT(!barnaby_row.try_sell(seller, null), "a curio sold that wasn't there") + // The whole join: a curio brushed out of a strange rock is marked, sells at its class price, and + // pays research once in a destructive analyzer. + var/obj/item/strange_rock/rock = allocate(/obj/item/strange_rock, get_turf(seller), 1, "house") + rock.find_type = /obj/item/xenoarch_curio/urn + rock.target_depth = 10 + rock.dug = 10 + var/obj/item/xenoarch_curio/urn = rock.free_find(null) + TEST_ASSERT(istype(urn), "a strange rock at depth gave no curio") + TEST_ASSERT(barnaby_row.matches(urn), "Barnaby refused a curio dug out of a strange rock") + var/list/urn_points = techweb_item_point_check(urn) + TEST_ASSERT_EQUAL(urn_points?[TECHWEB_POINT_TYPE_GENERIC], 30, "a dug urn pays the wrong research") + seller.put_in_hands(urn) + var/before = account.account_balance + TEST_ASSERT(barnaby_row.try_sell(seller, null), "a dug curio didn't sell") + TEST_ASSERT_EQUAL(account.account_balance - before, 80, "a dug urn paid the wrong amount") + + var/obj/item/strange_rock/relic_rock = allocate(/obj/item/strange_rock, get_turf(seller), 2, "technical") + relic_rock.find_type = /obj/item/relic/excavated + relic_rock.target_depth = 10 + relic_rock.dug = 10 + var/obj/item/relic/excavated/oddity = relic_rock.free_find(null) + TEST_ASSERT(istype(oddity), "a strange rock at depth gave no strange object") + TEST_ASSERT(boffin_row.matches(oddity), "Boffin refused a dug strange object") + TEST_ASSERT(!barnaby_row.matches(oddity), "Barnaby took a dug strange object") + qdel(oddity) + +/// A large find gets into a specimen crate the way a crew would: pushed onto the open crate and the +/// lid shut, or heaved in. Never two to a crate, never into a shut one, never into a plain crate. +/datum/unit_test/voidcrew_xenoarch_trade/crating/Run() + var/turf/start = run_loc_floor_bottom_left + var/turf/crate_spot = get_step(start, EAST) + var/turf/plain_spot = get_step(start, NORTH) + var/obj/machinery/artifact/find = allocate(/obj/machinery/artifact, start, 1) + var/obj/structure/closet/crate/specimen/crate = allocate(/obj/structure/closet/crate/specimen, crate_spot) + if(crate.opened) + crate.close() + TEST_ASSERT(!find.Move(crate_spot, EAST), "a find was pushed onto a shut crate") + TEST_ASSERT_EQUAL(find.loc, start, "a find ended up on a shut crate") + TEST_ASSERT(!crate.load_find(find), "a find was heaved into a shut crate") + + crate.open() + TEST_ASSERT(find.Move(crate_spot, EAST), "a find could not be pushed onto the open specimen crate") + TEST_ASSERT(crate.close(), "the crate would not shut on a find") + TEST_ASSERT_EQUAL(find.loc, crate, "shutting the lid did not take the find in") + TEST_ASSERT(find.suppressed, "a crated find was not held still") + + // Opened again, the find sits in the crate; a second find has no room by either path. + crate.open() + TEST_ASSERT_EQUAL(find.loc, crate_spot, "opening the crate did not leave the find in it") + var/obj/machinery/artifact/other = allocate(/obj/machinery/artifact, start, 1) + TEST_ASSERT(!other.Move(crate_spot, EAST), "two finds were pushed into one crate") + TEST_ASSERT(!crate.load_find(other), "two finds were heaved into one crate") + + // Bolted finds don't go anywhere. + find.forceMove(get_step(crate_spot, EAST)) + other.set_anchored(TRUE) + TEST_ASSERT(!crate.load_find(other), "a bolted find was heaved into a crate") + other.set_anchored(FALSE) + TEST_ASSERT(crate.load_find(other), "a loose find beside the open crate would not heave in") + TEST_ASSERT_EQUAL(other.loc, crate, "a heaved find is not in the crate") + + // A plain crate takes no large find. + var/obj/structure/closet/crate/plain = allocate(/obj/structure/closet/crate, plain_spot) + if(!plain.opened) + plain.open() + var/obj/machinery/artifact/third = allocate(/obj/machinery/artifact, start, 1) + TEST_ASSERT(!third.Move(plain_spot, NORTH), "a find was pushed into an open plain crate") + /// The checkpoint turns back a loose find, lets a crated one through, and never stops one leaving. /datum/unit_test/voidcrew_xenoarch_trade/checkpoint/Run() var/datum/outpost_checkpoint/checkpoint = allocate(/datum/outpost_checkpoint) @@ -264,9 +348,9 @@ checkpoint.exit_request = FALSE var/obj/structure/closet/crate/specimen/crate = allocate(/obj/structure/closet/crate/specimen, chamber) - if(crate.opened) - crate.close() - find.forceMove(crate) + if(!crate.opened) + crate.open() + TEST_ASSERT(crate.load_find(find), "the find would not heave into the open crate") TEST_ASSERT_EQUAL(length(checkpoint.inspect_chamber()), 0, "a crated find was turned back") var/datum/outpost_shop/vendor/checkpoint/halcyon/desk = allocate(/datum/outpost_shop/vendor/checkpoint/halcyon) diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm index 375134ee545..67785115c32 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm @@ -123,6 +123,7 @@ /datum/techweb_node/nanite_remotes = 1, /datum/techweb_node/restricted_tech = 2, /datum/techweb_node/desynchronization = 2, + /datum/techweb_node/xenoarch_exotic_particles = 1, ) var/gated = 0 for(var/node_id in SSresearch.techweb_nodes) @@ -131,7 +132,7 @@ if(node.zone_permission) gated++ TEST_ASSERT_EQUAL(gated, length(expected), "The direct-research gate covers the wrong number of nodes") - TEST_ASSERT_EQUAL(gated, 45, "The direct-research gate must cover exactly forty-five nodes") + TEST_ASSERT_EQUAL(gated, 46, "The direct-research gate must cover exactly forty-six nodes") TEST_ASSERT_EQUAL(SSresearch.techweb_node_by_id("nanite_military").type, /datum/techweb_node/nanite_combat, "Military nanites changed id") /// Gated gear left the open nodes it used to share, and only sits on its new node. diff --git a/voidcrew/modules/xenoarch/core/artifact_effect.dm b/voidcrew/modules/xenoarch/core/artifact_effect.dm index b9531404a84..bbe595b16a0 100644 --- a/voidcrew/modules/xenoarch/core/artifact_effect.dm +++ b/voidcrew/modules/xenoarch/core/artifact_effect.dm @@ -304,6 +304,15 @@ var/mob/responsible = get_responsible() log_combat(responsible || holder, victim, "exposed", holder, "to [log_name]") +/// Hook: it had a limited number of uses (lives, a gas reserve) and has none left. A spent +/// effect does nothing more and sells as common (pricing_tier()). +/datum/artifact_effect/proc/is_spent() + return FALSE + +/// The tier buyers price it at: its own, or common once it is spent. +/datum/artifact_effect/proc/pricing_tier() + return is_spent() ? ARTIFACT_TIER_COMMON : tier + /// What the analyser can read off it: list("kind", "release", "family"), in report words. /datum/artifact_effect/proc/report_parts() var/datum/artifact_energy_kind/kind = artifact_energy_kind(energy_kind) diff --git a/voidcrew/modules/xenoarch/core/artifact_pricing.dm b/voidcrew/modules/xenoarch/core/artifact_pricing.dm index f1fece3ec39..d8d4cf6aec1 100644 --- a/voidcrew/modules/xenoarch/core/artifact_pricing.dm +++ b/voidcrew/modules/xenoarch/core/artifact_pricing.dm @@ -13,7 +13,12 @@ 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()) + return xenoarch_price_for(artifact.origin_band, main.pricing_tier(), artifact.label_accuracy(), artifact.damaged, artifact.harvests_taken()) + +/// The tier buyers price this artifact at: its main effect's, or common once that effect is spent. +/// Vex's voucher reads this too, so a spent extreme earns none. +/proc/artifact_price_tier(obj/machinery/artifact/artifact) + return artifact?.main_effect?.pricing_tier() || ARTIFACT_TIER_COMMON /** * The pure price function behind artifact_sale_value(). @@ -52,7 +57,8 @@ return round(.) /// Research points this artifact pays on its first full analysis, or 0 once paid or if it was never dug. +/// A spent effect still studies at its own tier: it is the same thing, only emptied. /proc/artifact_research_value(obj/machinery/artifact/artifact) - if(QDELETED(artifact) || artifact.research_paid || !artifact.main_effect) + if(QDELETED(artifact) || !artifact.is_dug() || 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/curios.dm b/voidcrew/modules/xenoarch/dig/curios.dm index fc84bc654d4..9f5a85c40bf 100644 --- a/voidcrew/modules/xenoarch/dig/curios.dm +++ b/voidcrew/modules/xenoarch/dig/curios.dm @@ -171,13 +171,8 @@ GLOBAL_LIST_INIT(xenoarch_curio_values, list( */ /proc/xenoarch_dig_mark_find(obj/item/find, value) if(!istype(find) || !value) - return - if(istype(find, /obj/item/xenoarch_curio)) - var/obj/item/xenoarch_curio/curio = find - curio.dig_value = value - else if(istype(find, /obj/item/relic/excavated)) - var/obj/item/relic/excavated/relic = find - relic.dig_value = value + return FALSE + return xenoarch_mark_curio(find, value) // ===== Research notes ===== @@ -196,8 +191,6 @@ GLOBAL_LIST_INIT(xenoarch_curio_values, list( /obj/item/relic/excavated /// ZONE_* it was dug in, or null var/origin_band - /// Credits an outpost buyer pays for it, once marked as dug - var/dig_value = 0 // ===== Curios ===== @@ -212,8 +205,6 @@ GLOBAL_LIST_INIT(xenoarch_curio_values, list( var/curio_class = XENOARCH_CURIO_FOSSIL /// ZONE_* it was dug in, or null if it never came out of the ground var/origin_band - /// Credits an outpost buyer pays for it, once marked as dug - var/dig_value = 0 /// Icon states to pick from var/list/variants /// Descriptions to pick from diff --git a/voidcrew/modules/xenoarch/dig/site_walls.dm b/voidcrew/modules/xenoarch/dig/site_walls.dm index 98f43995c5e..d0ecaed8432 100644 --- a/voidcrew/modules/xenoarch/dig/site_walls.dm +++ b/voidcrew/modules/xenoarch/dig/site_walls.dm @@ -91,6 +91,7 @@ GLOBAL_LIST_INIT(xenoarch_rock_tints, list( playsound(src, 'sound/effects/rock/rock_break.ogg', 50, TRUE) if(XENOARCH_FIND_SMALL) freed = new /obj/item/strange_rock(src, site.band, site.site_kind) + playsound(src, 'sound/effects/rock/rocktap3.ogg', 50, TRUE) if(user) to_chat(user, span_notice("A lump of veined rock drops out of the face.")) return freed diff --git a/voidcrew/modules/xenoarch/dig/tools.dm b/voidcrew/modules/xenoarch/dig/tools.dm index 656b0f99e88..45cbff9c3cb 100644 --- a/voidcrew/modules/xenoarch/dig/tools.dm +++ b/voidcrew/modules/xenoarch/dig/tools.dm @@ -79,7 +79,7 @@ /obj/item/excavation/brush/quick name = "quick brush" - desc = "An excavation brush with a vibrating head. It clears grit in a third of the time." + desc = "An excavation brush with a vibrating head. It buzzes against your fingers." icon_state = "brush_quick" custom_materials = list(/datum/material/plastic = SMALL_MATERIAL_AMOUNT * 2, /datum/material/iron = SMALL_MATERIAL_AMOUNT * 2, /datum/material/gold = SMALL_MATERIAL_AMOUNT * 2) brush_time = XENOARCH_BRUSH_TIME_QUICK diff --git a/voidcrew/modules/xenoarch/effects/gas.dm b/voidcrew/modules/xenoarch/effects/gas.dm index 9e617b33629..228415d4d0d 100644 --- a/voidcrew/modules/xenoarch/effects/gas.dm +++ b/voidcrew/modules/xenoarch/effects/gas.dm @@ -1,7 +1,9 @@ // Ported from NovaSector (5ce2bd2215c0) modular_nova/modules/xenoarchartifacts/effects/gas.dm. // Voidcrew: oxygen, nitrogen or carbon dioxide only (plasma and nitrous oxide are their own // effects); room-temperature gas; vents indoors only, never into planet air; stops at a modest -// pressure; no destroy burst. +// pressure; no destroy burst. Like the Polaris vents (effects_polaris/plasma_vent.dm) it holds a +// finite reserve, less than one gas contract asks for, shared with any battery drawn off it, so it +// is never a free nitrogen well for gas contracts. /datum/artifact_effect/gas label_name = "Vents gas" @@ -12,6 +14,10 @@ var/gas_type = /datum/gas/oxygen /// It stops venting once its tile reaches this pressure (kPa) var/max_pressure = ONE_ATMOSPHERE + /// Moles left in it; it runs dry for good when this hits zero + var/reserve = 0 + /// Said it was dry + var/announced_dry = FALSE /datum/artifact_effect/gas/setup() var/static/list/gas_weights = list( @@ -21,10 +27,17 @@ ) gas_type = pick_weight(gas_weights) max_pressure = rand(105, 150) + reserve = rand(150, 300) +// Half of what is left goes with a harvest copy, so the reserve is shared, never doubled. /datum/artifact_effect/gas/copy_settings_to(datum/artifact_effect/gas/copy) copy.gas_type = gas_type copy.max_pressure = max_pressure + copy.reserve = round(reserve / 2) + reserve -= copy.reserve + +/datum/artifact_effect/gas/is_spent() + return reserve <= 0 /datum/artifact_effect/gas/do_effect_touch(mob/living/user) if(vent(rand(2, 8))) @@ -40,19 +53,32 @@ /datum/artifact_effect/gas/do_effect_destroy() return -/// Lets `moles` of its gas out onto its tile. Returns TRUE if any came out. +/// Lets up to `moles` of its gas out onto its tile, from its reserve. Returns TRUE if any came out. /datum/artifact_effect/gas/proc/vent(moles) if(moles <= 0) return FALSE var/turf/open/here = source_turf() if(!istype(here) || here.planetary_atmos || isspaceturf(here)) return FALSE + if(reserve <= 0) + run_dry(here) + return FALSE var/datum/gas_mixture/air = here.return_air() if(!air || air.return_pressure() >= max_pressure) return FALSE + moles = min(moles, reserve) + reserve -= moles var/datum/gas_mixture/puff = new puff.assert_gas(gas_type) puff.gases[gas_type][MOLES] = moles puff.temperature = T20C here.assume_air(puff) return TRUE + +/// Its reserve is spent: one last hiss, then nothing ever again. +/datum/artifact_effect/gas/proc/run_dry(turf/here) + if(announced_dry) + return + announced_dry = TRUE + here.visible_message(span_notice("[holder] gives a thin hiss, and nothing more comes out.")) + playsound(here, 'sound/effects/spray3.ogg', 25, TRUE, SHORT_RANGE_SOUND_EXTRARANGE) diff --git a/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm b/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm index 302bab411dd..24073c05e17 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/plasma_vent.dm @@ -27,10 +27,15 @@ reserve = rand(reserve_low, reserve_high) max_pressure = rand(110, 140) +// Half of what is left goes with a copy, so the reserve is shared, never doubled. /datum/artifact_effect/gas_vent/copy_settings_to(datum/artifact_effect/gas_vent/copy) - copy.reserve = reserve + copy.reserve = round(reserve / 2) + reserve -= copy.reserve copy.max_pressure = max_pressure +/datum/artifact_effect/gas_vent/is_spent() + return reserve <= 0 + /datum/artifact_effect/gas_vent/do_effect_touch(mob/living/user) if(vent(rand(3, 8))) to_chat(user, span_warning("Gas hisses out of [holder] around your hand.")) diff --git a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm index 88fdf320669..1b815b2fc7e 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/resurrect.dm @@ -46,6 +46,9 @@ /datum/artifact_effect/resurrect/copy_settings_to(datum/artifact_effect/resurrect/copy) copy.uses_left = uses_left +/datum/artifact_effect/resurrect/is_spent() + return uses_left <= 0 + /datum/artifact_effect/resurrect/do_effect_touch(mob/living/user) seek() diff --git a/voidcrew/modules/xenoarch/study/research.dm b/voidcrew/modules/xenoarch/study/research.dm index 7e9a5164088..f2056bf7284 100644 --- a/voidcrew/modules/xenoarch/study/research.dm +++ b/voidcrew/modules/xenoarch/study/research.dm @@ -45,8 +45,22 @@ * pleasant extra rather than a research route. */ GLOBAL_LIST_INIT(xenoarch_curio_research_points, list( - // The strange object a technical, house or temple site gives up is tg's relic. - /obj/item/relic = 60, + /obj/item/xenoarch_curio/shell = 20, + /obj/item/xenoarch_curio/plant_fossil = 25, + /obj/item/xenoarch_curio/bone = 25, + /obj/item/xenoarch_curio/bowl = 25, + /obj/item/xenoarch_curio/skull = 30, + /obj/item/xenoarch_curio/urn = 30, + /obj/item/xenoarch_curio/scrap = 30, + /obj/item/xenoarch_curio/sidearm = 35, + /obj/item/xenoarch_curio/seed_pod = 40, + /obj/item/xenoarch_curio/statuette = 45, + /obj/item/xenoarch_curio/instrument = 45, + /obj/item/xenoarch_curio/idol = 50, + /obj/item/xenoarch_curio/skeleton = 60, + // The strange object a site gives up is tg's relic. Only a dug one pays: maintenance relics and + // experimentor copies are the plain type. + /obj/item/relic/excavated = 60, )) /// Adds the curio table to tg's destructive-analyzer payouts. diff --git a/voidcrew/modules/xenoarch/trade/buyers.dm b/voidcrew/modules/xenoarch/trade/buyers.dm index 02ff921e467..f86e7bf32c1 100644 --- a/voidcrew/modules/xenoarch/trade/buyers.dm +++ b/voidcrew/modules/xenoarch/trade/buyers.dm @@ -186,9 +186,9 @@ return !COOLDOWN_FINISHED(src, inspect_cooldown) return TRUE -/// Vouchers this buyer adds for `find`. +/// Vouchers this buyer adds for `find`. A spent extreme is only a curiosity, and earns none. /datum/xenoarch_buyer/proc/vouchers_for(obj/machinery/artifact/find) - if(extreme_vouchers > 0 && find?.main_effect?.tier == ARTIFACT_TIER_EXTREME) + if(extreme_vouchers > 0 && find?.main_effect && artifact_price_tier(find) == ARTIFACT_TIER_EXTREME) return extreme_vouchers return 0 @@ -223,7 +223,7 @@ /// What the trader says as they pay for `find`. Only said once the find is theirs. /datum/xenoarch_buyer/proc/closing_line(obj/machinery/artifact/find) - if(find.main_effect?.tier == ARTIFACT_TIER_EXTREME) + if(find.main_effect && artifact_price_tier(find) == ARTIFACT_TIER_EXTREME) return line(XENOARCH_LINE_LIVE) if(!find.label) return line(XENOARCH_LINE_BLANK) diff --git a/voidcrew/modules/xenoarch/trade/metrics.dm b/voidcrew/modules/xenoarch/trade/metrics.dm index 9612690ccee..a6aa1f7aa2c 100644 --- a/voidcrew/modules/xenoarch/trade/metrics.dm +++ b/voidcrew/modules/xenoarch/trade/metrics.dm @@ -10,7 +10,9 @@ "serial" = find.serial, "accuracy" = round(find.label_accuracy(), 0.01), "labelled" = !!find.label, - "tier" = find.main_effect?.tier, + "tier" = find.main_effect ? artifact_price_tier(find) : null, + "effect_tier" = find.main_effect?.tier, + "spent" = !!find.main_effect?.is_spent(), "band" = xenoarch_band_name(find.origin_band), "harvests" = find.harvests_taken(), "damaged" = !!find.damaged, diff --git a/voidcrew/modules/xenoarch/trade/specimen_crate.dm b/voidcrew/modules/xenoarch/trade/specimen_crate.dm index cecd2daa49f..c550d5b6234 100644 --- a/voidcrew/modules/xenoarch/trade/specimen_crate.dm +++ b/voidcrew/modules/xenoarch/trade/specimen_crate.dm @@ -7,6 +7,11 @@ * the find and small things, never people or other machines, and shows the find's field tag on * the lid so a hold full of crates can be told apart. * + * It is also the only crate a large find can be loaded into. Open crates are dense here and the + * usual drag-to-stuff only takes items and people, so a find gets in one of two ways: drag it onto + * the open crate to heave it in (the lid shuts on it), or push it onto the open crate and shut the + * lid like any other crate. + * * Art: a recolour of tg's plain crate (icons/obj/storage/crates.dmi "crate"/"crateopen"), lead * grey with padded straps; "manifest" and "welded" are tg's own, copied so crate overlays line up. */ @@ -33,6 +38,88 @@ return FALSE return ..() +/// How long it takes to heave a large find into the crate. +#define SPECIMEN_CRATE_HEAVE_TIME (3 SECONDS) + +// A find dragged onto the open crate is heaved in. +/obj/structure/closet/crate/specimen/mouse_drop_receive(atom/movable/dropped, mob/living/user, params) + if(!istype(dropped, /obj/machinery/artifact)) + return ..() + heave_in(dropped, user) + +// The open crate lets a find be pushed onto it, so shutting the lid takes it in. +/obj/structure/closet/crate/specimen/CanAllowThrough(atom/movable/mover, border_dir) + . = ..() + if(!. && opened && istype(mover, /obj/machinery/artifact) && has_room_for(mover)) + return TRUE + +/// No other find is already in it or sitting in it, and `find` is loose. +/obj/structure/closet/crate/specimen/proc/has_room_for(obj/machinery/artifact/find) + if(find.anchored || (locate(/obj/machinery/artifact) in contents)) + return FALSE + for(var/obj/machinery/artifact/other in loc) + if(other != find) + return FALSE + return TRUE + +/** + * Whether `find` can be heaved in by `user` right now. Says why not unless `quiet` (the check a + * heave in progress keeps making). + */ +/obj/structure/closet/crate/specimen/proc/can_heave(obj/machinery/artifact/find, mob/living/user, quiet = FALSE) + var/reason + if(QDELETED(src) || QDELETED(find) || !isturf(loc) || !isturf(find.loc)) + reason = "can't get at it!" + else if(!opened) + reason = "the lid's shut!" + else if(find.anchored) + reason = "it's bolted down!" + else if(find.z != z || get_dist(find, src) > 1) + reason = "too far away!" + else if(!has_room_for(find)) + reason = "no room!" + else if(!can_close()) + reason = "something's in the way!" + if(reason && user && !quiet) + balloon_alert(user, reason) + return !reason + +/// Heaves `find` into the open crate and drops the lid on it. Sleeps. Returns TRUE if it went in. +/obj/structure/closet/crate/specimen/proc/heave_in(obj/machinery/artifact/find, mob/living/user) + if(!isliving(user) || DOING_INTERACTION(user, DOAFTER_SOURCE_XENOARCH)) + return FALSE + if(!can_heave(find, user)) + return FALSE + user.visible_message( + span_notice("[user] gets a grip on [find] and starts heaving it into [src]."), + span_notice("You get a grip on [find] and start heaving it into [src]."), + ) + // Getting your arms round it is a touch, like hauling it. + find.touched_by(user, accidental = TRUE) + if(!do_after(user, SPECIMEN_CRATE_HEAVE_TIME, find, extra_checks = CALLBACK(src, PROC_REF(can_heave), find, user, TRUE), interaction_key = DOAFTER_SOURCE_XENOARCH)) + return FALSE + return load_find(find, user) + +/// Sets `find` down in the open crate and shuts the lid on it. Returns TRUE if it ended up inside. +/obj/structure/closet/crate/specimen/proc/load_find(obj/machinery/artifact/find, mob/living/user) + if(!can_heave(find, user)) + return FALSE + if(user?.pulling == find) + user.stop_pulling() + find.forceMove(loc) + // The lid takes whatever sits in the crate, the find included, through the usual closing path. + if(!close(user) || find.loc != src) + return FALSE + playsound(src, 'sound/effects/bang.ogg', 25, TRUE) + if(user) + user.visible_message( + span_notice("[user] heaves [find] into [src] and drops the lid on it."), + span_notice("You heave [find] into [src] and drop the lid on it."), + ) + return TRUE + +#undef SPECIMEN_CRATE_HEAVE_TIME + /obj/structure/closet/crate/specimen/examine(mob/user) . = ..() if(opened) @@ -43,3 +130,20 @@ . += "Something heavy is strapped in." if(find.is_dug()) . += "A field tag clipped to the lid reads [find.serial]." + +// Printed in the field-gear node ("Xenoarchaeology", study/techweb.dm). It costs more iron than +// it breaks down into. +/datum/design/specimen_crate + name = "Specimen Crate" + desc = "A padded, lead-lined crate big enough for one large find." + id = "xenoarch_specimen_crate" + build_type = PROTOLATHE | AWAY_LATHE + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 5, + /datum/material/plastic = SHEET_MATERIAL_AMOUNT * 2, + ) + build_path = /obj/structure/closet/crate/specimen + category = list( + RND_CATEGORY_EQUIPMENT + RND_SUBCATEGORY_EQUIPMENT_SCIENCE, + ) + departmental_flags = DEPARTMENT_BITFLAG_SCIENCE | DEPARTMENT_BITFLAG_CARGO From 2c8846c61dddc100fe17ba50bc8d8df35c56a493 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:59:34 -0800 Subject: [PATCH 36/72] Fix runtimes in heal, hurt and labeler messages; scan pad sees spawned finds - span_notice(a ? b : c) expands to "" + a ? b : c, so every heal, hurt and robo variant message and the labeler's tagging message threw a type mismatch. The heal aura stopped partway through its targets each time. Parenthesise the choice. - A find created on a scan pad never "entered" it, so the analyser beside it did not watch it until it moved. The pad now also listens for atoms initialised on its tile. --- voidcrew/modules/xenoarch/effects/health.dm | 12 ++++++------ voidcrew/modules/xenoarch/study/labeler.dm | 2 +- voidcrew/modules/xenoarch/study/scanpad.dm | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/voidcrew/modules/xenoarch/effects/health.dm b/voidcrew/modules/xenoarch/effects/health.dm index 5c5135220d9..0bd78a64401 100644 --- a/voidcrew/modules/xenoarch/effects/health.dm +++ b/voidcrew/modules/xenoarch/effects/health.dm @@ -16,17 +16,17 @@ /datum/artifact_effect/heal/do_effect_touch(mob/living/user) if(mend(user, 12)) - to_chat(user, span_notice(robotic ? "Your damaged parts knit themselves back together." : "A soothing warmth spreads through you.")) + to_chat(user, span_notice((robotic ? "Your damaged parts knit themselves back together." : "A soothing warmth spreads through you."))) /datum/artifact_effect/heal/do_effect_aura(seconds_per_tick) for(var/mob/living/target as anything in living_in_range()) if(mend(target, 0.75 * seconds_per_tick) && prob(10)) - to_chat(target, span_notice(robotic ? "A faint hum steadies your systems." : "You feel a gentle warmth from something nearby.")) + to_chat(target, span_notice((robotic ? "A faint hum steadies your systems." : "You feel a gentle warmth from something nearby."))) /datum/artifact_effect/heal/do_effect_pulse(power) for(var/mob/living/target as anything in living_in_range()) if(mend(target, clamp(power * 0.6, 4, 20))) - to_chat(target, span_notice(robotic ? "A wave of energy mends your plating." : "A wave of warmth washes through you.")) + to_chat(target, span_notice((robotic ? "A wave of energy mends your plating." : "A wave of warmth washes through you."))) /// Heals one mob within the shared cap. Returns the amount healed. /datum/artifact_effect/heal/proc/mend(mob/living/target, amount) @@ -56,17 +56,17 @@ /datum/artifact_effect/hurt/do_effect_touch(mob/living/user) if(wound(user, 8)) - to_chat(user, span_warning(robotic ? "Your systems report sudden damage!" : "A painful discharge of energy strikes you!")) + to_chat(user, span_warning((robotic ? "Your systems report sudden damage!" : "A painful discharge of energy strikes you!"))) /datum/artifact_effect/hurt/do_effect_aura(seconds_per_tick) for(var/mob/living/target as anything in living_in_range()) if(wound(target, 0.5 * seconds_per_tick) && prob(10)) - to_chat(target, span_warning(robotic ? "Something nearby is stressing your plating." : "You feel a painful force radiating from something nearby.")) + to_chat(target, span_warning((robotic ? "Something nearby is stressing your plating." : "You feel a painful force radiating from something nearby."))) /datum/artifact_effect/hurt/do_effect_pulse(power) for(var/mob/living/target as anything in living_in_range()) if(wound(target, clamp(power * 0.3, 3, 12))) - to_chat(target, span_warning(robotic ? "A wave of energy buckles your plating!" : "A wave of energy tears at your skin!")) + to_chat(target, span_warning((robotic ? "A wave of energy buckles your plating!" : "A wave of energy tears at your skin!"))) /// Hurts one mob, split between brute and burn. Returns TRUE if it took any. /datum/artifact_effect/hurt/proc/wound(mob/living/target, amount) diff --git a/voidcrew/modules/xenoarch/study/labeler.dm b/voidcrew/modules/xenoarch/study/labeler.dm index c556fe510a7..878389cd2db 100644 --- a/voidcrew/modules/xenoarch/study/labeler.dm +++ b/voidcrew/modules/xenoarch/study/labeler.dm @@ -114,7 +114,7 @@ playsound(find, 'sound/items/handling/component_pickup.ogg', 30, TRUE) user.visible_message( span_notice("[user] presses a printed tag onto [find]."), - span_notice(replaced ? "You peel off the old tag and press the new one onto [find]." : "You press the tag onto [find]."), + span_notice((replaced ? "You peel off the old tag and press the new one onto [find]." : "You press the tag onto [find].")), ) return ITEM_INTERACT_SUCCESS diff --git a/voidcrew/modules/xenoarch/study/scanpad.dm b/voidcrew/modules/xenoarch/study/scanpad.dm index 54b795108ab..11a50416041 100644 --- a/voidcrew/modules/xenoarch/study/scanpad.dm +++ b/voidcrew/modules/xenoarch/study/scanpad.dm @@ -34,6 +34,8 @@ . = ..() var/static/list/loc_connections = list( COMSIG_ATOM_ENTERED = PROC_REF(on_entered), + // A find made right on the pad (an admin, a test) never "enters" it. + COMSIG_ATOM_AFTER_SUCCESSFUL_INITIALIZED_ON = PROC_REF(on_entered), COMSIG_ATOM_EXITED = PROC_REF(on_exited), ) AddElement(/datum/element/connect_loc, loc_connections) From 40301f38b2f58eac2fec479cbdc4f3c77988fc43 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:07:00 -0800 Subject: [PATCH 37/72] Describe loading the specimen crate and curio research on the xenoarchaeology page --- wiki/content/xenoarchaeology.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wiki/content/xenoarchaeology.md b/wiki/content/xenoarchaeology.md index ce80def2484..81bac647294 100644 --- a/wiki/content/xenoarchaeology.md +++ b/wiki/content/xenoarchaeology.md @@ -112,7 +112,7 @@ A large find is dense and unbolted, so you pull it. Pulling or bumping an artifa !!! warning "Hauled artifacts can go off" Latex gloves stop you waking a touch-triggered artifact. They do not stop one that is already awake from acting on you. -A large find can be shut in a crate. A bolted-down find will not go in. While a find is shut in a crate it does nothing at all. Any ordinary crate will take it. The **specimen crate** is a padded, lead-lined crate built for one large find: it takes the find and small things, never people or other machines, and its lid shows the find's catalogue number. +A large find travels in a **specimen crate**, a padded, lead-lined crate built for one large find. Open the crate, then either drag the find onto it (a few seconds' heave, and the lid shuts on it) or push the find onto the open crate and close the lid. Ordinary crates will not take one, and a bolted-down find will not go in. The specimen crate also holds small things, never people or other machines, and its lid shows the find's catalogue number. While a find is shut in a crate it does nothing at all. Every way into a trader outpost runs through a scan chamber. A loose artifact on the chamber floor makes the guard refuse the whole chamber ("Crate it first.") until someone takes it back out. A find in a shut crate passes. Going out is never a problem. Inside an outpost an artifact does nothing and cannot be bolted down. The lift between floors carries unbolted things standing in its alcove, so a crate rides up with you. @@ -244,7 +244,7 @@ Barnaby and Boffin pay by the piece. Vex buys none. | 150 | Statuette, strange instrument, idol, seed pod, fossil skeleton | | 200 | Strange object (Boffin only) | -Only finds that came out of a dig site sell. A strange object from maintenance or an experimentor copy is not one. Feeding a strange object to the destructive analyzer pays 60 research points, once per research server. +Only finds that came out of a dig site sell. A strange object from maintenance or an experimentor copy is not one. Feeding a dug curio or strange object to the destructive analyzer pays research points, once per kind per research server. Rarer finds pay more. ## Labels and triggers From 48e749f811ae5f1e54dfa928f53a079882e0e443 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:45:24 -0800 Subject: [PATCH 38/72] Let harmful artifact batteries act on whoever carries them --- .../voidcrew_xenoarch_effects_polaris.dm | 14 ++++++------ .../xenoarch/effects_polaris/disarm.dm | 4 ++-- .../effects_polaris/electric_field.dm | 9 +++++--- .../xenoarch/effects_polaris/life_drain.dm | 22 ++++++++++++++----- .../xenoarch/effects_polaris/poltergeist.dm | 2 +- .../effects_polaris/status_effects.dm | 4 +--- wiki/content/xenoarchaeology.md | 2 +- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm index 12f42757e47..0fd9b18a73f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm +++ b/voidcrew/modules/unit_tests/voidcrew_xenoarch_effects_polaris.dm @@ -100,7 +100,7 @@ /** * Harvested copies run from a battery item, usually in someone's hand (the utilizer). Every Polaris * effect runs from such a holder through the public API (wake, touch, tick) and the raw hooks - * without errors, and the harmful ones never turn on whoever carries the battery. A folder stands + * without errors, and the harmful ones work on whoever carries the battery too. A folder stands * in for the battery: these tests must not depend on another part's types. */ /datum/unit_test/voidcrew_xenoarch_polaris_battery_holder/Run() @@ -131,8 +131,6 @@ copy.do_effect_aura(2) copy.do_effect_pulse(copy.maximum_charges) copy.deactivate(quiet = TRUE) - if(copy.harmful) - TEST_ASSERT(carrier.is_holding(battery), "[effect_type] run from a battery made its carrier drop it") qdel(copy) carrier.fully_heal(HEAL_ALL) target.fully_heal(HEAL_ALL) @@ -141,15 +139,17 @@ if(!carrier.is_holding(battery)) carrier.put_in_active_hand(battery) - // The harmful ones spare their carrier outright. + // The harmful ones hit their carrier too. find.replace_effects(/datum/artifact_effect/electric_field, /datum/artifact_effect/disarm, 3) var/datum/artifact_effect/electric_field/field = find.main_effect.make_harvest_copy(battery) var/datum/artifact_effect/disarm/pry = find.second_effect.make_harvest_copy(battery) var/obj/item/crowbar/bar = allocate(/obj/item/crowbar) carrier.put_in_inactive_hand(bar) - TEST_ASSERT(!field.arc_to(carrier, 10, FALSE), "an electric field in a battery arced to its own carrier") - TEST_ASSERT(!pry.pry(carrier), "a disarm battery pried open its own carrier's hand") - TEST_ASSERT(carrier.is_holding(battery), "the carrier lost the battery") + TEST_ASSERT(field.arc_to(carrier, 10, FALSE), "an electric field in a battery spared its own carrier") + carrier.fully_heal(HEAL_ALL) + carrier.swap_hand() + TEST_ASSERT(pry.pry(carrier), "a disarm battery spared its own carrier's hand") + TEST_ASSERT(!carrier.is_holding(bar), "the disarm battery did not make its carrier drop the crowbar") qdel(field) qdel(pry) diff --git a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm index b6be89644fb..b454885922a 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/disarm.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/disarm.dm @@ -2,7 +2,7 @@ // Rewritten from Polaris (6cd19fe0dec6) code/modules/xenoarcheaology/effects/disarm.dm. Voidcrew: // pries open the hand people are using, so what they hold drops at their feet (never thrown, so // nothing lands in a chasm or lava); items that can't be dropped stay put. Run from a carried -// battery, it never pries open its carrier's hand. Logged like a weapon. +// battery, it pries open its carrier's hand too. Logged like a weapon. /datum/artifact_effect/disarm label_name = "Makes people drop things" @@ -28,7 +28,7 @@ /// Makes `target` drop what is in their active hand. Returns TRUE if it fell. /datum/artifact_effect/disarm/proc/pry(mob/living/target) - if(target.stat != CONSCIOUS || target == artifact_effect_visible_source(holder)) + if(target.stat != CONSCIOUS) return FALSE if(!prob(100 * artifact_exposure(target))) return FALSE diff --git a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm index 40925560bf2..20de4024364 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/electric_field.dm @@ -5,7 +5,7 @@ // Polaris shocked everyone in range for up to 40 burn. Field arcs never stun, bursts only knock // down briefly, and every arc is logged. A hand on it grounds the toucher: the charge leaps to // everyone else nearby instead, as in Polaris. Lights in range flicker as it charges. Run from a -// carried battery, it never arcs to its carrier. +// carried battery, it arcs to its carrier too. /// Lights one charge can flicker, so a room full of lamps costs no more than a few #define ARTIFACT_FIELD_MAX_FLICKERS 6 @@ -54,7 +54,7 @@ */ /datum/artifact_effect/electric_field/proc/arc_to(mob/living/target, damage, knockdown) var/atom/source = artifact_effect_visible_source(holder) - if(target.stat == DEAD || target == source || !conducts(target)) + if(target.stat == DEAD || !conducts(target)) return FALSE damage *= artifact_exposure(target) if(damage < 1) @@ -63,10 +63,13 @@ // Positional on purpose: only the carbon override has the jitter, stutter and stun arguments. if(!target.electrocute_act(damage, holder, 1, flags, 4 SECONDS, 2 SECONDS, 1 SECONDS)) return FALSE - source?.Beam(target, icon_state = "lightning[rand(1, 12)]", time = 0.5 SECONDS) + if(source != target) + source?.Beam(target, icon_state = "lightning[rand(1, 12)]", time = 0.5 SECONDS) if(issilicon(target) || (target.mob_biotypes & MOB_ROBOTIC)) to_chat(target, span_danger("ERROR: Electrical fault detected!")) target.adjust_stutter(3 SECONDS) + else if(source == target) + to_chat(target, span_danger("Sparks crawl up your arm!")) else to_chat(target, span_danger("A spark leaps from [source] to you!")) log_harm(target) diff --git a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm index c80f798728e..86d8cb5694a 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/life_drain.dm @@ -47,8 +47,6 @@ /datum/artifact_effect/life_drain/proc/can_hook(mob/living/target) if(target.stat == DEAD || hooked?[REF(target)]) return FALSE - if(target == artifact_effect_visible_source(holder)) - return FALSE return artifact_exposure(target) >= 0.5 /// Hooks a beam into `target`; the drain lands after the windup if they stay. Returns TRUE if it hooked. @@ -58,26 +56,38 @@ var/atom/origin = artifact_effect_visible_source(holder) if(!isatom(origin)) return FALSE - var/datum/beam/drain_beam = origin.Beam(target, icon_state = "drain_life", time = ARTIFACT_DRAIN_WINDUP + 0.5 SECONDS, maxdistance = range + 1) var/key = REF(target) hooked[key] = TRUE + playsound(target, 'sound/effects/magic/enter_blood.ogg', 35, TRUE) + // Carried in a battery, it feeds on whoever holds it: no beam to step out of. + if(origin == target) + target.visible_message( + span_danger("[target] shudders as something in [target.p_their()] grip starts to pull."), + span_userdanger("Something in your grip hooks into you. Something cold starts to pull."), + ) + addtimer(CALLBACK(src, PROC_REF(finish_hook), WEAKREF(target), null, key, damage, TRUE), ARTIFACT_DRAIN_WINDUP, TIMER_DELETE_ME) + return TRUE + var/datum/beam/drain_beam = origin.Beam(target, icon_state = "drain_life", time = ARTIFACT_DRAIN_WINDUP + 0.5 SECONDS, maxdistance = range + 1) target.visible_message( span_danger("A sickly beam lashes out from [origin] and hooks into [target]!"), span_userdanger("A sickly beam lashes out from [origin] and hooks into you. Something cold starts to pull."), ) - playsound(target, 'sound/effects/magic/enter_blood.ogg', 35, TRUE) addtimer(CALLBACK(src, PROC_REF(finish_hook), WEAKREF(target), WEAKREF(drain_beam), key, damage), ARTIFACT_DRAIN_WINDUP, TIMER_DELETE_ME) return TRUE /// The windup ended: drains `target` if the beam still holds them. -/datum/artifact_effect/life_drain/proc/finish_hook(datum/weakref/target_ref, datum/weakref/beam_ref, key, damage) +/datum/artifact_effect/life_drain/proc/finish_hook(datum/weakref/target_ref, datum/weakref/beam_ref, key, damage, held = FALSE) hooked -= key var/mob/living/target = target_ref?.resolve() var/datum/beam/drain_beam = beam_ref?.resolve() if(!target || target.stat == DEAD) qdel(drain_beam) return - if(QDELETED(drain_beam)) + if(held) + if(artifact_effect_visible_source(holder) != target) + to_chat(target, span_notice("The cold hook slips out of you.")) + return + else if(QDELETED(drain_beam)) to_chat(target, span_notice("The cold hook slips out of you.")) return qdel(drain_beam) diff --git a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm index 38783c19867..5a527a8351d 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/poltergeist.dm @@ -46,7 +46,7 @@ var/best_distance = INFINITY var/best_has_mind = FALSE for(var/mob/living/candidate in living_in_range()) - if(candidate.stat != CONSCIOUS || candidate == artifact_effect_visible_source(holder)) + if(candidate.stat != CONSCIOUS) continue var/has_mind = !!candidate.mind var/distance = get_dist(center, candidate) diff --git a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm index 4999ee237ee..fd83aec0b84 100644 --- a/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm +++ b/voidcrew/modules/xenoarch/effects_polaris/status_effects.dm @@ -312,10 +312,8 @@ log_harm(target) return TRUE -/// Who it works on. A harmful one carried in a battery spares whoever carries it. +/// Who it works on. A harmful one carried in a battery works on whoever carries it too. /datum/artifact_effect/bestow/proc/can_bestow(mob/living/target) - if(harmful && target == artifact_effect_visible_source(holder)) - return FALSE return target.stat != DEAD /// Hook: `target` just got their first dose. diff --git a/wiki/content/xenoarchaeology.md b/wiki/content/xenoarchaeology.md index 81bac647294..5cfbb38c9c4 100644 --- a/wiki/content/xenoarchaeology.md +++ b/wiki/content/xenoarchaeology.md @@ -193,7 +193,7 @@ The **exotic utilizer** is a handheld projector with a slot for the battery. Ope - A field or burst effect runs until the battery is empty, about six and a half minutes from full. - A contact effect waits for you to press the prongs against someone, yourself included. A full battery gives ten uses. -- A field or burst reaches everyone in range, and many effects do not spare the person holding it. +- A field or burst reaches everyone in range, including the person holding it. - It goes dark in a trader outpost. ### Smashing From 6c6529d2784495fc9be48bfc35a6251170ba9dcc Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:30:57 -0800 Subject: [PATCH 39/72] Charge ship saves only for upgrades, cheaper research, cap at 10 parts Machines at their standard parts save for free and come back the same; only upgraded ones are billed, since only their parts carry. Research costs 1 science part per 50 nodes. Upgrades and research together cost at most 10 parts. --- voidcrew/_DEFINES/ship_saves.dm | 5 +- .../modules/ship_saves/ship_save_progress.dm | 34 +++++++- .../modules/ship_saves/ship_save_survey.dm | 23 +++++- .../modules/unit_tests/voidcrew_ship_saves.dm | 78 ++++++++++++++----- wiki/content/player-outposts.md | 4 +- wiki/content/ship-parts.md | 2 +- 6 files changed, 117 insertions(+), 29 deletions(-) diff --git a/voidcrew/_DEFINES/ship_saves.dm b/voidcrew/_DEFINES/ship_saves.dm index 02a908bc24a..345553e3c2d 100644 --- a/voidcrew/_DEFINES/ship_saves.dm +++ b/voidcrew/_DEFINES/ship_saves.dm @@ -4,12 +4,15 @@ #define SHIP_SAVE_SLOTS 3 /// The least a save can cost, in ship parts of every class together. #define SHIP_SAVE_PRICE_MIN 5 +/// The most a save's upgraded machines and research cost together, in combat, science and trade parts. +/// The hull and carried funds are on top. +#define SHIP_SAVE_PRICE_MAX 10 /// 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 +#define SHIP_SAVE_NODES_PER_PART 50 /// 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. diff --git a/voidcrew/modules/ship_saves/ship_save_progress.dm b/voidcrew/modules/ship_saves/ship_save_progress.dm index 723a5f19f54..5d5f155fc83 100644 --- a/voidcrew/modules/ship_saves/ship_save_progress.dm +++ b/voidcrew/modules/ship_saves/ship_save_progress.dm @@ -1,5 +1,5 @@ /** - * What a paid-for save keeps beyond the hull: the stock parts in the machines on the price table, and one + * What a paid-for save keeps beyond the hull: the stock parts in upgraded 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. */ @@ -31,6 +31,36 @@ GLOBAL_LIST_INIT(ship_save_kept_power_parts, typecacheof(list( /obj/item/stock_parts/power_store/battery/bluespace/empty, ), only_root_path = TRUE)) +/** + * Whether a machine holds a stock part above tier 1, or a kept cell that holds more than the one its board + * comes with. Only these are charged for and only these carry their parts, so a stock machine saves for + * free and comes back the same. + */ +/proc/ship_save_machine_upgraded(obj/machinery/machine) + SHOULD_NOT_SLEEP(TRUE) + for(var/part in machine.component_parts) + if(istype(part, /datum/stock_part)) + var/datum/stock_part/stock_part = part + if(stock_part.tier > 1) + return TRUE + else if(istype(part, /obj/item/stock_parts/power_store)) + var/obj/item/stock_parts/power_store/power_part = part + if(GLOB.ship_save_kept_power_parts[power_part.type] && power_part.maxcharge > ship_save_default_charge(machine, power_part)) + return TRUE + return FALSE + +/// How much the cell a machine's board puts in this power part's slot holds. +/proc/ship_save_default_charge(obj/machinery/machine, obj/item/stock_parts/power_store/power_part) + SHOULD_NOT_SLEEP(TRUE) + var/obj/item/stock_parts/power_store/default_type = istype(power_part, /obj/item/stock_parts/power_store/battery) ? /obj/item/stock_parts/power_store/battery : /obj/item/stock_parts/power_store/cell + var/obj/item/circuitboard/machine/board = machine.circuit + if(istype(board)) + for(var/slot_type in board.req_components) + if(ispath(slot_type, /obj/item/stock_parts/power_store) && istype(power_part, slot_type)) + default_type = board.def_components?[slot_type] || slot_type + break + return initial(default_type.maxcharge) + /// 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) @@ -75,7 +105,7 @@ GLOBAL_LIST_INIT(ship_save_kept_power_parts, typecacheof(list( if(!ship_save_keeps_object(machine)) continue var/saved_type = machine.checkpoint_type() - if(GLOB.ship_save_priced_machines[saved_type]) + if(GLOB.ship_save_priced_machines[saved_type] && ship_save_machine_upgraded(machine)) var/list/part_paths = ship_save_part_paths(machine) if(length(part_paths)) var/key = machine_key(machine, port, saved_type) diff --git a/voidcrew/modules/ship_saves/ship_save_survey.dm b/voidcrew/modules/ship_saves/ship_save_survey.dm index 3ff1646910e..a1093d705aa 100644 --- a/voidcrew/modules/ship_saves/ship_save_survey.dm +++ b/voidcrew/modules/ship_saves/ship_save_survey.dm @@ -3,7 +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. Machines not on this table are free. Subtypes inherit, and a later, + * What upgraded machines cost to save, since their stock parts come back. A machine at stock parts, or one + * not on this table, is 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( @@ -100,13 +101,15 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list( // 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) + // A stock machine comes back the same for free. Only upgrades are paid for, because only they carry. + if(price && ship_save_machine_upgraded(machine)) 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"]++ + if(price) object_mark = class_mark[price["class"]] else if(istype(machine, /obj/machinery/power/shuttle_engine)) object_mark = "e" @@ -143,6 +146,22 @@ GLOBAL_LIST_INIT(ship_save_priced_machines, zebra_typecacheof(list( 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 + // Machines and research together never cost more than SHIP_SAVE_PRICE_MAX. The biggest class gives first. + if(total > SHIP_SAVE_PRICE_MAX) + var/list/class_parts = list() + for(var/part_class in items_by_class) + for(var/list/item as anything in items_by_class[part_class]) + class_parts[part_class] += item["parts"] + var/list/discount = list() + while(total > SHIP_SAVE_PRICE_MAX) + var/biggest + for(var/part_class in GLOB.ship_part_classes) + if(class_parts[part_class] - discount[part_class] > (biggest ? class_parts[biggest] - discount[biggest] : 0)) + biggest = part_class + discount[biggest]++ + total-- + for(var/part_class in discount) + items_by_class[part_class] += list(list("name" = "Yard discount", "count" = null, "parts" = -discount[part_class])) var/list/misc_items = items_by_class[PART_CLASS_MISC] var/hull_parts = CEILING(mass / SHIP_SAVE_MASS_PER_PART, 1) if(hull_parts) diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm index d295bb320cd..23814320258 100644 --- a/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm +++ b/voidcrew/modules/unit_tests/voidcrew_ship_saves.dm @@ -3,7 +3,8 @@ #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_NODES_PER_PART 50 // SHIP_SAVE_NODES_PER_PART +#define SHIP_SAVE_TEST_PRICE_MAX 10 // SHIP_SAVE_PRICE_MAX #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 @@ -424,6 +425,14 @@ return item return null +/// Swaps one of a machine's stock parts for a better one. +/datum/unit_test/voidcrew_ship_saves/proc/upgrade_part(obj/machinery/machine, part_base, better_part) + var/datum/stock_part/old_part = locate(part_base) in machine.component_parts + TEST_ASSERT_NOTNULL(old_part, "[machine.type] has no [part_base] to upgrade") + machine.component_parts -= old_part + machine.component_parts += GLOB.stock_part_datums[better_part] + machine.RefreshParts() + /// 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 @@ -433,13 +442,24 @@ 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) + // Only upgraded machines are billed: their parts are what a save carries. + for(var/i in 1 to 2) + var/obj/machinery/ship_combat/laser_turret/laser = allocate(/obj/machinery/ship_combat/laser_turret, tile) + upgrade_part(laser, /datum/stock_part/micro_laser, /datum/stock_part/micro_laser/tier4) + // The bill never uses a name a player gave it. + laser.name = "Big Bertha" + // A stock laser turret comes back the same, and is free. + allocate(/obj/machinery/ship_combat/laser_turret, tile) + var/obj/machinery/chem_dispenser/chem = allocate(/obj/machinery/chem_dispenser, tile) + upgrade_part(chem, /datum/stock_part/matter_bin, /datum/stock_part/matter_bin/tier2) + // Comes with a better cell than a plain one, which isn't an upgrade. allocate(/obj/machinery/chem_dispenser, tile) - allocate(/obj/machinery/chem_dispenser/drinks, tile) + var/obj/machinery/chem_dispenser/drinks/bar = allocate(/obj/machinery/chem_dispenser/drinks, tile) + upgrade_part(bar, /datum/stock_part/matter_bin, /datum/stock_part/matter_bin/tier2) + var/obj/machinery/mineral/ore_redemption/redemption = allocate(/obj/machinery/mineral/ore_redemption, tile) + upgrade_part(redemption, /datum/stock_part/matter_bin, /datum/stock_part/matter_bin/tier3) + // Nothing to upgrade, so nothing to pay. + allocate(/obj/machinery/porta_turret/ship_defense, 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) @@ -454,26 +474,23 @@ var/list/classes = list() for(var/list/entry as anything in survey["bill"]) classes += entry["class"] - 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] - 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") + TEST_ASSERT_EQUAL(classes.Join(","), "combat,science,trade,misc", "The bill's classes are in the wrong order") 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(laser_line["count"], 2, "A stock or hologram laser turret was billed") + TEST_ASSERT_EQUAL(bill_class(survey, "combat")["parts"], 2, "Wrong combat total") + TEST_ASSERT_NULL(bill_line(survey, "combat", "Defense turret"), "A turret with nothing to upgrade was billed") + TEST_ASSERT_EQUAL(bill_line(survey, "science", "Chem dispenser")?["parts"], 1, "An upgraded chem dispenser did not cost 1 science") + TEST_ASSERT_EQUAL(bill_line(survey, "science", "Chem dispenser")?["count"], 1, "A stock chem dispenser's own cell counted as an upgrade") 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") - TEST_ASSERT_NULL(bill_class(survey, "misc"), "Machines off the price table were charged in misc") + TEST_ASSERT_EQUAL(bill_line(survey, "trade", "Ore redemption machine")?["parts"], 1, "An upgraded ore redemption machine did not cost 1 trade") + TEST_ASSERT_NULL(bill_line(survey, "trade", "Cargo console"), "A console with nothing to upgrade was billed") + TEST_ASSERT_EQUAL(bill_line(survey, "misc", "Yard minimum")?["parts"], 1, "Machines off the price table were charged, or the minimum is wrong") 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"]["combat"], 2, "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_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") + TEST_ASSERT_EQUAL(survey["cost"]["misc"], 1, "The charge disagrees with the bill") // 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] @@ -573,6 +590,24 @@ 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), "combat", "Defense turret")?["parts"], 3, "Six turrets at 1 per 2 are not one line of 3") + // Machines and research stop at the cap, taken off the biggest class first. The hull is on top. + var/list/big_lines = list( + "Laser turret" = list("class" = "combat", "each" = 1, "count" = 9), + "Protolathe" = list("class" = "science", "each" = 1, "count" = 3), + "Ore silo" = list("class" = "trade", "each" = 1, "count" = 2), + ) + var/list/capped = ship_save_price_bill(big_lines, 300, SHIP_SAVE_TEST_NODES_PER_PART) + TEST_ASSERT_EQUAL(capped["total"], SHIP_SAVE_TEST_PRICE_MAX + 2, "Machines and research went over the cap, or the hull was capped") + TEST_ASSERT_EQUAL(capped["cost"]["combat"], 4, "The cap didn't come off the biggest class first") + TEST_ASSERT_EQUAL(capped["cost"]["science"], 4, "The cap took from the wrong class") + TEST_ASSERT_EQUAL(capped["cost"]["trade"], 2, "The cap took from a small class") + TEST_ASSERT_EQUAL(capped["cost"]["misc"], 2, "The cap touched the hull") + TEST_ASSERT_EQUAL(bill_line(capped, "combat", "Yard discount")?["parts"], -5, "The discount line doesn't show what came off") + TEST_ASSERT_EQUAL(bill_class(capped, "combat")["parts"], 4, "The class total doesn't match its lines") + TEST_ASSERT_NULL(bill_line(capped, "trade", "Yard discount"), "A class the cap didn't touch has a discount line") + var/list/at_cap = ship_save_price_bill(list("Laser turret" = list("class" = "combat", "each" = 1, "count" = SHIP_SAVE_TEST_PRICE_MAX)), 0) + TEST_ASSERT_NULL(bill_line(at_cap, "combat", "Yard discount"), "A bill at the cap was discounted") + 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") @@ -1910,6 +1945,7 @@ #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_PRICE_MAX #undef SHIP_SAVE_TEST_FUNDS_PER_PART #undef SHIP_SAVE_TEST_PLAN_MAX #undef SHIP_SAVE_TEST_PASSWORD_PRICE diff --git a/wiki/content/player-outposts.md b/wiki/content/player-outposts.md index 2d9fc92e1c4..c3e27d1e888 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 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. 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. +The price depends on what is aboard, and the console lists it before you pay. The hull costs misc parts. Weapons, defenses, lab, chemistry, cargo and mining machines cost nothing at their standard parts; once upgraded, weapons and defenses cost combat parts, lab and chemistry machines science parts, and cargo and mining machines trade parts. Upgraded machines and research together never cost more than 10 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. @@ -188,7 +188,7 @@ You can load a save in the same round you made it once the ship it came from is 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. +**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 50 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. diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md index 76176766ae6..8587e69fac9 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, 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. +[Saving a ship](player-outposts.md) in a ship bay also costs parts. The hull costs misc parts, which can be paid for at 2,500 credits each. The rest pays for the progress a save carries, in matching classes: upgraded weapons cost combat parts, upgraded lab and chemistry machines and research (1 science part per 50 nodes) cost science parts, and upgraded cargo and mining machines cost trade parts. Machines at their standard parts are free, upgrades and research together cost at most 10 parts, and every save costs at least 5 parts in total. Funds carry 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 b6f05f8af278541fe0c9ea0be347df181a59586d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:43:34 -0800 Subject: [PATCH 40/72] Fix gene powers that gene injector loot relies on DNA vault mutations no longer keep their buff when acquiring them fails, Laser Eyes costs major instability, and Shock Touch or a Mending Touch smite on another visitor counts as an attack at trader outposts. --- code/modules/station_goals/vault_mutation.dm | 28 ++++++++++++++++++++ tgstation.dme | 1 + voidcrew/edits/datums/mutations/sight.dm | 5 ++++ voidcrew/modules/trade/outpost_security.dm | 12 +++++++++ 4 files changed, 46 insertions(+) create mode 100644 voidcrew/edits/datums/mutations/sight.dm diff --git a/code/modules/station_goals/vault_mutation.dm b/code/modules/station_goals/vault_mutation.dm index db35d0c34fc..92666720bbe 100644 --- a/code/modules/station_goals/vault_mutation.dm +++ b/code/modules/station_goals/vault_mutation.dm @@ -8,6 +8,10 @@ /datum/mutation/breathless/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END ADD_TRAIT(acquirer, TRAIT_NOBREATH, GENETIC_MUTATION) /datum/mutation/breathless/on_losing(mob/living/carbon/human/owner)//this shouldnt happen under normal condition but just to be sure @@ -23,6 +27,10 @@ /datum/mutation/quick/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.add_movespeed_modifier(/datum/movespeed_modifier/dna_vault_speedup) /datum/mutation/quick/on_losing(mob/living/carbon/human/owner) @@ -38,6 +46,10 @@ /datum/mutation/tough/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.physiology.brute_mod *= 0.7 ADD_TRAIT(acquirer, TRAIT_PIERCEIMMUNE, GENETIC_MUTATION) @@ -55,6 +67,10 @@ /datum/mutation/dextrous/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.next_move_modifier *= 0.5 /datum/mutation/dextrous/on_losing(mob/living/carbon/human/owner) @@ -70,6 +86,10 @@ /datum/mutation/fire_immunity/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.physiology.burn_mod *= 0.5 acquirer.add_traits(list(TRAIT_RESISTHEAT, TRAIT_NOFIRE), GENETIC_MUTATION) @@ -87,6 +107,10 @@ /datum/mutation/quick_recovery/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.physiology.stun_mod *= 0.5 /datum/mutation/quick_recovery/on_losing(mob/living/carbon/human/owner) @@ -102,6 +126,10 @@ /datum/mutation/plasmocile/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END var/obj/item/organ/lungs/improved_lungs = acquirer.get_organ_slot(ORGAN_SLOT_LUNGS) ADD_TRAIT(owner, TRAIT_VIRUSIMMUNE, GENETIC_MUTATION) if(improved_lungs) diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..18bf3405591 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6840,6 +6840,7 @@ #include "voidcrew\edits\datums\components\spawner.dm" #include "voidcrew\edits\datums\elements\decals\_decal.dm" #include "voidcrew\edits\datums\mapgen\CaveGenerator.dm" +#include "voidcrew\edits\datums\mutations\sight.dm" #include "voidcrew\edits\datums\weather\weather.dm" #include "voidcrew\edits\datums\weather\weather_types\rain_storm.dm" #include "voidcrew\edits\datums\weather\weather_types\sand_storm.dm" diff --git a/voidcrew/edits/datums/mutations/sight.dm b/voidcrew/edits/datums/mutations/sight.dm new file mode 100644 index 00000000000..05216971af9 --- /dev/null +++ b/voidcrew/edits/datums/mutations/sight.dm @@ -0,0 +1,5 @@ +// Laser Eyes never set an instability, so it stacked for free with every other +// gene. Gene injector loot (voidcrew/modules/loot/gene_injectors.dm) hands it out, +// so it costs what the other major powers cost. +/datum/mutation/laser_eyes + instability = POSITIVE_INSTABILITY_MAJOR diff --git a/voidcrew/modules/trade/outpost_security.dm b/voidcrew/modules/trade/outpost_security.dm index 9b89bbd6146..0f6eeb321c7 100644 --- a/voidcrew/modules/trade/outpost_security.dm +++ b/voidcrew/modules/trade/outpost_security.dm @@ -121,6 +121,18 @@ GLOBAL_DATUM_INIT(outpost_pvp_enforcement, /datum/outpost_pvp_enforcement, new) SIGNAL_HANDLER register_pvp_aggression(victim, pilot) +// Touch spells land through the hand item's own interaction signal, which none +// of the victim-side signals above see. Shock Touch is an attack on any living +// target; Mending Touch only when it smites instead of healing. +/datum/action/cooldown/spell/touch/shock/cast_on_hand_hit(obj/item/melee/touch_attack/hand, atom/victim, mob/living/carbon/caster) + . = ..() + if(. && isliving(victim)) + GLOB.outpost_pvp_enforcement.register_pvp_aggression(victim, caster) + +/datum/action/cooldown/spell/touch/lay_on_hands/by_gods_light_i_smite_you(mob/living/carbon/smiter, mob/living/motherfucker_to_hurt, smite_multiplier) + . = ..() + GLOB.outpost_pvp_enforcement.register_pvp_aggression(motherfucker_to_hurt, smiter) + /// Firing a gun past a trader outpost's checkpoint without a permit marks the shooter as an armed trespasser (outpost_trespass.dm) /datum/outpost_pvp_enforcement/proc/on_outpost_gun_fired(mob/living/shooter, obj/item/gun/gun, atom/target) SIGNAL_HANDLER From e352560fa639d466849ed4bbd5d79f8e2056f3ac Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:43:39 -0800 Subject: [PATCH 41/72] Add gene injectors to zone loot One permanent injector per positive gene power, placed across the nine loot themes by flavour and power. An injector checks the target first and stays unused if the gene would not take, and none of them can be duplicated. Bought syndicate caches never pay one. --- tgstation.dme | 1 + voidcrew/modules/loot/gene_injectors.dm | 312 ++++++++++++++++++ voidcrew/modules/loot/themes/armory.dm | 7 + voidcrew/modules/loot/themes/expedition.dm | 11 + voidcrew/modules/loot/themes/industrial.dm | 11 + voidcrew/modules/loot/themes/medical.dm | 9 + voidcrew/modules/loot/themes/occult.dm | 12 + voidcrew/modules/loot/themes/plunder.dm | 2 + voidcrew/modules/loot/themes/research.dm | 12 + voidcrew/modules/loot/themes/syndicate.dm | 6 + voidcrew/modules/loot/themes/wardrobe.dm | 7 + .../trade/shop_catalog_black_market.dm | 3 +- 12 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 voidcrew/modules/loot/gene_injectors.dm diff --git a/tgstation.dme b/tgstation.dme index 18bf3405591..da65dbe8c7d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7244,6 +7244,7 @@ #include "voidcrew\modules\lich\lich_thrall.dm" #include "voidcrew\modules\lich\lich_wards.dm" #include "voidcrew\modules\loot\abandoned_crate.dm" +#include "voidcrew\modules\loot\gene_injectors.dm" #include "voidcrew\modules\loot\loot_guns.dm" #include "voidcrew\modules\loot\zone_loot.dm" #include "voidcrew\modules\loot\zone_mobs.dm" diff --git a/voidcrew/modules/loot/gene_injectors.dm b/voidcrew/modules/loot/gene_injectors.dm new file mode 100644 index 00000000000..f8806678b78 --- /dev/null +++ b/voidcrew/modules/loot/gene_injectors.dm @@ -0,0 +1,312 @@ +/** + * # Gene injector loot + * + * Single-use permanent DNA injectors found in zone loot caches (the loot theme tables). + * Each one adds exactly one mutation. Unlike the stock tg injectors they + * check the target before injecting and stay unused when the mutation + * can't take, so a find is never wasted on a corpse, the wrong species, a + * missing head or a conflicting gene. Every one carries TRAIT_NO_REPLICATE, + * so duplicators can't copy them. + * + * Bought syndicate caches strip these (shop_catalog_black_market.dm), and + * stock tg injectors are left alone for admins and mapped placements. + */ +/obj/item/dnainjector/loot + desc = "A single-use DNA injector with a handwritten label." + +/obj/item/dnainjector/loot/Initialize(mapload) + . = ..() + ADD_TRAIT(src, TRAIT_NO_REPLICATE, INNATE_TRAIT) + +/obj/item/dnainjector/loot/update_desc(updates) + . = ..() + desc = "[initial(desc)][used ? " This one is used up." : null]" + +/// The single mutation this injector adds. +/obj/item/dnainjector/loot/proc/get_loot_mutation() + return length(add_mutations) ? add_mutations[1] : null + +/// Whether the mutation would take on this target right now, mirroring /datum/mutation/proc/on_acquiring. +/obj/item/dnainjector/loot/proc/can_take(mob/living/carbon/target) + if(!ishuman(target) || target.stat == DEAD || !target.can_mutate()) + return FALSE + var/mutation_path = get_loot_mutation() + var/datum/mutation/template = GET_INITIALIZED_MUTATION(mutation_path) + if(!template) + return FALSE + var/mob/living/carbon/human/human_target = target + var/datum/mutation/existing = human_target.dna.get_mutation(mutation_path) + if(existing) + // Already permanent: nothing to add. A temporary copy can be made permanent. + return !length(existing.sources & list(MUTATION_SOURCE_ACTIVATED, MUTATION_SOURCE_MUTATOR)) + if(template.species_allowed && !template.species_allowed.Find(human_target.dna.species.id)) + return FALSE + if(template.health_req && human_target.health < template.health_req) + return FALSE + if(template.limb_req && !human_target.get_bodypart(template.limb_req)) + return FALSE + for(var/datum/mutation/held as anything in human_target.dna.mutations) + if((held.type in template.conflicts) || (mutation_path in held.conflicts)) + return FALSE + return TRUE + +/obj/item/dnainjector/loot/inject(mob/living/carbon/target, mob/user) + if(!can_take(target)) + return FALSE + . = ..() + if(!.) + return + // Belt and braces: never spend the injector unless the gene actually took. + var/datum/mutation/gained = target.dna.get_mutation(get_loot_mutation()) + if(!gained || !length(gained.sources & list(MUTATION_SOURCE_ACTIVATED, MUTATION_SOURCE_MUTATOR))) + return FALSE + +/obj/item/dnainjector/loot/antenna + name = "\improper DNA injector (Antenna)" + desc = "A single-use DNA injector. Someone drew a little radio mast on the label." + add_mutations = list(/datum/mutation/antenna) + +/obj/item/dnainjector/loot/biotech + name = "\improper DNA injector (Biotech Compatibility)" + desc = "A single-use DNA injector stamped with a skillchip maker's logo." + add_mutations = list(/datum/mutation/biotechcompat) + +/obj/item/dnainjector/loot/telepathy + name = "\improper DNA injector (Telepathy)" + desc = "A single-use DNA injector. The label shows two heads facing each other." + add_mutations = list(/datum/mutation/telepathy) + +/obj/item/dnainjector/loot/cindikinesis + name = "\improper DNA injector (Cindikinesis)" + desc = "A single-use DNA injector with a smudge of ash on the cap." + add_mutations = list(/datum/mutation/cindikinesis) + +/obj/item/dnainjector/loot/glowy + name = "\improper DNA injector (Glowy)" + desc = "A single-use DNA injector that glows faintly in the dark." + add_mutations = list(/datum/mutation/glow) + +/obj/item/dnainjector/loot/gigantism + name = "\improper DNA injector (Gigantism)" + desc = "A single-use DNA injector labelled in very large letters." + add_mutations = list(/datum/mutation/gigantism) + +/obj/item/dnainjector/loot/geladikinesis + name = "\improper DNA injector (Geladikinesis)" + desc = "A single-use DNA injector, cold to the touch and frosted over." + add_mutations = list(/datum/mutation/geladikinesis) + +/obj/item/dnainjector/loot/strength + name = "\improper DNA injector (Strength)" + desc = "A single-use DNA injector from a gym's back room." + add_mutations = list(/datum/mutation/strong) + +/obj/item/dnainjector/loot/stimmed + name = "\improper DNA injector (Stimmed)" + desc = "A single-use DNA injector with a sports drink sticker on it." + add_mutations = list(/datum/mutation/stimmed) + +/obj/item/dnainjector/loot/rock_eater + name = "\improper DNA injector (Rock Eater)" + desc = "A single-use DNA injector with tooth marks on the casing." + add_mutations = list(/datum/mutation/rock_eater) + +/obj/item/dnainjector/loot/olfaction + name = "\improper DNA injector (Olfaction)" + desc = "A single-use DNA injector. The label shows a dog's nose." + add_mutations = list(/datum/mutation/olfaction) + +/obj/item/dnainjector/loot/farsight + name = "\improper DNA injector (Farsight)" + desc = "A single-use DNA injector with a pair of binoculars sketched on the label." + add_mutations = list(/datum/mutation/farsight) + +/obj/item/dnainjector/loot/cold_adaptation + name = "\improper DNA injector (Cold Adaptation)" + desc = "A single-use DNA injector with a snowflake on the label." + add_mutations = list(/datum/mutation/adaptation/cold) + +/obj/item/dnainjector/loot/heat_adaptation + name = "\improper DNA injector (Heat Adaptation)" + desc = "A single-use DNA injector, warm to the touch." + add_mutations = list(/datum/mutation/adaptation/heat) + +/obj/item/dnainjector/loot/insulated + name = "\improper DNA injector (Insulated)" + desc = "A single-use DNA injector wrapped in electrical tape." + add_mutations = list(/datum/mutation/insulated) + +/obj/item/dnainjector/loot/mind_reader + name = "\improper DNA injector (Mind Reader)" + desc = "A single-use DNA injector from an interrogator's kit." + add_mutations = list(/datum/mutation/mindreader) + +/obj/item/dnainjector/loot/anti_glow + name = "\improper DNA injector (Anti-Glow)" + desc = "A single-use DNA injector painted matte black." + add_mutations = list(/datum/mutation/glow/anti) + +/obj/item/dnainjector/loot/webbing + name = "\improper DNA injector (Webbing)" + desc = "A single-use DNA injector wrapped in sticky silk." + add_mutations = list(/datum/mutation/webbing) + +/obj/item/dnainjector/loot/autotomy + name = "\improper DNA injector (Autotomy)" + desc = "A single-use DNA injector. The label shows a lizard missing its tail." + add_mutations = list(/datum/mutation/self_amputation) + +/obj/item/dnainjector/loot/dwarfism + name = "\improper DNA injector (Dwarfism)" + desc = "A single-use DNA injector, slightly smaller than the usual kind." + add_mutations = list(/datum/mutation/dwarfism) + +/obj/item/dnainjector/loot/tongue_spike + name = "\improper DNA injector (Tongue Spike)" + desc = "A single-use DNA injector with a warning sticker about tongues." + add_mutations = list(/datum/mutation/tongue_spike) + +/obj/item/dnainjector/loot/hypermetabolic + name = "\improper DNA injector (Hypermetabolic Blood)" + desc = "A single-use DNA injector filled with something dark red." + add_mutations = list(/datum/mutation/bloodier) + +/obj/item/dnainjector/loot/plasmocile + name = "\improper DNA injector (Plasmocile)" + desc = "A single-use DNA injector with a plasma hazard sign crossed out." + add_mutations = list(/datum/mutation/plasmocile) + +/obj/item/dnainjector/loot/thermal_vision + name = "\improper DNA injector (Thermal Vision)" + desc = "A single-use DNA injector with a heat map printed on the label." + add_mutations = list(/datum/mutation/thermal) + +/obj/item/dnainjector/loot/cryokinesis + name = "\improper DNA injector (Cryokinesis)" + desc = "A single-use DNA injector with a blue hazard stripe and frost on the cap." + add_mutations = list(/datum/mutation/cryokinesis) + +/obj/item/dnainjector/loot/pyrokinesis + name = "\improper DNA injector (Pyrokinesis)" + desc = "A single-use DNA injector with scorch marks along the casing." + add_mutations = list(/datum/mutation/pyrokinesis) + +/obj/item/dnainjector/loot/elastic_arms + name = "\improper DNA injector (Elastic Arms)" + desc = "A single-use DNA injector with a rubber band around it." + add_mutations = list(/datum/mutation/elastic_arms) + +/obj/item/dnainjector/loot/chem_spike + name = "\improper DNA injector (Chem Spike)" + desc = "A single-use DNA injector marked with a biohazard sign and a tongue." + add_mutations = list(/datum/mutation/tongue_spike/chem) + +/obj/item/dnainjector/loot/pressure_adaptation + name = "\improper DNA injector (Pressure Adaptation)" + desc = "A single-use DNA injector from a deep salvage diving kit." + add_mutations = list(/datum/mutation/adaptation/pressure) + +/obj/item/dnainjector/loot/thermal_adaptation + name = "\improper DNA injector (Thermal Adaptation)" + desc = "A single-use DNA injector labelled for both arctic and furnace work." + add_mutations = list(/datum/mutation/adaptation/thermal) + +/obj/item/dnainjector/loot/shock_touch + name = "\improper DNA injector (Shock Touch)" + desc = "A single-use DNA injector with a lightning bolt on the label." + add_mutations = list(/datum/mutation/shock) + +/obj/item/dnainjector/loot/chameleon + name = "\improper DNA injector (Chameleon)" + desc = "A single-use DNA injector. It is hard to keep track of where you put it down." + add_mutations = list(/datum/mutation/chameleon) + +/obj/item/dnainjector/loot/fire_breath + name = "\improper DNA injector (Fire Breath)" + desc = "A single-use DNA injector that smells of lighter fluid." + add_mutations = list(/datum/mutation/firebreath) + +/obj/item/dnainjector/loot/fiery_sweat + name = "\improper DNA injector (Fiery Sweat)" + desc = "A single-use DNA injector with a sweat-stained label." + add_mutations = list(/datum/mutation/fire) + +/obj/item/dnainjector/loot/void_magnet + name = "\improper DNA injector (Void Magnet)" + desc = "A single-use DNA injector. The liquid inside is perfectly black." + add_mutations = list(/datum/mutation/void) + +/obj/item/dnainjector/loot/inexorable + name = "\improper DNA injector (Inexorable)" + desc = "A single-use DNA injector from a field medic's kit, marked for last resorts." + add_mutations = list(/datum/mutation/inexorable) + +/obj/item/dnainjector/loot/mending_touch + name = "\improper DNA injector (Mending Touch)" + desc = "A single-use DNA injector with an open palm printed on the label." + add_mutations = list(/datum/mutation/lay_on_hands) + +/obj/item/dnainjector/loot/adrenaline_rush + name = "\improper DNA injector (Adrenaline Rush)" + desc = "A single-use DNA injector with a racing heart drawn on the label." + add_mutations = list(/datum/mutation/adrenaline_rush) + +/obj/item/dnainjector/loot/quick_recovery + name = "\improper DNA injector (Quick Recovery)" + desc = "A single-use DNA injector from a stunt performer's kit." + add_mutations = list(/datum/mutation/quick_recovery) + +/obj/item/dnainjector/loot/martyrdom + name = "\improper DNA injector (Martyrdom)" + desc = "A single-use DNA injector with a skull and crossbones on the label." + add_mutations = list(/datum/mutation/martyrdom) + +/obj/item/dnainjector/loot/xray + name = "\improper DNA injector (X-Ray Vision)" + desc = "A single-use DNA injector. The label shows the bones of a hand." + add_mutations = list(/datum/mutation/xray) + +/obj/item/dnainjector/loot/telekinesis + name = "\improper DNA injector (Telekinesis)" + desc = "A single-use DNA injector. The label shows a wrench floating above a palm." + add_mutations = list(/datum/mutation/telekinesis) + +/obj/item/dnainjector/loot/laser_eyes + name = "\improper DNA injector (Laser Eyes)" + desc = "A single-use DNA injector with a pair of red eyes drawn on the label." + add_mutations = list(/datum/mutation/laser_eyes) + +/obj/item/dnainjector/loot/hulk + name = "\improper DNA injector (Hulk)" + desc = "A single-use DNA injector, dented as if someone squeezed it too hard." + add_mutations = list(/datum/mutation/hulk) + +/obj/item/dnainjector/loot/tough + name = "\improper DNA injector (Tough)" + desc = "A single-use DNA injector in a thick, scuffed casing." + add_mutations = list(/datum/mutation/tough) + +/obj/item/dnainjector/loot/dextrous + name = "\improper DNA injector (Dextrous)" + desc = "A single-use DNA injector from a surgeon's private stock." + add_mutations = list(/datum/mutation/dextrous) + +/obj/item/dnainjector/loot/quick + name = "\improper DNA injector (Quick)" + desc = "A single-use DNA injector with a running shoe on the label." + add_mutations = list(/datum/mutation/quick) + +/obj/item/dnainjector/loot/breathless + name = "\improper DNA injector (Breathless)" + desc = "A single-use DNA injector from a vacuum rescue kit, no mask included." + add_mutations = list(/datum/mutation/breathless) + +/obj/item/dnainjector/loot/fire_immunity + name = "\improper DNA injector (Fire Immunity)" + desc = "A single-use DNA injector in a fireproof sleeve." + add_mutations = list(/datum/mutation/fire_immunity) + +/obj/item/dnainjector/loot/rock_absorber + name = "\improper DNA injector (Rock Absorber)" + desc = "A single-use DNA injector with flecks of ore stuck to the casing." + add_mutations = list(/datum/mutation/rock_absorber) diff --git a/voidcrew/modules/loot/themes/armory.dm b/voidcrew/modules/loot/themes/armory.dm index 55c12eb3b7d..c8c73e17723 100644 --- a/voidcrew/modules/loot/themes/armory.dm +++ b/voidcrew/modules/loot/themes/armory.dm @@ -149,6 +149,8 @@ /obj/item/clothing/glasses/hud/security/sunglasses = 5, /obj/item/melee/baton/security/loaded = 4, /obj/item/clothing/mask/balaclava = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/strength = 3, ) loot_uncommon = list( /obj/item/clothing/suit/armor/bulletproof = 10, @@ -173,6 +175,8 @@ /obj/item/organ/cyberimp/cyberware/slabskin = 3, /obj/item/organ/eyes/robotic/cyberware/deadeye = 3, /obj/item/organ/cyberimp/cyberware/atlas = 2, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/quick_recovery = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -182,6 +186,9 @@ /obj/item/handloaders_vise = 3, /obj/item/clothing/glasses/sunglasses/marksmans_cant = 2, /obj/item/garrison_standard = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/hulk = 1, + /obj/item/dnainjector/loot/tough = 1, ) /obj/structure/closet/crate/zone_loot/armory diff --git a/voidcrew/modules/loot/themes/expedition.dm b/voidcrew/modules/loot/themes/expedition.dm index 5dfa9fbb1a7..9bd98aee396 100644 --- a/voidcrew/modules/loot/themes/expedition.dm +++ b/voidcrew/modules/loot/themes/expedition.dm @@ -55,6 +55,8 @@ // prospector's chrome, cheapest rung: pistons that get you off the // ground fast and hold your footing on bad decking /obj/item/organ/cyberimp/cyberware/shock_coils = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/geladikinesis = 3, ) loot_uncommon = list( /obj/item/gun/energy/recharge/kinetic_accelerator = 10, @@ -70,6 +72,10 @@ // and hoppers its own ore, and a harpoon winch for everything else /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw = 4, /obj/item/organ/cyberimp/arm/toolkit/cyberware/angler = 3, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/olfaction = 3, + /obj/item/dnainjector/loot/farsight = 2, + /obj/item/dnainjector/loot/cold_adaptation = 2, ) loot_prime = list( /obj/item/borg/upgrade/modkit/range = 9, @@ -83,6 +89,8 @@ /obj/item/organ/eyes/robotic/cyberware/prospector = 3, /obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook = 3, /obj/item/organ/cyberimp/cyberware/hopper = 2, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/pressure_adaptation = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -94,6 +102,9 @@ /obj/item/pickaxe/divining = 2, /obj/item/deepwell_sampler = 1, /obj/item/longwalk_rig = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/quick = 1, + /obj/item/dnainjector/loot/breathless = 1, ) /obj/structure/closet/crate/zone_loot/expedition diff --git a/voidcrew/modules/loot/themes/industrial.dm b/voidcrew/modules/loot/themes/industrial.dm index b9d65961ebb..6fae89d3e02 100644 --- a/voidcrew/modules/loot/themes/industrial.dm +++ b/voidcrew/modules/loot/themes/industrial.dm @@ -55,6 +55,8 @@ /obj/item/weldingtool/largetank = 5, /obj/item/stock_parts/power_store/cell/high = 4, /obj/item/stack/sheet/plasteel/twenty = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/rock_eater = 3, ) loot_uncommon = list( /obj/item/stack/sheet/mineral/titanium/fifty = 8, @@ -74,6 +76,9 @@ // the maintenance crew's chrome: driver, wrench and cutters folded // into the knuckles, and faster hands behind them /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/heat_adaptation = 2, + /obj/item/dnainjector/loot/insulated = 3, ) loot_prime = list( /obj/item/stack/sheet/plasteel/fifty = 9, @@ -89,6 +94,9 @@ /obj/item/stack/sheet/mineral/diamond/fifty = 2, // Hot-work chrome: a body-wide coolant loop. /obj/item/organ/cyberimp/cyberware/coolant = 3, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/thermal_adaptation = 2, + /obj/item/dnainjector/loot/shock_touch = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -100,6 +108,9 @@ /obj/item/weldingtool/slagmaw = 2, /obj/item/stamp/helios_pattern = 1, /obj/item/stock_parts/power_store/cell/forge_heart = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/fire_immunity = 1, + /obj/item/dnainjector/loot/rock_absorber = 1, ) /obj/structure/closet/crate/zone_loot/industrial diff --git a/voidcrew/modules/loot/themes/medical.dm b/voidcrew/modules/loot/themes/medical.dm index 42af4e43537..3d152ec9ea7 100644 --- a/voidcrew/modules/loot/themes/medical.dm +++ b/voidcrew/modules/loot/themes/medical.dm @@ -51,6 +51,8 @@ // cheapest thing on the parlor's shelf and the one piece of chrome a // clinic would actually stock: it replaces a stomach /obj/item/organ/cyberimp/cyberware/gastro = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/stimmed = 3, ) loot_uncommon = list( /obj/item/storage/medkit/advanced = 10, @@ -74,6 +76,9 @@ // reserve and a blood filter, both things a med bay would have fitted /obj/item/organ/cyberimp/cyberware/second_wind = 4, /obj/item/organ/cyberimp/cyberware/hemoglass = 3, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/hypermetabolic = 3, + /obj/item/dnainjector/loot/plasmocile = 2, ) loot_prime = list( /obj/item/reagent_containers/hypospray/medipen/atropine = 7, @@ -91,6 +96,10 @@ // node that restarts a heart on its own (4 vouchers over the counter) /obj/item/organ/cyberimp/cyberware/dead_channel = 3, /obj/item/organ/cyberimp/cyberware/lazarus = 2, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/inexorable = 2, + /obj/item/dnainjector/loot/mending_touch = 2, + /obj/item/dnainjector/loot/adrenaline_rush = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/occult.dm b/voidcrew/modules/loot/themes/occult.dm index 694c55147a2..e9e98726474 100644 --- a/voidcrew/modules/loot/themes/occult.dm +++ b/voidcrew/modules/loot/themes/occult.dm @@ -58,6 +58,9 @@ /obj/item/coin/gold = 5, /obj/item/toy/cards/deck/tarot = 5, /obj/item/reagent_containers/cup/glass/bottle/wine = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/telepathy = 3, + /obj/item/dnainjector/loot/cindikinesis = 2, ) loot_uncommon = list( /obj/item/statuebust = 8, @@ -71,6 +74,9 @@ // whoever was robbing these graves before you left their tools in // their arm: three seconds a body, and the skull gives up what it kept /obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber = 3, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/webbing = 3, + /obj/item/dnainjector/loot/autotomy = 2, ) loot_prime = list( /obj/item/stack/sheet/mineral/diamond/five = 8, @@ -83,6 +89,10 @@ /obj/item/book/granter/action/spell/sacredflame = 2, /obj/item/book/granter/action/spell/fireball = 1, /obj/item/book/granter/action/spell/random = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/fire_breath = 1, + /obj/item/dnainjector/loot/fiery_sweat = 1, + /obj/item/dnainjector/loot/void_magnet = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -94,6 +104,8 @@ /obj/item/flashlight/lantern/censer_quiet_parish = 2, /obj/item/cane/shepherds_crook = 1, /obj/item/clothing/neck/beads/vow_ring = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/dextrous = 1, ) /obj/structure/closet/crate/zone_loot/occult diff --git a/voidcrew/modules/loot/themes/plunder.dm b/voidcrew/modules/loot/themes/plunder.dm index ea2a1067b3d..b142f73c2ae 100644 --- a/voidcrew/modules/loot/themes/plunder.dm +++ b/voidcrew/modules/loot/themes/plunder.dm @@ -66,6 +66,8 @@ // boarder's palms: your hands clamp shut on your gun when you go // down, and tables stop being furniture /obj/item/organ/cyberimp/cyberware/gecko = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/tongue_spike = 3, ) loot_prime = list( /obj/item/stack/spacecash/c1000 = 9, diff --git a/voidcrew/modules/loot/themes/research.dm b/voidcrew/modules/loot/themes/research.dm index dffb832ac37..721e1358923 100644 --- a/voidcrew/modules/loot/themes/research.dm +++ b/voidcrew/modules/loot/themes/research.dm @@ -47,6 +47,9 @@ /obj/item/experi_scanner = 5, /obj/item/research_notes/loot/tiny = 5, /obj/item/stack/ore/bluespace_crystal/artificial = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/antenna = 3, + /obj/item/dnainjector/loot/biotech = 3, ) loot_uncommon = list( /obj/item/stack/sheet/mineral/plasma/thirty = 10, @@ -85,6 +88,12 @@ // the annex's own interface work: a skull jack that flies a ship from // anywhere aboard it, as long as the hull still has a helm console /obj/item/organ/cyberimp/cyberware/rigger = 2, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/thermal_vision = 3, + /obj/item/dnainjector/loot/cryokinesis = 2, + /obj/item/dnainjector/loot/pyrokinesis = 2, + /obj/item/dnainjector/loot/elastic_arms = 2, + /obj/item/dnainjector/loot/chem_spike = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable @@ -96,6 +105,9 @@ /obj/item/reagent_containers/cup/beaker/entangled = 2, /obj/item/clothing/gloves/fingerless/chronal_splint = 1, /obj/item/clothing/suit/toggle/labcoat/eventide_courier = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/xray = 1, + /obj/item/dnainjector/loot/telekinesis = 2, ) /obj/structure/closet/crate/zone_loot/research diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 1f63090058e..2de7274da42 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -66,6 +66,9 @@ // a wrist spike for door motors and turret IFF /obj/item/organ/cyberimp/cyberware/cargo_cavity = 4, /obj/item/organ/cyberimp/arm/toolkit/cyberware/icepick = 3, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/mind_reader = 2, + /obj/item/dnainjector/loot/anti_glow = 2, ) loot_prime = list( /obj/item/pen/sleepy = 4, @@ -83,6 +86,9 @@ /obj/item/radio/listening_coin = 3, /obj/item/static_cuff = 2, /obj/item/clothing/suit/hooded/cloak/second_shadow = 1, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/martyrdom = 1, + /obj/item/dnainjector/loot/laser_eyes = 1, ) /obj/structure/closet/crate/zone_loot/syndicate diff --git a/voidcrew/modules/loot/themes/wardrobe.dm b/voidcrew/modules/loot/themes/wardrobe.dm index 737a9710b90..35dc5bede0e 100644 --- a/voidcrew/modules/loot/themes/wardrobe.dm +++ b/voidcrew/modules/loot/themes/wardrobe.dm @@ -48,6 +48,9 @@ // somebody's unused parlor appointment: tattoo ink you wear under // the skin, colour and pattern set at a Cradle /obj/item/organ/cyberimp/cyberware/chromatic_dermis = 4, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/glowy = 3, + /obj/item/dnainjector/loot/gigantism = 2, ) loot_uncommon = list( /obj/item/clothing/suit/jacket/leather/biker = 10, @@ -63,6 +66,8 @@ // black eyes that see in the dark. Half the reason anyone buys them // is that everyone can tell you have them /obj/item/organ/eyes/robotic/cyberware/nightshade = 3, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/dwarfism = 3, ) loot_prime = list( /obj/item/clothing/head/cowboy/bounty = 9, @@ -75,6 +80,8 @@ // a sealed cache: now the long tail of prime, open to any band /obj/item/clothing/shoes/clown_shoes/banana_shoes/combat = 2, /obj/item/megaphone/clown = 2, + // gene injector loot (gene_injectors.dm) + /obj/item/dnainjector/loot/chameleon = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/trade/shop_catalog_black_market.dm b/voidcrew/modules/trade/shop_catalog_black_market.dm index 317a6578920..447680b7a76 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -943,7 +943,8 @@ || ispath(loot_path, /obj/item/claymore) || ispath(loot_path, /obj/item/katana) || ispath(loot_path, /obj/item/fireaxe) \ || ispath(loot_path, /obj/item/spear) || ispath(loot_path, /obj/item/hatchet) || ispath(loot_path, /obj/item/mjollnir) \ || ispath(loot_path, /obj/item/organ/cyberimp) || ispath(loot_path, /obj/item/organ/eyes/robotic/cyberware) \ - || ispath(loot_path, /obj/item/cyberware_pair_case) || ispath(loot_path, /obj/item/storage/case/cyberware)) + || ispath(loot_path, /obj/item/cyberware_pair_case) || ispath(loot_path, /obj/item/storage/case/cyberware) \ + || ispath(loot_path, /obj/item/dnainjector)) pool -= loot_path return pools From 4f4f6c85d475ed3c464ce77390f145e3f4f42627 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:43:39 -0800 Subject: [PATCH 42/72] Rate the loot gene injectors --- tools/gear_catalog/categories.py | 1 + tools/gear_catalog/ratings/medical.json | 454 +++++++++++++++++++++++- 2 files changed, 454 insertions(+), 1 deletion(-) diff --git a/tools/gear_catalog/categories.py b/tools/gear_catalog/categories.py index 861e0dde54f..7e804a834c1 100644 --- a/tools/gear_catalog/categories.py +++ b/tools/gear_catalog/categories.py @@ -174,6 +174,7 @@ ('/obj/item/instrument', 'toys', '/obj/item/instrument'), ('/obj/item/trash', 'trash', '/obj/item/trash'), ('/obj/item/vending_refill', 'vending_refills', '/obj/item/vending_refill'), + ('/obj/item/dnainjector/loot', 'medical', '/obj/item/dnainjector'), ('/obj/item/dnainjector', 'genetics', '/obj/item/dnainjector'), ('/obj/item/flashlight', 'tools', '/obj/item/flashlight'), ('/obj/item/assembly', 'tools', '/obj/item/assembly'), diff --git a/tools/gear_catalog/ratings/medical.json b/tools/gear_catalog/ratings/medical.json index 4d161a4a47a..e048ac30bc1 100644 --- a/tools/gear_catalog/ratings/medical.json +++ b/tools/gear_catalog/ratings/medical.json @@ -13,7 +13,8 @@ "advanced surgery", "basic surgery", "scanners", - "blood" + "blood", + "gene injectors" ], "items": { "/obj/item/autopsy_scanner": { @@ -187,6 +188,456 @@ "reason": "Anchor. Revives a fresh corpse at the cost of your back slot, and thick chest clothing blocks it. Common on hulls, and a cargo crate of two costs 1,000 cr.", "tier": "green" }, + "/obj/item/dnainjector/loot/adrenaline_rush": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Adrenaline Rush)", + "rank": 35, + "reason": "A stun-shortening chemical burst every two minutes. Like Anti-Stun. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/antenna": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Antenna)", + "rank": 10, + "reason": "A permanent hands-free radio. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/anti_glow": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Anti-Glow)", + "rank": 16, + "reason": "A pocket of darkness around the user; players only. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/autotomy": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Autotomy)", + "rank": 18, + "reason": "Drop a random limb on demand, can slip cuffs. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/biotech": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Biotech Compatibility)", + "rank": 1, + "reason": "One more point of skillchip room. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/breathless": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Breathless)", + "rank": 48, + "reason": "Never needs to breathe. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/chameleon": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Chameleon)", + "rank": 31, + "reason": "Fades to invisible while still; players only. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/chem_spike": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Chem Spike)", + "rank": 28, + "reason": "Ranged tongue that injects the user's own blood chemicals. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/cindikinesis": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Cindikinesis)", + "rank": 2, + "reason": "Makes ash. Flavour. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/cold_adaptation": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Cold Adaptation)", + "rank": 23, + "reason": "No cold damage and no slipping on ice; beats snow storms. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/cryokinesis": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Cryokinesis)", + "rank": 26, + "reason": "A ranged freezing beam on a cooldown. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/dextrous": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Dextrous)", + "rank": 46, + "reason": "Halves every click cooldown. Above Cascade. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/dwarfism": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Dwarfism)", + "rank": 19, + "reason": "Walk over tables, like Gecko cyberware. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/elastic_arms": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Elastic Arms)", + "rank": 37, + "reason": "Melee reach of two tiles, but no normal guns. Monowire-class. Loot only.", + "tier": "red" + }, + "/obj/item/dnainjector/loot/farsight": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Farsight)", + "rank": 13, + "reason": "A slightly wider view without a power chromosome. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/fiery_sweat": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Fiery Sweat)", + "rank": 39, + "reason": "Halves burn damage, with random self-ignition. Half of Coolant. Loot only.", + "tier": "red" + }, + "/obj/item/dnainjector/loot/fire_breath": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Fire Breath)", + "rank": 32, + "reason": "A burning cone on a long cooldown. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/fire_immunity": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Fire Immunity)", + "rank": 49, + "reason": "Half burn damage and never catches fire. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/geladikinesis": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Geladikinesis)", + "rank": 5, + "reason": "Makes snow for snow walls. Flavour. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/gigantism": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Gigantism)", + "rank": 4, + "reason": "Bigger sprite and a small tackle edge. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/glowy": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Glowy)", + "rank": 3, + "reason": "A coloured light around the user. Flavour. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/heat_adaptation": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Heat Adaptation)", + "rank": 24, + "reason": "No heat damage and ash storm immunity. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/hulk": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Hulk)", + "rank": 44, + "reason": "Permanent stun and stamina immunity for humans. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/hypermetabolic": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Hypermetabolic Blood)", + "rank": 21, + "reason": "Six times blood regeneration, more bleeding. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/inexorable": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Inexorable)", + "rank": 40, + "reason": "Stays up below crit with no pain. Same traits as Dead Channel. Loot only.", + "tier": "red" + }, + "/obj/item/dnainjector/loot/insulated": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Insulated)", + "rank": 14, + "reason": "Shock immunity. Like insulated gloves that never come off. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/laser_eyes": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Laser Eyes)", + "rank": 43, + "reason": "An endless laser with no ammo and nothing to disarm. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/martyrdom": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Martyrdom)", + "rank": 9, + "reason": "Gibs and blasts the holder at hard crit. Never route it.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/mending_touch": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Mending Touch)", + "rank": 34, + "reason": "Moves a patient's wounds onto the healer. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/mind_reader": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Mind Reader)", + "rank": 15, + "reason": "Reads recent speech, intent and true identity of a player. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/olfaction": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Olfaction)", + "rank": 12, + "reason": "Tracks whoever touched an item on the same level. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/plasmocile": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Plasmocile)", + "rank": 22, + "reason": "No plasma breath damage and no viruses. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/pressure_adaptation": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Pressure Adaptation)", + "rank": 29, + "reason": "Immune to low and high pressure; still needs air and warmth. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/pyrokinesis": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Pyrokinesis)", + "rank": 27, + "reason": "A ranged igniting heat blast on a cooldown. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/quick": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Quick)", + "rank": 47, + "reason": "A permanent speed boost. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/quick_recovery": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Quick Recovery)", + "rank": 36, + "reason": "Halves stun lengths permanently. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/rock_absorber": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Rock Absorber)", + "rank": 50, + "reason": "Mineral sheets give golem buffs, gold reflects lasers. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/rock_eater": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Rock Eater)", + "rank": 8, + "reason": "Eat ore and sheets as food. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/shock_touch": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Shock Touch)", + "rank": 30, + "reason": "A touch every 7 s that disarms both hands and confuses. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/stimmed": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Stimmed)", + "rank": 7, + "reason": "Only speeds the exercise buff. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/strength": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Strength)", + "rank": 6, + "reason": "Only touches exercise and boxing. Loot only.", + "tier": "none" + }, + "/obj/item/dnainjector/loot/telekinesis": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Telekinesis)", + "rank": 42, + "reason": "Works items, consoles and guns from 15 tiles away. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/telepathy": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Telepathy)", + "rank": 11, + "reason": "Private messages to someone in view. Flavour. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/thermal_adaptation": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Thermal Adaptation)", + "rank": 38, + "reason": "No heat or cold damage at all. Coolant-class without a slot. Loot only.", + "tier": "red" + }, + "/obj/item/dnainjector/loot/thermal_vision": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Thermal Vision)", + "rank": 25, + "reason": "Sees mobs through walls for ten seconds, hurting the eyes. Doppler-class. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/tongue_spike": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Tongue Spike)", + "rank": 20, + "reason": "A thrown tongue for 25 damage that has to be picked back up. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/tough": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Tough)", + "rank": 45, + "reason": "30% less brute and nothing can pierce the skin. Never sell.", + "tier": "above" + }, + "/obj/item/dnainjector/loot/void_magnet": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Void Magnet)", + "rank": 33, + "reason": "Ten seconds of invulnerability a minute, with random triggers. Loot only.", + "tier": "yellow" + }, + "/obj/item/dnainjector/loot/webbing": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (Webbing)", + "rank": 17, + "reason": "Lays webs that slow everyone else. Loot only.", + "tier": "green" + }, + "/obj/item/dnainjector/loot/xray": { + "confidence": "medium", + "group": "gene injectors", + "hash": "", + "name": "DNA injector (X-Ray Vision)", + "rank": 41, + "reason": "Permanent sight through walls; no other way to get it. Never sell.", + "tier": "above" + }, "/obj/item/healthanalyzer": { "confidence": "high", "group": "scanners", @@ -1698,6 +2149,7 @@ "/obj/item/circular_saw/cruel": "no route", "/obj/item/circular_saw/cyborg": "no route", "/obj/item/defibrillator/compact/combat": "no route", + "/obj/item/dnainjector/loot": "abstract", "/obj/item/healthanalyzer/rad_laser": "no route", "/obj/item/hemostat/cruel": "no route", "/obj/item/hemostat/cyborg": "no route", From 5e7127e72d394627e8679c1902325df92c41d573 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:43:39 -0800 Subject: [PATCH 43/72] Test gene injector loot, the vault mutation guard and Shock Touch at outposts --- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_gene_injector_loot.dm | 117 ++++++++++++++++++ .../unit_tests/voidcrew_shop_catalog.dm | 1 + 3 files changed, 119 insertions(+) create mode 100644 voidcrew/modules/unit_tests/voidcrew_gene_injector_loot.dm diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..71e40bc83b5 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -86,6 +86,7 @@ #include "voidcrew_fresh_cores.dm" #include "voidcrew_gear_behaviour.dm" #include "voidcrew_gear_fixes.dm" +#include "voidcrew_gene_injector_loot.dm" #include "voidcrew_helm_access.dm" #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_gene_injector_loot.dm b/voidcrew/modules/unit_tests/voidcrew_gene_injector_loot.dm new file mode 100644 index 00000000000..7bddbe55c0e --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_gene_injector_loot.dm @@ -0,0 +1,117 @@ +/** + * # Gene injector loot (voidcrew/modules/loot/gene_injectors.dm) + * + * Every loot injector adds exactly one real mutation, can't be copied, and is + * reachable from a loot theme; a failed injection never spends the injector; + * the DNA vault mutations leave nothing behind when they fail to take; and + * Shock Touch counts as an attack at a trader outpost. + */ + +/datum/unit_test/voidcrew_gene_injector_loot_tables + /// Loot injector subtypes deliberately built but not placed in any theme yet. + /// Clever and the Wizardly, Superhuman and Ork hulks are held back by having no subtype at all. + var/static/list/held_back = list() + +/datum/unit_test/voidcrew_gene_injector_loot_tables/Run() + var/list/placed = list() + for(var/theme_path in GLOB.loot_themes) + var/datum/loot_theme/theme = GLOB.loot_themes[theme_path] + for(var/list/table in list(theme.loot_common, theme.loot_uncommon, theme.loot_prime, theme.loot_uniques)) + for(var/entry in table) + if(ispath(entry, /obj/item/dnainjector)) + TEST_ASSERT(ispath(entry, /obj/item/dnainjector/loot), "[theme_path] drops a stock injector [entry]; loot uses the checked /loot subtypes") + placed[entry] = TRUE + + var/obj/item/dnainjector/loot/base = allocate(/obj/item/dnainjector/loot) + var/list/mutations_seen = list() + for(var/injector_path in subtypesof(/obj/item/dnainjector/loot)) + var/obj/item/dnainjector/loot/injector = allocate(injector_path) + TEST_ASSERT_EQUAL(length(injector.add_mutations), 1, "[injector_path] must add exactly one mutation") + TEST_ASSERT_EQUAL(length(injector.remove_mutations), 0, "[injector_path] must not remove mutations") + var/mutation_path = injector.add_mutations[1] + TEST_ASSERT(ispath(mutation_path, /datum/mutation) && GET_INITIALIZED_MUTATION(mutation_path), "[injector_path] adds [mutation_path], which is not a registered mutation") + TEST_ASSERT(!mutations_seen[mutation_path], "[injector_path] and [mutations_seen[mutation_path]] add the same mutation") + mutations_seen[mutation_path] = injector_path + TEST_ASSERT(injector.name != base.name, "[injector_path] has no name of its own") + TEST_ASSERT(injector.desc != base.desc, "[injector_path] has no description of its own") + // TRAIT_NO_REPLICATE; voidcrew defines are included after unit tests. + TEST_ASSERT(HAS_TRAIT(injector, "no_replicate"), "[injector_path] can be copied by a duplicator") + if(injector_path in held_back) + TEST_ASSERT(!placed[injector_path], "[injector_path] is placed in loot but still listed as held back") + else + TEST_ASSERT(placed[injector_path], "[injector_path] is in no loot theme table, dead content") + +/// A refused injection keeps the injector; a good one spends it. +/datum/unit_test/voidcrew_gene_injector_loot_refusal + +/datum/unit_test/voidcrew_gene_injector_loot_refusal/Run() + var/mob/living/carbon/human/corpse = allocate(/mob/living/carbon/human/consistent) + corpse.death() + var/obj/item/dnainjector/loot/telekinesis/tk = allocate(/obj/item/dnainjector/loot/telekinesis) + // attack() only spends the injector when inject() succeeds; a living user on + // another target would sit in a do_after, so call the step itself. + TEST_ASSERT(!tk.inject(corpse, corpse), "Injecting a corpse would spend the injector") + TEST_ASSERT_NULL(corpse.dna.get_mutation(/datum/mutation/telekinesis), "A corpse gained a mutation") + + var/mob/living/carbon/human/cat = allocate(/mob/living/carbon/human/species/felinid) + var/obj/item/dnainjector/loot/hulk/hulk = allocate(/obj/item/dnainjector/loot/hulk) + hulk.attack(cat, cat) + TEST_ASSERT(!hulk.used, "A felinid spent a human-only Hulk injector") + TEST_ASSERT_NULL(cat.dna.get_mutation(/datum/mutation/hulk), "A felinid became a hulk") + + var/mob/living/carbon/human/plasmaman = allocate(/mob/living/carbon/human/species/plasma) + var/obj/item/dnainjector/loot/strength/strength = allocate(/obj/item/dnainjector/loot/strength) + strength.attack(plasmaman, plasmaman) + TEST_ASSERT(!strength.used, "A geneless plasmaman spent an injector") + + var/mob/living/carbon/human/explorer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/dnainjector/loot/cold_adaptation/cold = allocate(/obj/item/dnainjector/loot/cold_adaptation) + cold.attack(explorer, explorer) + TEST_ASSERT(cold.used, "A valid injection did not spend the injector") + TEST_ASSERT_NOTNULL(explorer.dna.get_mutation(/datum/mutation/adaptation/cold), "A valid injection did not add its mutation") + var/obj/item/dnainjector/loot/heat_adaptation/heat = allocate(/obj/item/dnainjector/loot/heat_adaptation) + heat.attack(explorer, explorer) + TEST_ASSERT(!heat.used, "A conflicting adaptation spent the injector") + TEST_ASSERT_NULL(explorer.dna.get_mutation(/datum/mutation/adaptation/heat), "Two adaptations were held at once") + var/obj/item/dnainjector/loot/cold_adaptation/second_cold = allocate(/obj/item/dnainjector/loot/cold_adaptation) + second_cold.attack(explorer, explorer) + TEST_ASSERT(!second_cold.used, "Injecting a gene the target already has spent the injector") + +/// The DNA vault mutations must not apply their buff when acquisition fails. +/datum/unit_test/voidcrew_gene_injector_vault_guard + +/datum/unit_test/voidcrew_gene_injector_vault_guard/Run() + for(var/mutation_path in list(/datum/mutation/breathless, /datum/mutation/quick, /datum/mutation/tough, /datum/mutation/dextrous, /datum/mutation/fire_immunity, /datum/mutation/quick_recovery, /datum/mutation/plasmocile)) + var/mob/living/carbon/human/corpse = allocate(/mob/living/carbon/human/consistent) + corpse.death() + var/brute = corpse.physiology.brute_mod + var/burn = corpse.physiology.burn_mod + var/stun = corpse.physiology.stun_mod + var/next_move = corpse.next_move_modifier + corpse.dna.add_mutation(mutation_path, MUTATION_SOURCE_MUTATOR) + TEST_ASSERT_NULL(corpse.dna.get_mutation(mutation_path), "[mutation_path] took on a corpse") + TEST_ASSERT_EQUAL(corpse.physiology.brute_mod, brute, "[mutation_path] left a brute modifier after failing") + TEST_ASSERT_EQUAL(corpse.physiology.burn_mod, burn, "[mutation_path] left a burn modifier after failing") + TEST_ASSERT_EQUAL(corpse.physiology.stun_mod, stun, "[mutation_path] left a stun modifier after failing") + TEST_ASSERT_EQUAL(corpse.next_move_modifier, next_move, "[mutation_path] left a click speed modifier after failing") + TEST_ASSERT(!corpse.has_movespeed_modifier(/datum/movespeed_modifier/dna_vault_speedup), "[mutation_path] left a speed modifier after failing") + for(var/trait in list(TRAIT_NOBREATH, TRAIT_PIERCEIMMUNE, TRAIT_RESISTHEAT, TRAIT_NOFIRE, TRAIT_VIRUSIMMUNE)) + TEST_ASSERT(!HAS_TRAIT_FROM(corpse, trait, GENETIC_MUTATION), "[mutation_path] left [trait] after failing") + +/// Shock Touch on another visitor earns the caster a strike like any other attack. +/datum/unit_test/voidcrew_gene_injector_shock_touch_outpost + +/datum/unit_test/voidcrew_gene_injector_shock_touch_outpost/Run() + var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost) + outpost.outpost_template = allocate(/datum/map_template/trader_outpost) + outpost.outpost_template.width = 1 + outpost.outpost_template.height = 1 + outpost.template_bottom_left = run_loc_floor_bottom_left + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + attacker.mind_initialize() + victim.mind_initialize() + var/datum/action/cooldown/spell/touch/shock/shock = allocate(/datum/action/cooldown/spell/touch/shock, attacker) + shock.cast_on_hand_hit(null, victim, attacker) + TEST_ASSERT_EQUAL(outpost.aggressor_strikes[attacker.mind], 1, "Shock Touch on a visitor did not count as an attack") + TEST_ASSERT_NULL(outpost.aggressor_strikes[victim.mind], "The shocked visitor was given a strike") diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index 7ce9f183264..4be45799933 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -637,6 +637,7 @@ for(var/item_path in pool) TEST_ASSERT(!(item_path in weapon_paths) && !ispath(item_path, /obj/item/gun) && !ispath(item_path, /obj/item/knife), "Supplies can pay an extra weapon") TEST_ASSERT(!ispath(item_path, /obj/item/organ/cyberimp), "Bought cargo can pay chrome") + TEST_ASSERT(!ispath(item_path, /obj/item/dnainjector), "Bought cargo can pay a gene injector") pool.Cut() // A caller may consume every working pool safely. var/weapon_count = 0 cache.PopulateContents() From bcf73f202a8eb07905aca9b707a78a0780896f3a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:48:19 -0800 Subject: [PATCH 44/72] Add the tutorial checklist core and retire the orientation briefing Adds a passive tutorial checklist panel that opens on spawn, with progress saved per player under vc/ keys in tutorial_completions. The orientation briefing popup is removed; its zone content becomes the checklist's Zones item. --- tgstation.dme | 14 +- .../interfaces/OrientationBriefing.tsx | 180 -------- .../interfaces/TutorialChecklist.tsx | 152 +++++++ .../preferences/features/index.ts | 1 + .../preferences/features/tutorials.tsx | 11 + voidcrew/_DEFINES/tutorials.dm | 68 +++ voidcrew/edits/mobs/new_player.dm | 5 +- .../modules/onboarding/first_time_notice.dm | 4 +- voidcrew/modules/onboarding/orientation.dm | 161 -------- .../player_outposts/outpost_residents.dm | 2 +- voidcrew/modules/tutorials/hooks_contract.dm | 1 + voidcrew/modules/tutorials/hooks_medical.dm | 1 + voidcrew/modules/tutorials/hooks_power.dm | 1 + voidcrew/modules/tutorials/hooks_research.dm | 1 + voidcrew/modules/tutorials/hooks_ship.dm | 1 + voidcrew/modules/tutorials/hooks_trade.dm | 1 + voidcrew/modules/tutorials/hooks_world.dm | 1 + voidcrew/modules/tutorials/hooks_zones.dm | 21 + voidcrew/modules/tutorials/preferences.dm | 7 + .../modules/tutorials/tutorial_catalog.dm | 391 ++++++++++++++++++ voidcrew/modules/tutorials/tutorial_panel.dm | 148 +++++++ .../modules/tutorials/tutorial_progress.dm | 241 +++++++++++ 22 files changed, 1065 insertions(+), 348 deletions(-) delete mode 100644 tgui/packages/voidcrew_tgui/interfaces/OrientationBriefing.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/TutorialChecklist.tsx create mode 100644 tgui/packages/voidcrew_tgui/preferences/features/tutorials.tsx create mode 100644 voidcrew/_DEFINES/tutorials.dm delete mode 100644 voidcrew/modules/onboarding/orientation.dm create mode 100644 voidcrew/modules/tutorials/hooks_contract.dm create mode 100644 voidcrew/modules/tutorials/hooks_medical.dm create mode 100644 voidcrew/modules/tutorials/hooks_power.dm create mode 100644 voidcrew/modules/tutorials/hooks_research.dm create mode 100644 voidcrew/modules/tutorials/hooks_ship.dm create mode 100644 voidcrew/modules/tutorials/hooks_trade.dm create mode 100644 voidcrew/modules/tutorials/hooks_world.dm create mode 100644 voidcrew/modules/tutorials/hooks_zones.dm create mode 100644 voidcrew/modules/tutorials/preferences.dm create mode 100644 voidcrew/modules/tutorials/tutorial_catalog.dm create mode 100644 voidcrew/modules/tutorials/tutorial_panel.dm create mode 100644 voidcrew/modules/tutorials/tutorial_progress.dm diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..6602642b33f 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\tutorials.dm" #include "voidcrew\_HELPERS\data_huds.dm" #include "voidcrew\_HELPERS\debug_atmos.dm" #include "voidcrew\_HELPERS\docking.dm" @@ -7411,7 +7412,6 @@ #include "voidcrew\modules\npc_ships\code\subtrees\combat_subtree.dm" #include "voidcrew\modules\npc_ships\code\subtrees\movement_subtree.dm" #include "voidcrew\modules\onboarding\first_time_notice.dm" -#include "voidcrew\modules\onboarding\orientation.dm" #include "voidcrew\modules\onboarding\zone_advisory.dm" #include "voidcrew\modules\outfits\corporate.dm" #include "voidcrew\modules\outfits\misc.dm" @@ -7709,6 +7709,18 @@ #include "voidcrew\modules\trade\favor_uniques\outfitter.dm" #include "voidcrew\modules\trade\favor_uniques\pike.dm" #include "voidcrew\modules\trade\favor_uniques\vex.dm" +#include "voidcrew\modules\tutorials\hooks_contract.dm" +#include "voidcrew\modules\tutorials\hooks_medical.dm" +#include "voidcrew\modules\tutorials\hooks_power.dm" +#include "voidcrew\modules\tutorials\hooks_research.dm" +#include "voidcrew\modules\tutorials\hooks_ship.dm" +#include "voidcrew\modules\tutorials\hooks_trade.dm" +#include "voidcrew\modules\tutorials\hooks_world.dm" +#include "voidcrew\modules\tutorials\hooks_zones.dm" +#include "voidcrew\modules\tutorials\preferences.dm" +#include "voidcrew\modules\tutorials\tutorial_catalog.dm" +#include "voidcrew\modules\tutorials\tutorial_panel.dm" +#include "voidcrew\modules\tutorials\tutorial_progress.dm" #include "voidcrew\modules\transporter\_transporter.dm" #include "voidcrew\modules\transporter\transporter_console.dm" #include "voidcrew\modules\transporter\transporter_designs.dm" diff --git a/tgui/packages/voidcrew_tgui/interfaces/OrientationBriefing.tsx b/tgui/packages/voidcrew_tgui/interfaces/OrientationBriefing.tsx deleted file mode 100644 index ed8c1761516..00000000000 --- a/tgui/packages/voidcrew_tgui/interfaces/OrientationBriefing.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState } from 'react'; -import { - Box, - Button, - Icon, - NoticeBox, - Section, - Stack, - Tabs, -} from 'tgui-core/components'; -import type { BooleanLike } from 'tgui-core/react'; - -import { useBackend } from '../backend'; -import { Window } from '../layouts'; - -type Zone = { - name: string; - color: string; - summary: string; - rules: string[]; -}; - -type ResearchNode = { - name: string; - desc: string; -}; - -type Data = { - ship_name: string | null; - combat_researched: BooleanLike; - zones: Zone[]; - research_path: ResearchNode[]; -}; - -type TabName = 'welcome' | 'zones' | 'combat'; - -const TABS: { id: TabName; label: string; icon: string }[] = [ - { id: 'welcome', label: 'Welcome', icon: 'hand-spock' }, - { id: 'zones', label: 'Zones', icon: 'circle-half-stroke' }, - { id: 'combat', label: 'Ship Combat', icon: 'crosshairs' }, -]; - -export const OrientationBriefing = () => { - const [tab, setTab] = useState('welcome'); - - return ( - - - - - - {TABS.map((entry) => ( - setTab(entry.id)} - icon={entry.icon} - > - {entry.label} - - ))} - - - - {tab === 'welcome' && } - {tab === 'zones' && } - {tab === 'combat' && } - - - - - ); -}; - -/** The reason this window exists at all: the server is being tested on you. */ -const WelcomeTab = () => { - const { act } = useBackend(); - - return ( -

- - Things will break. Expect bugs, half-finished features and rounds that - end badly through no fault of your own. - - - Report anything broken to jackrip on the Discord, what you were - doing and roughly when is usually enough to find it in the logs. The - wiki covers ships, the overmap, missions and everything else Voidcrew - adds, in far more detail than this window. - - - - - - - - - - - You crew a ship on an overmap. Take contracts, mine, trade and explore; - fly further out for better pay and worse odds. The other two tabs cover - where you are allowed to go and how to arm the ship. - -
- ); -}; - -/** What the three overmap bands permit, straight off the defines that enforce it. */ -const ZonesTab = () => { - const { data } = useBackend(); - const { zones } = data; - - return ( -
- - Your helm console always shows which ring you are in. Crossing a border - takes ten seconds and can be cancelled until it completes. - - {zones.map((zone) => ( - - - - {zone.name} - - {zone.summary} - - {zone.rules.map((rule) => ( - - • {rule} - - ))} - - - ))} - - Farther out, be ready for meteors, radiation storms on planets, hostile - crews and worse. - -
- ); -}; - -/** Why a new crew should not simply fly outward, and what fixes that. */ -const CombatTab = () => { - const { data } = useBackend(); - const { research_path, combat_researched, ship_name } = data; - - return ( -
- - Shields, turrets, missiles and interdictors are researched on your own - ship, not bought. Use an R&D console wired to your ship's - research server; each node unlocks a board you print and build in. - - {research_path.map((node) => ( - - {node.name} - {node.desc} - - ))} - {combat_researched ? ( - - {ship_name ?? 'Your ship'} has Shuttle Warfare Systems researched. You - can build shields and weapons whenever you are ready. - - ) : ( - - {ship_name ?? 'Your ship'} has no Shuttle Warfare Systems research yet: - no shields, nothing to shoot back with. Take contracts in the middle - ring, earn research points, and stay out of the outer ring until you - are equipped. - - )} -
- ); -}; diff --git a/tgui/packages/voidcrew_tgui/interfaces/TutorialChecklist.tsx b/tgui/packages/voidcrew_tgui/interfaces/TutorialChecklist.tsx new file mode 100644 index 00000000000..367bbca71b9 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/TutorialChecklist.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Box, Button, Icon, Section, Stack, Tabs } from 'tgui-core/components'; +import type { BooleanLike } from 'tgui-core/react'; + +import { useBackend } from '../backend'; +import { Window } from '../layouts'; + +type Step = { + key: string; + name: string; + text: string; + optional: BooleanLike; +}; + +type Item = { + id: string; + name: string; + steps: Step[]; +}; + +type Data = { + items: Item[]; + done: string[]; + notes: Record; + excused: string[]; + spawn_open: BooleanLike; +}; + +const itemProgress = (item: Item, done: Set, excused: Set) => { + const counted = item.steps.filter( + (step) => !step.optional && !excused.has(step.key), + ); + const finished = counted.filter((step) => done.has(step.key)).length; + const complete = done.has(item.id) || finished >= counted.length; + return { finished, total: counted.length, complete }; +}; + +export const TutorialChecklist = () => { + const { act, data } = useBackend(); + const { items = [], notes = {}, spawn_open } = data; + const done = new Set(data.done || []); + const excused = new Set(data.excused || []); + + const firstOpen = + items.find((item) => !itemProgress(item, done, excused).complete) || + items[0]; + const [selectedId, setSelectedId] = useState( + firstOpen?.id, + ); + const selected = items.find((item) => item.id === selectedId) || firstOpen; + + return ( + + + + + + +
+ + {items.map((item) => { + const progress = itemProgress(item, done, excused); + return ( + setSelectedId(item.id)} + > + + {item.name} + + {progress.complete ? ( + + ) : ( + + {progress.finished}/{progress.total} + + )} + + + + ); + })} + +
+
+ +
+ {selected ? ( + selected.steps.map((step) => ( + + )) + ) : null} +
+
+
+
+ + act('toggle_spawn_open')} + > + Don't open on spawn + + +
+
+
+ ); +}; + +type StepRowProps = { + step: Step; + done: boolean; + note?: string; +}; + +const StepRow = (props: StepRowProps) => { + const { step, done, note } = props; + return ( + + + + + + + {step.name} + {step.optional ? ( + + (optional) + + ) : null} + + {step.text} + {!done && !!note ? ( + + {note} + + ) : null} + + + ); +}; diff --git a/tgui/packages/voidcrew_tgui/preferences/features/index.ts b/tgui/packages/voidcrew_tgui/preferences/features/index.ts index eafe325f6e9..90d65e12583 100644 --- a/tgui/packages/voidcrew_tgui/preferences/features/index.ts +++ b/tgui/packages/voidcrew_tgui/preferences/features/index.ts @@ -4,3 +4,4 @@ export * from './autotranslate'; export * from './bark_voice'; export * from './intents'; +export * from './tutorials'; diff --git a/tgui/packages/voidcrew_tgui/preferences/features/tutorials.tsx b/tgui/packages/voidcrew_tgui/preferences/features/tutorials.tsx new file mode 100644 index 00000000000..b64097623ac --- /dev/null +++ b/tgui/packages/voidcrew_tgui/preferences/features/tutorials.tsx @@ -0,0 +1,11 @@ +import { + CheckboxInput, + type FeatureToggle, +} from 'tgui/interfaces/PreferencesMenu/preferences/features/base'; + +export const tutorial_checklist: FeatureToggle = { + name: 'Open tutorial checklist on spawn', + category: 'GAMEPLAY', + description: 'Open the tutorial checklist each time you spawn.', + component: CheckboxInput, +}; diff --git a/voidcrew/_DEFINES/tutorials.dm b/voidcrew/_DEFINES/tutorials.dm new file mode 100644 index 00000000000..9649df392bf --- /dev/null +++ b/voidcrew/_DEFINES/tutorials.dm @@ -0,0 +1,68 @@ +// Tutorial checklist keys (voidcrew/modules/tutorials). +// Stored in the tg `tutorial_completions` table. Every key starts with "vc/" so it can +// never collide with tg's own tutorial keys, and must stay at most 64 characters. +// Never rename a key that has shipped: players' saved progress is stored under it. + +// Checklist items. An item key is written once every counted step of the item is done, +// so later step additions don't reopen a finished item. +#define TUTORIAL_ITEM_SHIP "vc/ship" +#define TUTORIAL_ITEM_CONTRACT "vc/contract" +#define TUTORIAL_ITEM_RESEARCH "vc/research" +#define TUTORIAL_ITEM_RUIN "vc/ruin" +#define TUTORIAL_ITEM_TRADE "vc/trade" +#define TUTORIAL_ITEM_POWER "vc/power" +#define TUTORIAL_ITEM_CHROME "vc/chrome" +#define TUTORIAL_ITEM_SURGERY "vc/surgery" +#define TUTORIAL_ITEM_ZONES "vc/zones" + +// Your ship +#define TUTORIAL_STEP_SHIP_MANAGE "vc/ship/manage" +#define TUTORIAL_STEP_SHIP_SEARCH "vc/ship/search" +#define TUTORIAL_STEP_SHIP_COURSE "vc/ship/course" +#define TUTORIAL_STEP_SHIP_DOCK "vc/ship/dock" +#define TUTORIAL_STEP_SHIP_UNDOCK "vc/ship/undock" +#define TUTORIAL_STEP_SHIP_BUILD "vc/ship/build" + +// First contract +#define TUTORIAL_STEP_CONTRACT_TAKE "vc/contract/take" +#define TUTORIAL_STEP_CONTRACT_WORK "vc/contract/work" +#define TUTORIAL_STEP_CONTRACT_TURN_IN "vc/contract/turn_in" + +// Research and surveys +#define TUTORIAL_STEP_RESEARCH_CONSOLE "vc/research/console" +#define TUTORIAL_STEP_RESEARCH_LATHE "vc/research/lathe" +#define TUTORIAL_STEP_RESEARCH_NODE "vc/research/node" +#define TUTORIAL_STEP_RESEARCH_BUILD "vc/research/build" +#define TUTORIAL_STEP_RESEARCH_SURVEY "vc/research/survey" +#define TUTORIAL_STEP_RESEARCH_NOTES "vc/research/notes" + +// Planet ruin +#define TUTORIAL_STEP_RUIN_LAND "vc/ruin/land" +#define TUTORIAL_STEP_RUIN_FIND "vc/ruin/find" +#define TUTORIAL_STEP_RUIN_CACHE "vc/ruin/cache" + +// Outpost trade +#define TUTORIAL_STEP_TRADE_ELEVATOR "vc/trade/elevator" +#define TUTORIAL_STEP_TRADE_BUY "vc/trade/buy" +#define TUTORIAL_STEP_TRADE_SELL "vc/trade/sell" + +// Ship power +#define TUTORIAL_STEP_POWER_SMES "vc/power/smes" +#define TUTORIAL_STEP_POWER_GENERATOR "vc/power/generator" +#define TUTORIAL_STEP_POWER_APC "vc/power/apc" + +// Cyberware +#define TUTORIAL_STEP_CHROME_BUY "vc/chrome/buy" +#define TUTORIAL_STEP_CHROME_INSTALL "vc/chrome/install" + +// First surgery +#define TUTORIAL_STEP_SURGERY_START "vc/surgery/start" +#define TUTORIAL_STEP_SURGERY_FINISH "vc/surgery/finish" + +// Zones +#define TUTORIAL_STEP_ZONES_YELLOW "vc/zones/yellow" +#define TUTORIAL_STEP_ZONES_WARFARE "vc/zones/warfare" +#define TUTORIAL_STEP_ZONES_RED "vc/zones/red" + +/// Prefix shared by every Voidcrew tutorial key in `tutorial_completions`. +#define TUTORIAL_KEY_PREFIX "vc/" diff --git a/voidcrew/edits/mobs/new_player.dm b/voidcrew/edits/mobs/new_player.dm index 06a93b2919d..0cbc2aa15cb 100644 --- a/voidcrew/edits/mobs/new_player.dm +++ b/voidcrew/edits/mobs/new_player.dm @@ -343,9 +343,8 @@ if(joined_ship.memo && humanc) addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(show_ship_memo_to_player), humanc, joined_ship), 3 SECONDS) - // First spawn of the round gets the orientation briefing, a beat after the - // memo (voidcrew/modules/onboarding/orientation.dm) - try_show_orientation_briefing(character) + // Tutorial checklist, a beat after the memo (voidcrew/modules/tutorials) + try_show_tutorial_checklist(character) return TRUE diff --git a/voidcrew/modules/onboarding/first_time_notice.dm b/voidcrew/modules/onboarding/first_time_notice.dm index 70c15616958..b3b43ea1f21 100644 --- a/voidcrew/modules/onboarding/first_time_notice.dm +++ b/voidcrew/modules/onboarding/first_time_notice.dm @@ -18,8 +18,8 @@ * player's preferences savefile. Closing the window without answering (or * disconnecting mid-prompt, which is exactly what the six-second player did) * leaves the flag unset, so their next visit asks again. This is distinct from - * the orientation briefing in orientation.dm, which is per-round and only - * fires after a spawn - a player who bounces off the lobby never reaches it. + * the tutorial checklist (voidcrew/modules/tutorials), which only opens after a + * spawn - a player who bounces off the lobby never reaches it. */ /// Preferences-savefile key marking that this player has answered the notice. diff --git a/voidcrew/modules/onboarding/orientation.dm b/voidcrew/modules/onboarding/orientation.dm deleted file mode 100644 index 22ecee9d339..00000000000 --- a/voidcrew/modules/onboarding/orientation.dm +++ /dev/null @@ -1,161 +0,0 @@ -/** - * # Orientation Briefing - * - * The screen a player gets the first time they spawn into a round. It covers the - * three things a new crew has no other way to find out: that the server is in an - * early testing phase and where bugs go, what the overmap's three zone bands - * actually permit, and that the gear which makes the deep bands survivable is - * researched rather than bought. - * - * One tab each, and every tab kept short. This opens four seconds after spawn, on - * top of a player who is still working out which way their ship faces, anything - * longer than a screen gets closed unread. - * - * Shown once per client per round - a respawn twenty minutes later does not - * re-open it - and available afterwards from the OOC tab, since the one time it - * opens automatically is also the busiest moment of a player's round. - * - * The zone rows are built here rather than written into the TSX so that a change - * to what a band permits is a change to one file, next to the defines that - * enforce it. The advisory this screen sets up is enforced in the field by - * zone_advisory.dm. - */ - -/// Where bug reports go during the testing phase. -#define VOIDCREW_DISCORD_URL "https://discord.gg/6z9wQTYJmK" - -/// The player wiki. Kept in step with WIKIURL in config/voidcrew/voidcrew_config.txt, -/// which is what the standard tg wiki verb reads. -#define VOIDCREW_WIKI_URL "https://wiki.voidcrew-lrp.com" - -/// Gap between spawning and the briefing opening. Long enough that it lands -/// after the client has finished loading into its body rather than on top of the -/// spawn, short enough that it is still the first thing they read. -#define ORIENTATION_BRIEFING_DELAY (4 SECONDS) - -/// Ckeys already shown the briefing this round. -GLOBAL_LIST_EMPTY(orientation_briefing_seen) - -/// One datum for the whole server: it holds no per-player state, and TGUI keys -/// its windows on (user, datum) so any number of players can have it open. -GLOBAL_DATUM_INIT(orientation_briefing, /datum/orientation_briefing, new) - -/datum/orientation_briefing - -/datum/orientation_briefing/ui_state(mob/user) - return GLOB.always_state - -/datum/orientation_briefing/ui_interact(mob/user, datum/tgui/ui) - ui = SStgui.try_update_ui(user, src, ui) - if(!ui) - ui = new(user, src, "OrientationBriefing") - ui.open() - -/datum/orientation_briefing/ui_data(mob/user) - var/list/data = list() - var/obj/structure/overmap/ship/ship = get_ship_from_atom(user) - data["ship_name"] = ship?.name - // Hard boolean rather than ship?.: a null here would read as "not - // researched" in the UI anyway, and saying so plainly is what a ghost or - // someone standing on a ruin should see. - data["combat_researched"] = ship ? ship.has_ship_combat_research() : FALSE - return data - -/datum/orientation_briefing/ui_static_data(mob/user) - var/list/data = list() - - data["zones"] = list( - list( - "name" = ZONE_NAME_GREEN, - "color" = ZONE_COLOR_GREEN, - "summary" = "The inner ring, where every ship starts. Weapons will not fire and nobody can board you.", - "rules" = list( - "No ship weapons, no boarding, no interdiction", - "Nothing out here damages your ship or attacks your crew", - "Lowest ore yields and contract pay", - ), - ), - list( - "name" = ZONE_NAME_YELLOW, - "color" = ZONE_COLOR_YELLOW, - "summary" = "The middle ring. Weapons still will not fire, but a crew can raid you.", - "rules" = list( - "No ship weapons", - "Boarding and interdiction are allowed", - "Player vs player requires escalation. Robbery is accepted, and combat is acceptable if they resist", - "About 50% more ore, about 70% more contract pay", - ), - ), - list( - "name" = ZONE_NAME_RED, - "color" = ZONE_COLOR_RED, - "summary" = "The outer ring. Nothing is off limits. Your hull can be shot at, and the ships out here are equipped for it.", - "rules" = list( - "Ship weapons are live: you can be shot", - "PLAYERS CAN KILL YOU FOR NOTHING", - "Boarding and interdiction are allowed", - "Double ore, about 160% more pay plus a trade voucher", - ), - ), - ) - - // Pulled off the live nodes so a rename in ship_combat/research.dm follows. - var/list/research_path = list() - for(var/node_id in list(TECHWEB_NODE_SHIP_COMBAT, TECHWEB_NODE_SHIP_COMBAT_SHIELDS, TECHWEB_NODE_SHIP_COMBAT_LASERS, TECHWEB_NODE_SHIP_COMBAT_MISSILES, TECHWEB_NODE_SHIP_COMBAT_INTERDICTOR)) - var/datum/techweb_node/node = SSresearch.techweb_node_by_id(node_id) - if(!node) - continue - research_path += list(list( - "name" = node.display_name, - "desc" = node.description, - )) - data["research_path"] = research_path - - return data - -/datum/orientation_briefing/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) - . = ..() - if(.) - return - switch(action) - if("open_discord") - // Hands the link to the player's own browser; TGUI itself has no way - // out to an external site. - DIRECT_OUTPUT(ui.user, link(VOIDCREW_DISCORD_URL)) - return TRUE - if("open_wiki") - DIRECT_OUTPUT(ui.user, link(VOIDCREW_WIKI_URL)) - return TRUE - -/** - * Opens the briefing for a player who has not seen it yet this round. - * - * Called from the spawn path. The seen-list is marked immediately rather than - * when the window opens, so a player who spawns, dies and respawns inside the - * delay still only queues one. - */ -/proc/try_show_orientation_briefing(mob/user) - if(!user?.client || !user.ckey) - return - if(user.ckey in GLOB.orientation_briefing_seen) - return - GLOB.orientation_briefing_seen += user.ckey - addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(show_orientation_briefing), user), ORIENTATION_BRIEFING_DELAY) - -/// Timer landing point for try_show_orientation_briefing(). -/proc/show_orientation_briefing(mob/user) - if(!user?.client) - return - GLOB.orientation_briefing.ui_interact(user) - -/client/verb/orientation_briefing() - set name = "Orientation Briefing" - set category = "OOC" - set desc = "Re-open the new player briefing: testing phase, zones, and ship combat gear." - if(!mob) - return - GLOB.orientation_briefing.ui_interact(mob) - -#undef ORIENTATION_BRIEFING_DELAY -#undef VOIDCREW_DISCORD_URL -#undef VOIDCREW_WIKI_URL diff --git a/voidcrew/modules/player_outposts/outpost_residents.dm b/voidcrew/modules/player_outposts/outpost_residents.dm index 045855736a1..ad3ed6a69a9 100644 --- a/voidcrew/modules/player_outposts/outpost_residents.dm +++ b/voidcrew/modules/player_outposts/outpost_residents.dm @@ -179,7 +179,7 @@ SEND_GLOBAL_SIGNAL(COMSIG_GLOB_CREWMEMBER_JOINED, character, job.title) log_shuttle("[character.ckey] arrived as a resident of [home.name]") to_chat(character, span_boldnotice("You are a resident of [home.name].")) - try_show_orientation_briefing(character) + try_show_tutorial_checklist(character) return TRUE /// Called before create_character transfers the lobby mind, after appearance prompts. diff --git a/voidcrew/modules/tutorials/hooks_contract.dm b/voidcrew/modules/tutorials/hooks_contract.dm new file mode 100644 index 00000000000..13aabe5125c --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_contract.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part B: contract accept, work and turn-in hooks. diff --git a/voidcrew/modules/tutorials/hooks_medical.dm b/voidcrew/modules/tutorials/hooks_medical.dm new file mode 100644 index 00000000000..7ddb118c67e --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_medical.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part C: cyberware install and surgery hooks. diff --git a/voidcrew/modules/tutorials/hooks_power.dm b/voidcrew/modules/tutorials/hooks_power.dm new file mode 100644 index 00000000000..70e8dd6b4a3 --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_power.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part C: SMES, generator and APC hooks. diff --git a/voidcrew/modules/tutorials/hooks_research.dm b/voidcrew/modules/tutorials/hooks_research.dm new file mode 100644 index 00000000000..51d753ae87e --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_research.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part C: R&D console, protolathe, research node, survey console and notes hooks. diff --git a/voidcrew/modules/tutorials/hooks_ship.dm b/voidcrew/modules/tutorials/hooks_ship.dm new file mode 100644 index 00000000000..22988806336 --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_ship.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part B: captain management, helm and construction console hooks. diff --git a/voidcrew/modules/tutorials/hooks_trade.dm b/voidcrew/modules/tutorials/hooks_trade.dm new file mode 100644 index 00000000000..70b6c068ad1 --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_trade.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part B: outpost elevator, shop purchase and buyback hooks. diff --git a/voidcrew/modules/tutorials/hooks_world.dm b/voidcrew/modules/tutorials/hooks_world.dm new file mode 100644 index 00000000000..5bd8de4f9af --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_world.dm @@ -0,0 +1 @@ +// Tutorial checklist hooks. Part C: closet, loot panel and ruin area hooks. diff --git a/voidcrew/modules/tutorials/hooks_zones.dm b/voidcrew/modules/tutorials/hooks_zones.dm new file mode 100644 index 00000000000..17d350d1b80 --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_zones.dm @@ -0,0 +1,21 @@ +// Tutorial checklist hooks. Part A: the Zones item (entering yellow and red space, +// researching Shuttle Warfare Systems). Overrides chain with any others of the same proc. + +/// The crossing timer lands here once the ship is actually over the line. +/obj/structure/overmap/ship/complete_zone_transition() + . = ..() + if(QDELETED(src) || istype(src, /obj/structure/overmap/ship/npc) || !SSovermap_zones?.initialized) + return + var/zone_type = SSovermap_zones.get_zone_type(get_turf(src)) + if(zone_type == ZONE_YELLOW || zone_type == ZONE_RED) + tutorial_credit_crew(src, TUTORIAL_STEP_ZONES_YELLOW, aboard_only = TRUE) + if(zone_type == ZONE_RED) + tutorial_credit_crew(src, TUTORIAL_STEP_ZONES_RED, aboard_only = TRUE) + +/datum/techweb/research_node(datum/techweb_node/node, force = FALSE, auto_adjust_cost = TRUE, get_that_dosh = TRUE, atom/research_source) + . = ..() + if(!. || !istype(node) || node.id != TECHWEB_NODE_SHIP_COMBAT) + return + var/obj/structure/overmap/ship/ship = metric_techweb_ship(src) + if(ship) + tutorial_credit_crew(ship, TUTORIAL_STEP_ZONES_WARFARE) diff --git a/voidcrew/modules/tutorials/preferences.dm b/voidcrew/modules/tutorials/preferences.dm new file mode 100644 index 00000000000..9083a1523ea --- /dev/null +++ b/voidcrew/modules/tutorials/preferences.dm @@ -0,0 +1,7 @@ +/// Opens the tutorial checklist by itself after every spawn. Progress is tracked +/// either way; this only controls the automatic window (tutorial_panel.dm). +/datum/preference/toggle/tutorial_checklist + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tutorial_checklist" + savefile_identifier = PREFERENCE_PLAYER + default_value = TRUE diff --git a/voidcrew/modules/tutorials/tutorial_catalog.dm b/voidcrew/modules/tutorials/tutorial_catalog.dm new file mode 100644 index 00000000000..3f993c12ad8 --- /dev/null +++ b/voidcrew/modules/tutorials/tutorial_catalog.dm @@ -0,0 +1,391 @@ +/** + * # Tutorial checklist catalog + * + * One /datum/vc_tutorial per checklist item and one /datum/vc_tutorial_step per step, + * each a singleton owned by SSvc_tutorials. Steps are credited passively by hooks + * (hooks_*.dm) calling tutorial_credit() or tutorial_credit_crew() with a step key + * from voidcrew/_DEFINES/tutorials.dm. Nothing here acts on the world. + * + * The step text is the one place how-to text is allowed in player UI (kit rule + * exception for the checklist). Keep it to one plain line. + */ + +/datum/vc_tutorial + /// Item key, a TUTORIAL_ITEM_* define. Written to the DB when the item completes. + var/id + /// Shown in the panel's item list. + var/name + /// Position in the panel, lowest first. + var/order = 0 + /// Step typepaths, in the order they are shown. + var/list/steps = list() + +/datum/vc_tutorial_step + /// Step key, a TUTORIAL_STEP_* define. At most 64 characters. + var/key + /// Short imperative title. + var/name + /// One plain line of how-to text. + var/text + /// Optional steps are shown but never counted toward the item. + var/optional = FALSE + +/** + * A short note shown under the step for this viewer, or null. + * Machine scans are cached per ship by SSvc_tutorials. + */ +/datum/vc_tutorial_step/proc/availability(mob/user) + return null + +/** + * TRUE when this step should not count toward its item for this viewer, for example + * a captain-only step seen by a crewmate. + */ +/datum/vc_tutorial_step/proc/excused(mob/user) + return FALSE + +/** + * State check run when the panel opens. TRUE credits the step to the viewer. + * Use it for things a player may have finished before the hook could see them. + */ +/datum/vc_tutorial_step/proc/state_satisfied(mob/user) + return FALSE + +// --------------------------------------------------------------------------- +// Your ship +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/ship + id = TUTORIAL_ITEM_SHIP + name = "Your ship" + order = 1 + steps = list( + /datum/vc_tutorial_step/ship_manage, + /datum/vc_tutorial_step/ship_search, + /datum/vc_tutorial_step/ship_course, + /datum/vc_tutorial_step/ship_dock, + /datum/vc_tutorial_step/ship_undock, + /datum/vc_tutorial_step/ship_build, + ) + +/datum/vc_tutorial_step/ship_manage + key = TUTORIAL_STEP_SHIP_MANAGE + name = "Open Ship Management" + text = "Use the Ship Management button to see your crew, invites and ship settings." + +/datum/vc_tutorial_step/ship_manage/availability(mob/user) + if(excused(user)) + return "Captain only." + return null + +/datum/vc_tutorial_step/ship_manage/excused(mob/user) + return !(locate(/datum/action/innate/captain_management) in user?.actions) + +/datum/vc_tutorial_step/ship_search + key = TUTORIAL_STEP_SHIP_SEARCH + name = "Search the lockers" + text = "Open your ship's lockers and crates, or Alt-click a tile to see everything on it." + +/// Shared by the helm steps: a hull with no helm can't do them. +/datum/vc_tutorial_step/proc/no_helm_note(mob/user) + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + if(ship && !SSvc_tutorials.ship_has_machine(ship, /obj/machinery/computer/helm)) + return "No helm aboard." + return null + +/datum/vc_tutorial_step/ship_course + key = TUTORIAL_STEP_SHIP_COURSE + name = "Set a course" + text = "At the helm, pick a heading, or click the map to fly there on autopilot." + +/datum/vc_tutorial_step/ship_course/availability(mob/user) + return no_helm_note(user) + +/datum/vc_tutorial_step/ship_dock + key = TUTORIAL_STEP_SHIP_DOCK + name = "Dock" + text = "Fly onto a planet, outpost or ruin and press Dock at the helm." + +/datum/vc_tutorial_step/ship_dock/availability(mob/user) + return no_helm_note(user) + +/datum/vc_tutorial_step/ship_undock + key = TUTORIAL_STEP_SHIP_UNDOCK + name = "Undock" + text = "Press Undock at the helm to leave." + +/datum/vc_tutorial_step/ship_undock/availability(mob/user) + return no_helm_note(user) + +/datum/vc_tutorial_step/ship_build + key = TUTORIAL_STEP_SHIP_BUILD + name = "See the construction console" + text = "Research Shuttle Construction, build the console, and use its drone to change your hull." + optional = TRUE + +// --------------------------------------------------------------------------- +// First contract +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/contract + id = TUTORIAL_ITEM_CONTRACT + name = "First contract" + order = 2 + steps = list( + /datum/vc_tutorial_step/contract_take, + /datum/vc_tutorial_step/contract_work, + /datum/vc_tutorial_step/contract_turn_in, + ) + +/datum/vc_tutorial_step/contract_take + key = TUTORIAL_STEP_CONTRACT_TAKE + name = "Take a contract" + text = "Open the contract board on your ship and accept one. Traders at outposts offer contracts too." + +/datum/vc_tutorial_step/contract_take/availability(mob/user) + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + if(ship && !SSvc_tutorials.ship_has_machine(ship, /obj/machinery/computer/mission_board)) + return "No board aboard. Traders offer contracts, or research Mission Logistics." + return null + +/datum/vc_tutorial_step/contract_work + key = TUTORIAL_STEP_CONTRACT_WORK + name = "Do the job" + text = "Follow the contract. The helm marks where to go." + +/datum/vc_tutorial_step/contract_turn_in + key = TUTORIAL_STEP_CONTRACT_TURN_IN + name = "Get paid" + text = "Bring the goods to the contract pad and press Turn In on the board." + +// --------------------------------------------------------------------------- +// Research and surveys +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/research + id = TUTORIAL_ITEM_RESEARCH + name = "Research and surveys" + order = 3 + steps = list( + /datum/vc_tutorial_step/research_console, + /datum/vc_tutorial_step/research_lathe, + /datum/vc_tutorial_step/research_node, + /datum/vc_tutorial_step/research_build, + /datum/vc_tutorial_step/research_survey, + /datum/vc_tutorial_step/research_notes, + ) + +/datum/vc_tutorial_step/research_console + key = TUTORIAL_STEP_RESEARCH_CONSOLE + name = "Open the R&D console" + text = "No R&D aboard? Print the R&D Kit at the autolathe and set it up." + +/datum/vc_tutorial_step/research_lathe + key = TUTORIAL_STEP_RESEARCH_LATHE + name = "Use the protolathe" + text = "This is where researched gear gets printed." + +/datum/vc_tutorial_step/research_node + key = TUTORIAL_STEP_RESEARCH_NODE + name = "Research the survey console" + text = "At the R&D console, research Orbital Survey Console." + +/datum/vc_tutorial_step/research_node/state_satisfied(mob/user) + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + var/datum/techweb/web = ship?.get_research_web() + return !!web?.researched_nodes[TECHWEB_NODE_SURVEY_CONSOLE] + +/datum/vc_tutorial_step/research_build + key = TUTORIAL_STEP_RESEARCH_BUILD + name = "Build a survey console" + text = "Print its board at the circuit imprinter and build it like any computer." + +/datum/vc_tutorial_step/research_build/state_satisfied(mob/user) + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + return ship && SSvc_tutorials.ship_has_machine(ship, /obj/machinery/computer/camera_advanced/shuttle_docker/survey) + +/datum/vc_tutorial_step/research_survey + key = TUTORIAL_STEP_RESEARCH_SURVEY + name = "Survey a planet" + text = "Fly next to a planet and start a survey at the console." + +/datum/vc_tutorial_step/research_notes + key = TUTORIAL_STEP_RESEARCH_NOTES + name = "Turn in the notes" + text = "Print the notes at the survey console and use them on the R&D console." + +// --------------------------------------------------------------------------- +// Planet ruin +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/ruin + id = TUTORIAL_ITEM_RUIN + name = "Planet ruin" + order = 4 + steps = list( + /datum/vc_tutorial_step/ruin_land, + /datum/vc_tutorial_step/ruin_find, + /datum/vc_tutorial_step/ruin_cache, + ) + +/datum/vc_tutorial_step/ruin_land + key = TUTORIAL_STEP_RUIN_LAND + name = "Land on a planet" + text = "Dock at a planet from the helm." + +/datum/vc_tutorial_step/ruin_find + key = TUTORIAL_STEP_RUIN_FIND + name = "Find a ruin" + text = "Explore until you find a building someone left behind." + +/datum/vc_tutorial_step/ruin_cache + key = TUTORIAL_STEP_RUIN_CACHE + name = "Open the cache" + text = "Find the abandoned cache and open it. No cache? Try another ruin." + +// --------------------------------------------------------------------------- +// Outpost trade +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/trade + id = TUTORIAL_ITEM_TRADE + name = "Outpost trade" + order = 5 + steps = list( + /datum/vc_tutorial_step/trade_elevator, + /datum/vc_tutorial_step/trade_buy, + /datum/vc_tutorial_step/trade_sell, + ) + +/datum/vc_tutorial_step/trade_elevator + key = TUTORIAL_STEP_TRADE_ELEVATOR + name = "Ride the elevator" + text = "From your berth, take the elevator up to the concourse." + +/datum/vc_tutorial_step/trade_buy + key = TUTORIAL_STEP_TRADE_BUY + name = "Buy something" + text = "Talk to a trader and buy from their stock." + +/datum/vc_tutorial_step/trade_sell + key = TUTORIAL_STEP_TRADE_SELL + name = "Sell something" + text = "Traders buy the items on their wanted list. Hold one and sell it." + +// --------------------------------------------------------------------------- +// Ship power +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/power + id = TUTORIAL_ITEM_POWER + name = "Ship power" + order = 6 + steps = list( + /datum/vc_tutorial_step/power_smes, + /datum/vc_tutorial_step/power_generator, + /datum/vc_tutorial_step/power_apc, + ) + +/datum/vc_tutorial_step/power_smes + key = TUTORIAL_STEP_POWER_SMES + name = "Check the batteries" + text = "Find the SMES in engineering. It powers the ship." + +/datum/vc_tutorial_step/power_generator + key = TUTORIAL_STEP_POWER_GENERATOR + name = "Start the generator" + text = "Load plasma into the generator and turn it on to recharge the batteries." + +/datum/vc_tutorial_step/power_generator/availability(mob/user) + if(excused(user)) + return "No generator aboard. The general store sells one." + return null + +/datum/vc_tutorial_step/power_generator/excused(mob/user) + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + return ship && !SSvc_tutorials.ship_has_machine(ship, /obj/machinery/power/port_gen) + +/datum/vc_tutorial_step/power_apc + key = TUTORIAL_STEP_POWER_APC + name = "Find an APC" + text = "Each room has a power panel on the wall. Use it to turn things off or back on." + +// --------------------------------------------------------------------------- +// Cyberware +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/chrome + id = TUTORIAL_ITEM_CHROME + name = "Cyberware" + order = 7 + steps = list( + /datum/vc_tutorial_step/chrome_buy, + /datum/vc_tutorial_step/chrome_install, + ) + +/datum/vc_tutorial_step/chrome_buy + key = TUTORIAL_STEP_CHROME_BUY + name = "Buy chrome" + text = "The general store sells street chrome. The Chop Shop in the red zone sells the rest." + +/datum/vc_tutorial_step/chrome_install + key = TUTORIAL_STEP_CHROME_INSTALL + name = "Get it installed" + text = "Lie in the Chop Shop's Chrome Cradle, or have it put in by surgery." + +// --------------------------------------------------------------------------- +// First surgery +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/surgery + id = TUTORIAL_ITEM_SURGERY + name = "First surgery" + order = 8 + steps = list( + /datum/vc_tutorial_step/surgery_start, + /datum/vc_tutorial_step/surgery_finish, + ) + +/datum/vc_tutorial_step/surgery_start + key = TUTORIAL_STEP_SURGERY_START + name = "Start an operation" + text = "Lay a hurt crewmate down, cover them with a bedsheet and pick Tend Wounds. The autolathe prints the tools." + +/datum/vc_tutorial_step/surgery_finish + key = TUTORIAL_STEP_SURGERY_FINISH + name = "Finish it" + text = "Work through each step with the right tool until the wound is closed." + +// --------------------------------------------------------------------------- +// Zones (replaces the old orientation briefing) +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/zones + id = TUTORIAL_ITEM_ZONES + name = "Zones" + order = 9 + steps = list( + /datum/vc_tutorial_step/zones_yellow, + /datum/vc_tutorial_step/zones_warfare, + /datum/vc_tutorial_step/zones_red, + ) + +/datum/vc_tutorial_step/zones_yellow + key = TUTORIAL_STEP_ZONES_YELLOW + name = "Enter the yellow zone" + text = "Green space has no fighting between crews. In yellow, other crews can board and rob you." + +/datum/vc_tutorial_step/zones_warfare + key = TUTORIAL_STEP_ZONES_WARFARE + name = "Research Shuttle Warfare Systems" + text = "At the R&D console. It leads to shields, guns, missiles and an interdictor for your ship." + +/datum/vc_tutorial_step/zones_warfare/state_satisfied(mob/user) + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + return ship && ship.has_ship_combat_research() + +/datum/vc_tutorial_step/zones_red + key = TUTORIAL_STEP_ZONES_RED + name = "Enter the red zone" + text = "Anything goes in red. Ships fire on each other and players can kill you for nothing." + optional = TRUE diff --git a/voidcrew/modules/tutorials/tutorial_panel.dm b/voidcrew/modules/tutorials/tutorial_panel.dm new file mode 100644 index 00000000000..4a8fcbdb44b --- /dev/null +++ b/voidcrew/modules/tutorials/tutorial_panel.dm @@ -0,0 +1,148 @@ +/** + * # Tutorial checklist panel + * + * One tgui window per viewer (TutorialChecklist). It opens by itself 4 s after every + * spawn unless the player turned that off, closed it this round, or has every item + * done. The "Tutorials" OOC verb opens it any time, ghosts included. + * + * Its only action is the "open on spawn" preference. It takes no refs and never acts + * on the world. + */ + +/// Gap between spawning and the checklist opening, so it lands after the client has +/// loaded into its body. +#define TUTORIAL_CHECKLIST_DELAY (4 SECONDS) + +/// Ckeys who closed the checklist this round. It stays shut for them until next round. +GLOBAL_LIST_EMPTY(tutorial_checklist_hidden) + +/// One datum for the whole server; tgui keys windows on (user, datum). +GLOBAL_DATUM_INIT(tutorial_checklist, /datum/tutorial_checklist, new) + +/datum/tutorial_checklist + +/datum/tutorial_checklist/ui_state(mob/user) + return GLOB.always_state + +/datum/tutorial_checklist/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + run_state_checks(user) + ui = new(user, src, "TutorialChecklist") + // Credits push updates themselves (SStgui.try_update_ui), so the window + // doesn't need to rebuild its notes every tick. + ui.set_autoupdate(FALSE) + ui.open() + +/// Credits steps the viewer has already done but no hook saw. +/datum/tutorial_checklist/proc/run_state_checks(mob/user) + var/ckey = tutorial_viewer_ckey(user) + if(!ckey) + return + SSvc_tutorials.build_catalog() + for(var/key in SSvc_tutorials.steps_by_key) + if(SSvc_tutorials.has_key(ckey, key)) + continue + var/datum/vc_tutorial_step/step = SSvc_tutorials.steps_by_key[key] + if(step.state_satisfied(user)) + tutorial_credit(user, key) + +/datum/tutorial_checklist/ui_static_data(mob/user) + SSvc_tutorials.build_catalog() + var/list/items = list() + for(var/datum/vc_tutorial/item as anything in SSvc_tutorials.items) + var/list/steps = list() + for(var/datum/vc_tutorial_step/step as anything in item.steps) + steps += list(list( + "key" = step.key, + "name" = step.name, + "text" = step.text, + "optional" = step.optional, + )) + items += list(list( + "id" = item.id, + "name" = item.name, + "steps" = steps, + )) + return list("items" = items) + +/datum/tutorial_checklist/ui_data(mob/user) + var/list/data = list() + var/ckey = tutorial_viewer_ckey(user) + var/list/done_keys = list() + var/list/notes = list() + var/list/excused = list() + var/list/progress = ckey ? SSvc_tutorials.progress[ckey] : null + for(var/key in progress) + done_keys += key + for(var/key in SSvc_tutorials.steps_by_key) + if(progress?[key]) + continue + var/datum/vc_tutorial_step/step = SSvc_tutorials.steps_by_key[key] + var/note = step.availability(user) + if(note) + notes[key] = note + if(step.excused(user)) + excused += key + data["done"] = done_keys + data["notes"] = notes + data["excused"] = excused + data["spawn_open"] = user.client?.prefs ? !!user.client.prefs.read_preference(/datum/preference/toggle/tutorial_checklist) : TRUE + return data + +/datum/tutorial_checklist/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + switch(action) + if("toggle_spawn_open") + var/datum/preferences/prefs = ui.user.client?.prefs + if(!prefs) + return FALSE + var/datum/preference/toggle/tutorial_checklist/pref = GLOB.preference_entries[/datum/preference/toggle/tutorial_checklist] + prefs.write_preference(pref, !prefs.read_preference(/datum/preference/toggle/tutorial_checklist)) + return TRUE + +/datum/tutorial_checklist/ui_close(mob/user) + . = ..() + var/ckey = tutorial_viewer_ckey(user) + if(ckey) + GLOB.tutorial_checklist_hidden |= ckey + +/// The ckey whose progress a viewer sees. +/proc/tutorial_viewer_ckey(mob/user) + return user?.ckey || ckey(user?.mind?.key) + +/** + * Opens the checklist 4 s after a spawn, if the player wants it and still has + * something left to do. Called from the spawn paths. + */ +/proc/try_show_tutorial_checklist(mob/user) + if(!user?.client) + return + var/ckey = tutorial_viewer_ckey(user) + if(!ckey || (ckey in GLOB.tutorial_checklist_hidden)) + return + if(!user.client.prefs?.read_preference(/datum/preference/toggle/tutorial_checklist)) + return + if(SSvc_tutorials.all_complete(ckey, user)) + return + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(show_tutorial_checklist), user), TUTORIAL_CHECKLIST_DELAY) + +/// Timer landing point for try_show_tutorial_checklist(). +/proc/show_tutorial_checklist(mob/user) + if(!user?.client) + return + if(tutorial_viewer_ckey(user) in GLOB.tutorial_checklist_hidden) + return + GLOB.tutorial_checklist.ui_interact(user) + +/client/verb/tutorial_checklist() + set name = "Tutorials" + set category = "OOC" + set desc = "Open the tutorial checklist." + if(!mob) + return + GLOB.tutorial_checklist.ui_interact(mob) + +#undef TUTORIAL_CHECKLIST_DELAY diff --git a/voidcrew/modules/tutorials/tutorial_progress.dm b/voidcrew/modules/tutorials/tutorial_progress.dm new file mode 100644 index 00000000000..86b30a33e58 --- /dev/null +++ b/voidcrew/modules/tutorials/tutorial_progress.dm @@ -0,0 +1,241 @@ +/** + * # Tutorial checklist progress + * + * Holds which tutorial steps each ckey has done and saves them to tg's existing + * `tutorial_completions` table under "vc/" keys (no schema change). tg's own + * SStutorials ignores keys it doesn't know, so the two never collide. + * + * Progress belongs to the ckey, not the body: death, ghosting and cryo lose nothing. + * With no database it lives in memory for the round. + * + * The credit API every hook calls: + * - tutorial_credit(mob/user, step_key) + * - tutorial_credit_crew(obj/structure/overmap/ship/ship, step_key, aboard_only = FALSE) + */ + +/// How long a ship's "is this machine aboard" scan stays cached. +#define TUTORIAL_MACHINE_CACHE_TIME (60 SECONDS) + +SUBSYSTEM_DEF(vc_tutorials) + name = "Tutorial Checklist" + flags = SS_NO_FIRE + + /// ckey -> list(key -> TRUE). Step and item keys both live here. + var/list/progress = list() + /// Every checklist item singleton, sorted by order. + var/list/datum/vc_tutorial/items + /// step key -> step singleton + var/list/datum/vc_tutorial_step/steps_by_key + /// step key -> the item singleton that owns it + var/list/datum/vc_tutorial/item_by_step + /// Ckeys whose saved progress has been loaded (or asked for) this round. + var/list/loaded_ckeys = list() + /// "[REF(ship)]|[type]" -> list(world.time, result). Keyed by ref string so no hard ref is held. + var/list/machine_cache = list() + +/datum/controller/subsystem/vc_tutorials/Initialize() + build_catalog() + RegisterSignal(SSdcs, COMSIG_GLOB_CLIENT_CONNECT, PROC_REF(on_client_connect)) + for(var/client/client as anything in GLOB.clients) + if(client?.ckey) + INVOKE_ASYNC(src, PROC_REF(load_progress), client.ckey) + return SS_INIT_SUCCESS + +/// Builds the item and step singletons. Safe to call more than once; callers that can run +/// before Initialize call it first. +/datum/controller/subsystem/vc_tutorials/proc/build_catalog() + if(items) + return + items = list() + steps_by_key = list() + item_by_step = list() + for(var/item_type in subtypesof(/datum/vc_tutorial)) + var/datum/vc_tutorial/item = new item_type + if(!item.id) + continue + var/list/step_instances = list() + for(var/step_type in item.steps) + var/datum/vc_tutorial_step/step = new step_type + step_instances += step + steps_by_key[step.key] = step + item_by_step[step.key] = item + item.steps = step_instances + items += item + sortTim(items, GLOBAL_PROC_REF(cmp_vc_tutorial_order)) + +/// Sort helper for the checklist items. +/proc/cmp_vc_tutorial_order(datum/vc_tutorial/a, datum/vc_tutorial/b) + return a.order - b.order + +/datum/controller/subsystem/vc_tutorials/proc/on_client_connect(datum/source, client/client) + SIGNAL_HANDLER + if(client?.ckey) + INVOKE_ASYNC(src, PROC_REF(load_progress), client.ckey) + +/// Loads a ckey's saved "vc/" keys and merges them into anything credited this round. +/datum/controller/subsystem/vc_tutorials/proc/load_progress(ckey) + if(!ckey || loaded_ckeys[ckey]) + return + if(!SSdbcore.IsConnected()) + return + loaded_ckeys[ckey] = TRUE + var/datum/db_query/query = SSdbcore.NewQuery( + "SELECT tutorial_key FROM [format_table_name("tutorial_completions")] WHERE ckey = :ckey AND tutorial_key LIKE :prefix", + list("ckey" = ckey, "prefix" = "[TUTORIAL_KEY_PREFIX]%") + ) + if(!query.Execute()) + qdel(query) + loaded_ckeys -= ckey + return + var/list/done = progress[ckey] + if(!done) + done = list() + progress[ckey] = done + while(query.NextRow()) + var/key = query.item[1] + if(key) + done[key] = TRUE + qdel(query) + var/mob/viewer = get_mob_by_ckey(ckey) + if(viewer) + SStgui.try_update_ui(viewer, GLOB.tutorial_checklist) + +/// Writes keys for a ckey. Duplicates are ignored by the table's unique index. +/datum/controller/subsystem/vc_tutorials/proc/save_keys(ckey, list/keys) + if(!SSdbcore.IsConnected()) + return + for(var/key in keys) + var/datum/db_query/query = SSdbcore.NewQuery( + "INSERT INTO [format_table_name("tutorial_completions")] (ckey, tutorial_key) VALUES (:ckey, :tutorial_key) ON DUPLICATE KEY UPDATE tutorial_key = tutorial_key", + list("ckey" = ckey, "tutorial_key" = key) + ) + query.warn_execute() + qdel(query) + +/// TRUE if the ckey has this key (step or item). +/datum/controller/subsystem/vc_tutorials/proc/has_key(ckey, key) + var/list/done = progress[ckey] + return !!done?[key] + +/// TRUE if the item is complete for this viewer: its item key is saved, or every +/// counted step (not optional, not excused for them) is done. +/datum/controller/subsystem/vc_tutorials/proc/item_complete(datum/vc_tutorial/item, ckey, mob/user) + var/list/done = progress[ckey] + if(!done) + return FALSE + if(done[item.id]) + return TRUE + for(var/datum/vc_tutorial_step/step as anything in item.steps) + if(step.optional || done[step.key]) + continue + if(user && step.excused(user)) + continue + return FALSE + return TRUE + +/// TRUE if every item is complete for this viewer. +/datum/controller/subsystem/vc_tutorials/proc/all_complete(ckey, mob/user) + build_catalog() + for(var/datum/vc_tutorial/item as anything in items) + if(!item_complete(item, ckey, user)) + return FALSE + return TRUE + +/// Marks a step for a ckey. Returns TRUE if it was newly marked. +/datum/controller/subsystem/vc_tutorials/proc/credit_ckey(ckey, step_key, mob/user) + build_catalog() + var/list/done = progress[ckey] + if(!done) + done = list() + progress[ckey] = done + if(done[step_key]) + return FALSE + done[step_key] = TRUE + var/list/to_save = list(step_key) + var/datum/vc_tutorial/item = item_by_step[step_key] + if(item && !done[item.id] && item_complete(item, ckey, user)) + done[item.id] = TRUE + to_save += item.id + if(SSdbcore.IsConnected()) + INVOKE_ASYNC(src, PROC_REF(save_keys), ckey, to_save) + var/mob/viewer = user?.client ? user : get_mob_by_ckey(ckey) + if(viewer) + SStgui.try_update_ui(viewer, GLOB.tutorial_checklist) + return TRUE + +/// Whether a machine of this type is aboard the ship, cached for TUTORIAL_MACHINE_CACHE_TIME +/// because get_ship_machines() walks every area's contents. +/datum/controller/subsystem/vc_tutorials/proc/ship_has_machine(obj/structure/overmap/ship/ship, machine_type) + if(QDELETED(ship)) + return FALSE + var/cache_key = "[REF(ship)]|[machine_type]" + var/list/cached = machine_cache[cache_key] + if(cached && world.time < cached[1] + TUTORIAL_MACHINE_CACHE_TIME) + return cached[2] + var/result = length(ship.get_ship_machines(machine_type)) > 0 + machine_cache[cache_key] = list(world.time, result) + return result + +/** + * Credits a tutorial step to a player. Returns TRUE if it was newly credited. + * Cheap when the step is already done: one assoc lookup. + */ +/proc/tutorial_credit(mob/user, step_key) + if(!user || !step_key) + return FALSE + var/ckey = user.ckey || ckey(user.mind?.key) + if(!ckey) + return FALSE + var/list/done = SSvc_tutorials.progress[ckey] + if(done?[step_key]) + return FALSE + return SSvc_tutorials.credit_ckey(ckey, step_key, user) + +/** + * Credits a step to every member of a ship's crew, through their current bodies. + * With aboard_only, only members whose body is aboard that ship. + */ +/proc/tutorial_credit_crew(obj/structure/overmap/ship/ship, step_key, aboard_only = FALSE) + if(!ship?.ship_team || !step_key) + return + for(var/datum/mind/member as anything in ship.ship_team.members) + var/mob/body = member?.current + if(!body) + continue + if(aboard_only && get_ship_from_atom(body) != ship) + continue + tutorial_credit(body, step_key) + +// --------------------------------------------------------------------------- +// Ship signals: dock, undock, land, survey +// --------------------------------------------------------------------------- + +/obj/structure/overmap/ship/Initialize(mapload, datum/map_template/shuttle/voidcrew/template) + . = ..() + if(. == INITIALIZE_HINT_QDEL || istype(src, /obj/structure/overmap/ship/npc)) + return + RegisterSignal(src, COMSIG_VOIDCREW_SHIP_DOCKED, PROC_REF(tutorial_on_docked)) + RegisterSignal(src, COMSIG_VOIDCREW_SHIP_UNDOCKED, PROC_REF(tutorial_on_undocked)) + RegisterSignal(src, COMSIG_VOIDCREW_SURVEY_COMPLETED, PROC_REF(tutorial_on_survey)) + +/obj/structure/overmap/ship/proc/tutorial_on_docked(datum/source) + SIGNAL_HANDLER + // A checkpoint rebuild also sends DOCKED; only a real berth counts. + if(!docked) + return + tutorial_credit_crew(src, TUTORIAL_STEP_SHIP_DOCK, aboard_only = TRUE) + if(istype(docked, /obj/structure/overmap/planet) && !istype(docked, /obj/structure/overmap/planet/empty)) + tutorial_credit_crew(src, TUTORIAL_STEP_RUIN_LAND, aboard_only = TRUE) + +/obj/structure/overmap/ship/proc/tutorial_on_undocked(datum/source) + SIGNAL_HANDLER + // Also sent while a derelict hull is being deleted; nobody flew that. + if(QDELETED(src)) + return + tutorial_credit_crew(src, TUTORIAL_STEP_SHIP_UNDOCK, aboard_only = TRUE) + +/obj/structure/overmap/ship/proc/tutorial_on_survey(datum/source, celestial_type) + SIGNAL_HANDLER + tutorial_credit_crew(src, TUTORIAL_STEP_RESEARCH_SURVEY) + +#undef TUTORIAL_MACHINE_CACHE_TIME From b8a6526ff53372172a4c4716b3b3cf7877785cea Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:54:00 -0800 Subject: [PATCH 45/72] Add tutorial checklist hooks for ships, contracts and trade --- voidcrew/modules/tutorials/hooks_contract.dm | 30 ++++++++++ voidcrew/modules/tutorials/hooks_ship.dm | 45 +++++++++++++++ voidcrew/modules/tutorials/hooks_trade.dm | 59 ++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/voidcrew/modules/tutorials/hooks_contract.dm b/voidcrew/modules/tutorials/hooks_contract.dm index 13aabe5125c..00c8fdca320 100644 --- a/voidcrew/modules/tutorials/hooks_contract.dm +++ b/voidcrew/modules/tutorials/hooks_contract.dm @@ -1 +1,31 @@ // Tutorial checklist hooks. Part B: contract accept, work and turn-in hooks. +// Every override calls the parent with its exact argument list and returns its value. + +/// Covers the mission board, trader contracts and the kingpin. Returns TRUE or an error string. +/obj/structure/overmap/ship/accept_mission(datum/mission/mission) + . = ..() + if(. == TRUE && ismob(usr)) + tutorial_credit(usr, TUTORIAL_STEP_CONTRACT_TAKE) + +/// Crew who join after the contract was taken still get the step from the board. +/obj/machinery/computer/mission_board/ui_interact(mob/user, datum/tgui/ui) + . = ..() + var/obj/structure/overmap/ship/ship = get_ship() + if(length(ship?.active_missions)) + tutorial_credit(user, TUTORIAL_STEP_CONTRACT_TAKE) + +/datum/mission/on_objective_completed(datum/mission_objective/objective) + var/obj/structure/overmap/ship/ship = (active && !failed && !completed) ? servant : null + . = ..() + if(ship && !QDELETED(ship)) + tutorial_credit_crew(ship, TUTORIAL_STEP_CONTRACT_WORK) + +/// The mission qdels itself during the parent, so the ship is saved first. +/datum/mission/finish_mission(atom/reward_anchor) + var/obj/structure/overmap/ship/ship = (active && !failed && !completed) ? servant : null + . = ..() + if(!ship || QDELETED(ship) || !completed) + return + // Deliveries complete at hand-in, so the work step is credited here too. + tutorial_credit_crew(ship, TUTORIAL_STEP_CONTRACT_WORK) + tutorial_credit_crew(ship, TUTORIAL_STEP_CONTRACT_TURN_IN) diff --git a/voidcrew/modules/tutorials/hooks_ship.dm b/voidcrew/modules/tutorials/hooks_ship.dm index 22988806336..4565c12116c 100644 --- a/voidcrew/modules/tutorials/hooks_ship.dm +++ b/voidcrew/modules/tutorials/hooks_ship.dm @@ -1 +1,46 @@ // Tutorial checklist hooks. Part B: captain management, helm and construction console hooks. +// Every override calls the parent with its exact argument list and returns its value. +// Overrides of the same proc in other files (Part C's ruin cache closet hook) chain. + +/datum/captain_management_ui/ui_interact(mob/user, datum/tgui/ui) + . = ..() + tutorial_credit(user, TUTORIAL_STEP_SHIP_MANAGE) + +/// Lockers and crates aboard a ship. Hot path: one ckey lookup once the step is done. +/obj/structure/closet/open(mob/living/user, force = FALSE, special_effects = TRUE) + . = ..() + if(. != TRUE || !user) + return + var/user_ckey = user.ckey + if(!user_ckey || SSvc_tutorials.has_key(user_ckey, TUTORIAL_STEP_SHIP_SEARCH)) + return + if(get_ship_from_atom(src)) + tutorial_credit(user, TUTORIAL_STEP_SHIP_SEARCH) + +/// Alt-click tile search. +/datum/lootpanel/open(turf/tile) + . = ..() + var/mob/viewer = owner?.mob + if(viewer) + tutorial_credit(viewer, TUTORIAL_STEP_SHIP_SEARCH) + +/obj/machinery/computer/helm/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(!current_ship) + return + var/plotted = FALSE + switch(action) + if("change_heading", "set_course") + plotted = current_ship.commanded_course != BURN_NONE + if("autopilot") + plotted = current_ship.autopilot_engaged + if(!plotted) + return + var/mob/pilot = ui?.user || usr + if(ismob(pilot)) + tutorial_credit(pilot, TUTORIAL_STEP_SHIP_COURSE) + +/obj/machinery/computer/camera_advanced/base_construction/ship/ui_interact(mob/user, datum/tgui/ui) + . = ..() + if(current_ship) + tutorial_credit(user, TUTORIAL_STEP_SHIP_BUILD) diff --git a/voidcrew/modules/tutorials/hooks_trade.dm b/voidcrew/modules/tutorials/hooks_trade.dm index 70b6c068ad1..cd2391da556 100644 --- a/voidcrew/modules/tutorials/hooks_trade.dm +++ b/voidcrew/modules/tutorials/hooks_trade.dm @@ -1 +1,60 @@ // Tutorial checklist hooks. Part B: outpost elevator, shop purchase and buyback hooks. +// Every override calls the parent with its exact argument list and returns its value. +// The barter, chart and rumor SKUs replace try_purchase without calling the base, so +// each is covered on its own. + +/obj/machinery/outpost_elevator/complete_ride(floor_id, datum/weakref/rider_ref) + var/list/riders + var/list/turf/own_alcove = get_own_alcove() + for(var/turf/alcove_turf as anything in own_alcove) + for(var/mob/living/rider in alcove_turf) + if(rider.ckey) + LAZYSET(riders, rider, alcove_turf) + . = ..() + for(var/mob/living/rider as anything in riders) + if(!QDELETED(rider) && get_turf(rider) != riders[rider]) + tutorial_credit(rider, TUTORIAL_STEP_TRADE_ELEVATOR) + +/// Credits a finished purchase: every buy counts for trade, chrome also for cyberware. +/proc/tutorial_credit_purchase(mob/living/user, datum/shop_sku/sku) + if(!user || !sku) + return + tutorial_credit(user, TUTORIAL_STEP_TRADE_BUY) + var/bought = sku.item_path + if(ispath(bought, /obj/item/organ/cyberimp) || ispath(bought, /obj/item/organ/eyes/robotic/cyberware) || ispath(bought, /obj/item/autosurgeon)) + tutorial_credit(user, TUTORIAL_STEP_CHROME_BUY) + +/datum/shop_sku/try_purchase(mob/living/user, mob/living/basic/outpost_trader/vendor) + . = ..() + if(. == TRUE) + tutorial_credit_purchase(user, src) + +/datum/shop_sku/barter/try_purchase(mob/living/user, mob/living/basic/outpost_trader/vendor) + . = ..() + if(. == TRUE) + tutorial_credit_purchase(user, src) + +/datum/shop_sku/anomaly_chart/try_purchase(mob/living/user, mob/living/basic/outpost_trader/vendor) + . = ..() + if(. == TRUE) + tutorial_credit_purchase(user, src) + +/datum/shop_sku/ruin_chart/try_purchase(mob/living/user, mob/living/basic/outpost_trader/vendor) + . = ..() + if(. == TRUE) + tutorial_credit_purchase(user, src) + +/datum/shop_sku/rumor/try_purchase(mob/living/user, mob/living/basic/outpost_trader/vendor) + . = ..() + if(. == TRUE) + tutorial_credit_purchase(user, src) + +/datum/shop_buyback/try_sell(mob/living/user, mob/living/basic/outpost_trader/vendor, quiet = FALSE) + . = ..() + if(. == TRUE) + tutorial_credit(user, TUTORIAL_STEP_TRADE_SELL) + +/datum/shop_buyback/try_sell_bulk(mob/living/user, mob/living/basic/outpost_trader/vendor) + . = ..() + if(. > 0) + tutorial_credit(user, TUTORIAL_STEP_TRADE_SELL) From 2c9dffc5ab21403018f21765fe9c25d1126f7307 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:54:01 -0800 Subject: [PATCH 46/72] Add the world, research, power and medical tutorial checklist hooks --- voidcrew/modules/tutorials/hooks_medical.dm | 22 ++++++++++++++ voidcrew/modules/tutorials/hooks_power.dm | 16 ++++++++++ voidcrew/modules/tutorials/hooks_research.dm | 31 ++++++++++++++++++++ voidcrew/modules/tutorials/hooks_world.dm | 31 ++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/voidcrew/modules/tutorials/hooks_medical.dm b/voidcrew/modules/tutorials/hooks_medical.dm index 7ddb118c67e..d29f08f97a6 100644 --- a/voidcrew/modules/tutorials/hooks_medical.dm +++ b/voidcrew/modules/tutorials/hooks_medical.dm @@ -1 +1,23 @@ // Tutorial checklist hooks. Part C: cyberware install and surgery hooks. +// Overrides chain with any others of the same proc; each returns the parent's value unchanged. + +/// Chrome credits the body that receives it; a surgeon gets credit under Surgery instead. +/obj/item/organ/cyberimp/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + . = ..() + if(!special && organ_owner?.ckey) + tutorial_credit(organ_owner, TUTORIAL_STEP_CHROME_INSTALL) + +/obj/item/organ/eyes/robotic/cyberware/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + . = ..() + if(!special && organ_owner?.ckey) + tutorial_credit(organ_owner, TUTORIAL_STEP_CHROME_INSTALL) + +/datum/surgery_step/initiate(mob/living/user, mob/living/target, target_zone, obj/item/tool, datum/surgery/surgery, try_to_fail = FALSE) + tutorial_credit(user, TUTORIAL_STEP_SURGERY_START) + return ..() + +/// Credited before the parent, which qdels the surgery. Not COMSIG_MOB_SURGERY_FINISHED: +/// that fires from Destroy(), including for cancelled operations. +/datum/surgery/complete(mob/surgeon) + tutorial_credit(surgeon, TUTORIAL_STEP_SURGERY_FINISH) + return ..() diff --git a/voidcrew/modules/tutorials/hooks_power.dm b/voidcrew/modules/tutorials/hooks_power.dm index 70e8dd6b4a3..01b8cadac50 100644 --- a/voidcrew/modules/tutorials/hooks_power.dm +++ b/voidcrew/modules/tutorials/hooks_power.dm @@ -1 +1,17 @@ // Tutorial checklist hooks. Part C: SMES, generator and APC hooks. +// Overrides chain with any others of the same proc; each returns the parent's value unchanged. + +/obj/machinery/power/smes/ui_interact(mob/user, datum/tgui/ui) + . = ..() + if(user?.ckey && !SSvc_tutorials.progress[user.ckey]?[TUTORIAL_STEP_POWER_SMES] && get_ship_from_atom(src)) + tutorial_credit(user, TUTORIAL_STEP_POWER_SMES) + +/obj/machinery/power/port_gen/pacman/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(action == "toggle_power" && active) + tutorial_credit(ui?.user || usr, TUTORIAL_STEP_POWER_GENERATOR) + +/obj/machinery/power/apc/ui_interact(mob/user, datum/tgui/ui) + . = ..() + if(user?.ckey && !SSvc_tutorials.progress[user.ckey]?[TUTORIAL_STEP_POWER_APC] && get_ship_from_atom(src)) + tutorial_credit(user, TUTORIAL_STEP_POWER_APC) diff --git a/voidcrew/modules/tutorials/hooks_research.dm b/voidcrew/modules/tutorials/hooks_research.dm index 51d753ae87e..8876a373a38 100644 --- a/voidcrew/modules/tutorials/hooks_research.dm +++ b/voidcrew/modules/tutorials/hooks_research.dm @@ -1 +1,32 @@ // Tutorial checklist hooks. Part C: R&D console, protolathe, research node, survey console and notes hooks. +// Overrides chain with any others of the same proc; each returns the parent's value unchanged. + +/obj/machinery/computer/rdconsole/ui_interact(mob/user, datum/tgui/ui = null) + . = ..() + if(stored_research) + tutorial_credit(user, TUTORIAL_STEP_RESEARCH_CONSOLE) + +/// Research notes are redeemed (and deleted) by the Voidcrew override in research/edits/_rd_consoles.dm. +/// Notes of any origin count, because merged notes change origin. +/obj/machinery/computer/rdconsole/attackby(obj/item/attacking_item, mob/user, list/modifiers, list/attack_modifiers) + var/is_notes = istype(attacking_item, /obj/item/research_notes) + . = ..() + if(is_notes && QDELETED(attacking_item)) + tutorial_credit(user, TUTORIAL_STEP_RESEARCH_NOTES) + +/obj/machinery/rnd/production/ui_interact(mob/user, datum/tgui/ui) + . = ..() + if(istype(src, /obj/machinery/rnd/production/protolathe)) + tutorial_credit(user, TUTORIAL_STEP_RESEARCH_LATHE) + +/datum/techweb/research_node(datum/techweb_node/node, force = FALSE, auto_adjust_cost = TRUE, get_that_dosh = TRUE, atom/research_source) + . = ..() + if(!. || !istype(node) || node.id != TECHWEB_NODE_SURVEY_CONSOLE) + return + var/obj/structure/overmap/ship/ship = metric_techweb_ship(src) + if(ship) + tutorial_credit_crew(ship, TUTORIAL_STEP_RESEARCH_NODE) + +/obj/machinery/computer/camera_advanced/shuttle_docker/survey/ui_interact(mob/user, datum/tgui/ui) + . = ..() + tutorial_credit(user, TUTORIAL_STEP_RESEARCH_BUILD) diff --git a/voidcrew/modules/tutorials/hooks_world.dm b/voidcrew/modules/tutorials/hooks_world.dm index 5bd8de4f9af..6153ebace3b 100644 --- a/voidcrew/modules/tutorials/hooks_world.dm +++ b/voidcrew/modules/tutorials/hooks_world.dm @@ -1 +1,32 @@ // Tutorial checklist hooks. Part C: closet, loot panel and ruin area hooks. +// Overrides chain with any others of the same proc; each returns the parent's value unchanged. + +/// Opening an abandoned cache off-ship. Purchased caches are delivered aboard, so they don't count. +/obj/structure/closet/crate/zone_loot/open(mob/living/user, force = FALSE, special_effects = TRUE) + . = ..() + if(.) + tutorial_credit_ruin_cache(src, user) + +/obj/structure/closet/crate/secure/loot/open(mob/living/user, force = FALSE, special_effects = TRUE) + . = ..() + if(.) + tutorial_credit_ruin_cache(src, user) + +/proc/tutorial_credit_ruin_cache(obj/structure/closet/cache, mob/living/user) + if(!ismob(user) || !user.ckey) + return + if(SSvc_tutorials.progress[user.ckey]?[TUTORIAL_STEP_RUIN_CACHE]) + return + if(get_ship_from_atom(cache)) + return + tutorial_credit(user, TUTORIAL_STEP_RUIN_CACHE) + +/// Walking into a ruin. No ship maps /area/ruin. +/area/ruin/Entered(atom/movable/arrived, area/old_area) + . = ..() + if(!ismob(arrived)) + return + var/mob/visitor = arrived + if(!visitor.ckey || SSvc_tutorials.progress[visitor.ckey]?[TUTORIAL_STEP_RUIN_FIND]) + return + tutorial_credit(visitor, TUTORIAL_STEP_RUIN_FIND) From dcfb77a3a3c11467c6c1bc2a087e588b1eb1add0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:03:30 -0800 Subject: [PATCH 47/72] Tighten tutorial hooks: credit only real course changes, generator starts and crew board views --- voidcrew/modules/tutorials/hooks_contract.dm | 2 +- voidcrew/modules/tutorials/hooks_power.dm | 3 ++- voidcrew/modules/tutorials/hooks_ship.dm | 13 +++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/voidcrew/modules/tutorials/hooks_contract.dm b/voidcrew/modules/tutorials/hooks_contract.dm index 00c8fdca320..22ef35d7ab2 100644 --- a/voidcrew/modules/tutorials/hooks_contract.dm +++ b/voidcrew/modules/tutorials/hooks_contract.dm @@ -11,7 +11,7 @@ /obj/machinery/computer/mission_board/ui_interact(mob/user, datum/tgui/ui) . = ..() var/obj/structure/overmap/ship/ship = get_ship() - if(length(ship?.active_missions)) + if(length(ship?.active_missions) && user?.mind && (user.mind in ship.ship_team?.members)) tutorial_credit(user, TUTORIAL_STEP_CONTRACT_TAKE) /datum/mission/on_objective_completed(datum/mission_objective/objective) diff --git a/voidcrew/modules/tutorials/hooks_power.dm b/voidcrew/modules/tutorials/hooks_power.dm index 01b8cadac50..0ee92d39d30 100644 --- a/voidcrew/modules/tutorials/hooks_power.dm +++ b/voidcrew/modules/tutorials/hooks_power.dm @@ -7,8 +7,9 @@ tutorial_credit(user, TUTORIAL_STEP_POWER_SMES) /obj/machinery/power/port_gen/pacman/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + var/was_active = active . = ..() - if(action == "toggle_power" && active) + if(action == "toggle_power" && active && !was_active) tutorial_credit(ui?.user || usr, TUTORIAL_STEP_POWER_GENERATOR) /obj/machinery/power/apc/ui_interact(mob/user, datum/tgui/ui) diff --git a/voidcrew/modules/tutorials/hooks_ship.dm b/voidcrew/modules/tutorials/hooks_ship.dm index 4565c12116c..248bfa8f009 100644 --- a/voidcrew/modules/tutorials/hooks_ship.dm +++ b/voidcrew/modules/tutorials/hooks_ship.dm @@ -9,7 +9,7 @@ /// Lockers and crates aboard a ship. Hot path: one ckey lookup once the step is done. /obj/structure/closet/open(mob/living/user, force = FALSE, special_effects = TRUE) . = ..() - if(. != TRUE || !user) + if(. != TRUE || !ismob(user)) return var/user_ckey = user.ckey if(!user_ckey || SSvc_tutorials.has_key(user_ckey, TUTORIAL_STEP_SHIP_SEARCH)) @@ -24,16 +24,21 @@ if(viewer) tutorial_credit(viewer, TUTORIAL_STEP_SHIP_SEARCH) +/// Only a course this click actually changed counts, so refused clicks (viewer helms, +/// non-crew, a busy ship) never credit a course someone else set. /obj/machinery/computer/helm/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + var/obj/structure/overmap/ship/ship_before = current_ship + var/course_before = current_ship?.commanded_course + var/autopilot_before = current_ship?.autopilot_engaged . = ..() - if(!current_ship) + if(!current_ship || current_ship != ship_before) return var/plotted = FALSE switch(action) if("change_heading", "set_course") - plotted = current_ship.commanded_course != BURN_NONE + plotted = current_ship.commanded_course != BURN_NONE && current_ship.commanded_course != course_before if("autopilot") - plotted = current_ship.autopilot_engaged + plotted = current_ship.autopilot_engaged && !autopilot_before if(!plotted) return var/mob/pilot = ui?.user || usr From 9ce471f30e5f968feae50581e4f84bf4dc57a451 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:03:35 -0800 Subject: [PATCH 48/72] Add a dissection step to First surgery and a Vestige pact checklist item --- tgstation.dme | 1 + voidcrew/_DEFINES/tutorials.dm | 8 ++++ voidcrew/modules/tutorials/hooks_medical.dm | 8 ++++ voidcrew/modules/tutorials/hooks_vestige.dm | 36 ++++++++++++++++ .../modules/tutorials/tutorial_catalog.dm | 41 +++++++++++++++++++ 5 files changed, 94 insertions(+) create mode 100644 voidcrew/modules/tutorials/hooks_vestige.dm diff --git a/tgstation.dme b/tgstation.dme index 6602642b33f..d3ef6674f28 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7715,6 +7715,7 @@ #include "voidcrew\modules\tutorials\hooks_research.dm" #include "voidcrew\modules\tutorials\hooks_ship.dm" #include "voidcrew\modules\tutorials\hooks_trade.dm" +#include "voidcrew\modules\tutorials\hooks_vestige.dm" #include "voidcrew\modules\tutorials\hooks_world.dm" #include "voidcrew\modules\tutorials\hooks_zones.dm" #include "voidcrew\modules\tutorials\preferences.dm" diff --git a/voidcrew/_DEFINES/tutorials.dm b/voidcrew/_DEFINES/tutorials.dm index 9649df392bf..51d14d9f855 100644 --- a/voidcrew/_DEFINES/tutorials.dm +++ b/voidcrew/_DEFINES/tutorials.dm @@ -14,6 +14,7 @@ #define TUTORIAL_ITEM_CHROME "vc/chrome" #define TUTORIAL_ITEM_SURGERY "vc/surgery" #define TUTORIAL_ITEM_ZONES "vc/zones" +#define TUTORIAL_ITEM_VESTIGE "vc/vestige" // Your ship #define TUTORIAL_STEP_SHIP_MANAGE "vc/ship/manage" @@ -58,11 +59,18 @@ // First surgery #define TUTORIAL_STEP_SURGERY_START "vc/surgery/start" #define TUTORIAL_STEP_SURGERY_FINISH "vc/surgery/finish" +#define TUTORIAL_STEP_SURGERY_DISSECT "vc/surgery/dissect" // Zones #define TUTORIAL_STEP_ZONES_YELLOW "vc/zones/yellow" #define TUTORIAL_STEP_ZONES_WARFARE "vc/zones/warfare" #define TUTORIAL_STEP_ZONES_RED "vc/zones/red" +// Vestige pact +#define TUTORIAL_STEP_VESTIGE_FIND "vc/vestige/find" +#define TUTORIAL_STEP_VESTIGE_PACT "vc/vestige/pact" +#define TUTORIAL_STEP_VESTIGE_TRIAL "vc/vestige/trial" +#define TUTORIAL_STEP_VESTIGE_CLAIM "vc/vestige/claim" + /// Prefix shared by every Voidcrew tutorial key in `tutorial_completions`. #define TUTORIAL_KEY_PREFIX "vc/" diff --git a/voidcrew/modules/tutorials/hooks_medical.dm b/voidcrew/modules/tutorials/hooks_medical.dm index d29f08f97a6..0bf36193c66 100644 --- a/voidcrew/modules/tutorials/hooks_medical.dm +++ b/voidcrew/modules/tutorials/hooks_medical.dm @@ -21,3 +21,11 @@ /datum/surgery/complete(mob/surgeon) tutorial_credit(surgeon, TUTORIAL_STEP_SURGERY_FINISH) return ..() + +/// Every dissection tier inherits this step. The body's dissection_points_paid grows by +/// exactly what the notes are worth, so a rise means research was actually earned. +/datum/surgery_step/experimental_dissection/success(mob/user, mob/living/target, target_zone, obj/item/tool, datum/surgery/surgery, default_display_results = FALSE) + var/paid_before = target?.dissection_points_paid + . = ..() + if(target && target.dissection_points_paid > paid_before) + tutorial_credit(user, TUTORIAL_STEP_SURGERY_DISSECT) diff --git a/voidcrew/modules/tutorials/hooks_vestige.dm b/voidcrew/modules/tutorials/hooks_vestige.dm new file mode 100644 index 00000000000..c407208be1a --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_vestige.dm @@ -0,0 +1,36 @@ +// Tutorial checklist hooks: the Vestige pact item (entering a vestige, the pact, +// the trial and the boon). Overrides chain with any others of the same proc; each +// returns the parent's value unchanged. + +/// Chains with the /area/ruin/Entered hook in hooks_world.dm. +/area/ruin/space/has_grav/vestige/Entered(atom/movable/arrived, area/old_area) + . = ..() + if(!ismob(arrived)) + return + var/mob/visitor = arrived + if(!visitor.ckey || SSvc_tutorials.progress[visitor.ckey]?[TUTORIAL_STEP_VESTIGE_FIND]) + return + tutorial_credit(visitor, TUTORIAL_STEP_VESTIGE_FIND) + +/datum/vestige_trial/begin(mob/living/user) + . = ..() + tutorial_credit(user, TUTORIAL_STEP_VESTIGE_PACT) + +/// The trial qdels itself in the parent, so the owner and body are saved first. +/// `fulfilled` is set only when the completion really took. +/datum/vestige_trial/complete() + var/was_fulfilled = fulfilled + var/datum/mind/keeper = owner + var/mob/body = owner?.current + . = ..() + if(was_fulfilled || !fulfilled || !keeper) + return + tutorial_credit(body || keeper.current, TUTORIAL_STEP_VESTIGE_TRIAL) + +/// The parent qdels the action and may replace the claiming body (shapeshift upgrades). +/datum/action/vestige_reward/claim(mob/living/user, datum/mind/mind, datum/vestige_boon/choice_type) + var/was_paid = paid + . = ..() + if(was_paid || !paid) + return + tutorial_credit(QDELETED(user) ? mind?.current : user, TUTORIAL_STEP_VESTIGE_CLAIM) diff --git a/voidcrew/modules/tutorials/tutorial_catalog.dm b/voidcrew/modules/tutorials/tutorial_catalog.dm index 3f993c12ad8..4f02dfcd14d 100644 --- a/voidcrew/modules/tutorials/tutorial_catalog.dm +++ b/voidcrew/modules/tutorials/tutorial_catalog.dm @@ -344,6 +344,7 @@ steps = list( /datum/vc_tutorial_step/surgery_start, /datum/vc_tutorial_step/surgery_finish, + /datum/vc_tutorial_step/surgery_dissect, ) /datum/vc_tutorial_step/surgery_start @@ -356,6 +357,11 @@ name = "Finish it" text = "Work through each step with the right tool until the wound is closed." +/datum/vc_tutorial_step/surgery_dissect + key = TUTORIAL_STEP_SURGERY_DISSECT + name = "Dissect a body" + text = "Lay a corpse or dead creature on a table, cover it with a bedsheet and pick Dissection. What you learn goes into your ship's research." + // --------------------------------------------------------------------------- // Zones (replaces the old orientation briefing) // --------------------------------------------------------------------------- @@ -389,3 +395,38 @@ name = "Enter the red zone" text = "Anything goes in red. Ships fire on each other and players can kill you for nothing." optional = TRUE + +// --------------------------------------------------------------------------- +// Vestige pact +// --------------------------------------------------------------------------- + +/datum/vc_tutorial/vestige + id = TUTORIAL_ITEM_VESTIGE + name = "Vestige pact" + order = 10 + steps = list( + /datum/vc_tutorial_step/vestige_find, + /datum/vc_tutorial_step/vestige_pact, + /datum/vc_tutorial_step/vestige_trial, + /datum/vc_tutorial_step/vestige_claim, + ) + +/datum/vc_tutorial_step/vestige_find + key = TUTORIAL_STEP_VESTIGE_FIND + name = "Find a vestige" + text = "Vestiges appear during the round as red signals on sensors. Fly to one and go inside." + +/datum/vc_tutorial_step/vestige_pact + key = TUTORIAL_STEP_VESTIGE_PACT + name = "Make a pact" + text = "Find the patron inside and choose Pact. It sets you one trial." + +/datum/vc_tutorial_step/vestige_trial + key = TUTORIAL_STEP_VESTIGE_TRIAL + name = "Complete the trial" + text = "Do what the patron asked. The reminder on your screen shows your progress." + +/datum/vc_tutorial_step/vestige_claim + key = TUTORIAL_STEP_VESTIGE_CLAIM + name = "Claim your boon" + text = "Use the Claim Your Boon button and pick one." From 8dcd981dcfa67614b1932521d67b35ec97eb8876 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:26:09 -0800 Subject: [PATCH 49/72] Add unit tests for the tutorial checklist catalog and hooks --- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_tutorials.dm | 332 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 voidcrew/modules/unit_tests/voidcrew_tutorials.dm diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..f571c86dd6b 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -226,6 +226,7 @@ #include "voidcrew_survey_research_links.dm" #include "voidcrew_trader_checkpoint.dm" #include "voidcrew_trader_rental_locker.dm" +#include "voidcrew_tutorials.dm" #include "voidcrew_vestige.dm" #include "voidcrew_vestige_abductor.dm" #include "voidcrew_vestige_ascension.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_tutorials.dm b/voidcrew/modules/unit_tests/voidcrew_tutorials.dm new file mode 100644 index 00000000000..d9807f3840e --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_tutorials.dm @@ -0,0 +1,332 @@ +// Tutorial checklist (voidcrew/modules/tutorials). Each test drives the real hooked proc and +// reads SSvc_tutorials.progress. Fork defines are included after the test harness, so step +// keys are literals here; the catalog test checks every TUTORIAL_STEP_* a hook uses against +// voidcrew/_DEFINES/tutorials.dm and the live catalog. +// Credits INSERT into tutorial_completions only when a database is connected; the fake ckey +// makes those rows harmless and the unique index keeps them to one per key. + +#define TUTORIAL_TEST_CKEY "tutorialtest" + +/// Ship interiors come from the zone teleport fixture (area + registered docking port). +/datum/unit_test/voidcrew_tutorials + parent_type = /datum/unit_test/voidcrew_zone_teleport + abstract_type = /datum/unit_test/voidcrew_tutorials + var/list/crew_records = list() + var/list/keyed_players = list() + +/datum/unit_test/voidcrew_tutorials/New() + . = ..() + // The fixture's Destroy restores this; it never set it itself. + zones_were_active = SSovermap_zones.zones_active + SSvc_tutorials.progress -= TUTORIAL_TEST_CKEY + +/datum/unit_test/voidcrew_tutorials/Destroy() + SSvc_tutorials.progress -= TUTORIAL_TEST_CKEY + GLOB.tutorial_checklist_hidden -= TUTORIAL_TEST_CKEY + // A deleted mob that still holds a key with no client is reported as a stuck player. + for(var/mob/player as anything in keyed_players) + player.key = null + QDEL_LIST(crew_records) + return ..() + +/// A keyed player body. The hot-path hooks bail on mobs without a ckey. +/datum/unit_test/voidcrew_tutorials/proc/make_player(turf/location = run_loc_floor_bottom_left) + var/mob/living/carbon/human/consistent/player = allocate(/mob/living/carbon/human/consistent, location) + player.mind_initialize() + allocated += player.mind + player.mind.key = TUTORIAL_TEST_CKEY + player.key = TUTORIAL_TEST_CKEY + keyed_players += player + return player + +/// A ship whose interior is ship_turf, crewed by the given player. +/datum/unit_test/voidcrew_tutorials/proc/make_crewed_ship(turf/ship_turf, mob/living/player) + var/obj/structure/overmap/ship/ship = make_ship(ship_turf, run_loc_floor_top_right) + var/datum/team/voidcrew/team = allocate(/datum/team/voidcrew) + team.ship = ship + ship.ship_team = team + if(player) + team.join_member(player.mind) + crew_records |= ship.get_crew_record() + return ship + +/datum/unit_test/voidcrew_tutorials/proc/done(key) + return SSvc_tutorials.has_key(TUTORIAL_TEST_CKEY, key) + +/datum/unit_test/voidcrew_tutorials/proc/reset_progress() + SSvc_tutorials.progress -= TUTORIAL_TEST_CKEY + +/// Swaps one turf into a fresh area; the fixture restores it. +/datum/unit_test/voidcrew_tutorials/proc/make_area(turf/location, area_type) + var/area/new_area = new area_type + ship_areas += new_area + original_areas[location] = get_area(location) + location.change_area(get_area(location), new_area) + return new_area + +/// Keys unique and short, under "vc/", each item has a counted step, and every key a hook +/// credits is a step in the catalog. +/datum/unit_test/voidcrew_tutorials/catalog/Run() + SSvc_tutorials.build_catalog() + TEST_ASSERT_EQUAL(length(SSvc_tutorials.items), 10, "The checklist does not have its ten items") + var/list/seen = list() + for(var/datum/vc_tutorial/item as anything in SSvc_tutorials.items) + check_key(item.id, seen) + TEST_ASSERT(item.name, "[item.id] has no name") + var/counted = 0 + for(var/datum/vc_tutorial_step/step as anything in item.steps) + check_key(step.key, seen) + TEST_ASSERT(findtext(step.key, "[item.id]/") == 1, "[step.key] is not under its item [item.id]") + TEST_ASSERT(step.name && step.text, "[step.key] has no name or text") + if(!step.optional) + counted++ + TEST_ASSERT(counted, "[item.id] has no step that counts") + + var/list/define_values = list() + var/defines = file2text("voidcrew/_DEFINES/tutorials.dm") + var/regex/define_regex = regex("#define (TUTORIAL_STEP_\[A-Z_\]+) \"(\[^\"\]+)\"", "g") + while(define_regex.Find(defines)) + define_values[define_regex.group[1]] = define_regex.group[2] + TEST_ASSERT(length(define_values) >= 30, "Could not read the step defines") + var/regex/use_regex = regex("TUTORIAL_STEP_\[A-Z_\]+", "g") + var/files_read = 0 + for(var/file_name in flist("voidcrew/modules/tutorials/")) + if(!findtext(file_name, ".dm", -3)) + continue + files_read++ + var/source = file2text("voidcrew/modules/tutorials/[file_name]") + while(use_regex.Find(source)) + var/define_name = use_regex.match + var/key = define_values[define_name] + TEST_ASSERT(key, "[file_name] uses [define_name], which is not defined") + TEST_ASSERT(SSvc_tutorials.steps_by_key[key], "[file_name] credits [key], which no checklist step owns") + TEST_ASSERT(files_read >= 10, "Could not read the tutorial hook files") + +/datum/unit_test/voidcrew_tutorials/catalog/proc/check_key(key, list/seen) + TEST_ASSERT(istext(key) && length(key), "A checklist key is empty") + TEST_ASSERT(length(key) <= 64, "[key] is longer than the 64 characters the table holds") + TEST_ASSERT(findtext(key, "vc/") == 1, "[key] does not start with vc/") + TEST_ASSERT(!seen[key], "[key] is used twice") + seen[key] = TRUE + +/// Opening a locker aboard counts; one off the ship doesn't. +/datum/unit_test/voidcrew_tutorials/ship_search/Run() + var/turf/deck = run_loc_floor_bottom_left + var/turf/outside = get_step(deck, EAST) + var/mob/living/carbon/human/player = make_player(outside) + make_crewed_ship(deck, player) + var/obj/structure/closet/off_ship = allocate(/obj/structure/closet, outside) + TEST_ASSERT(off_ship.open(player), "The off-ship locker would not open") + TEST_ASSERT(!done("vc/ship/search"), "A locker off the ship counted as searching the ship") + player.forceMove(deck) + var/obj/structure/closet/aboard = allocate(/obj/structure/closet, deck) + TEST_ASSERT(aboard.open(player), "The locker aboard would not open") + TEST_ASSERT(done("vc/ship/search"), "Opening a locker aboard was not credited") + +/// An abandoned cache opened off-ship counts. +/datum/unit_test/voidcrew_tutorials/ruin_cache/Run() + var/mob/living/carbon/human/player = make_player() + // A crate won't open with someone standing on it. + var/obj/structure/closet/crate/zone_loot/cache = allocate(/obj/structure/closet/crate/zone_loot, get_step(run_loc_floor_bottom_left, EAST)) + TEST_ASSERT(cache.open(player), "The cache would not open") + TEST_ASSERT(done("vc/ruin/cache"), "Opening an abandoned cache was not credited") + +/// Walking into a ruin, and into a vestige (which is also a ruin). +/datum/unit_test/voidcrew_tutorials/ruin_entry/Run() + var/turf/start = run_loc_floor_bottom_left + var/turf/ruin_turf = get_step(start, EAST) + var/turf/vestige_turf = get_step(ruin_turf, EAST) + make_area(ruin_turf, /area/ruin) + make_area(vestige_turf, /area/ruin/space/has_grav/vestige) + var/mob/living/carbon/human/player = make_player(start) + player.forceMove(ruin_turf) + TEST_ASSERT(done("vc/ruin/find"), "Entering a ruin was not credited") + TEST_ASSERT(!done("vc/vestige/find"), "A plain ruin counted as a vestige") + reset_progress() + player.forceMove(vestige_turf) + TEST_ASSERT(done("vc/vestige/find"), "Entering a vestige was not credited") + TEST_ASSERT(done("vc/ruin/find"), "A vestige did not count as a ruin") + +/datum/unit_test/voidcrew_tutorials/surgery/Run() + var/mob/living/carbon/human/surgeon = make_player() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/datum/surgery/operation = new /datum/surgery(patient, BODY_ZONE_CHEST, patient.get_bodypart(BODY_ZONE_CHEST)) + operation.complete(surgeon) + TEST_ASSERT(QDELETED(operation), "The finished operation was not cleaned up") + TEST_ASSERT(done("vc/surgery/finish"), "Finishing an operation was not credited") + +/// A dissection that pays research counts; reopening a spent body doesn't. +/datum/unit_test/voidcrew_tutorials/dissection/Run() + var/mob/living/carbon/human/surgeon = make_player() + var/mob/living/carbon/human/corpse = allocate(/mob/living/carbon/human/consistent, get_step(run_loc_floor_bottom_left, EAST)) + corpse.death() + var/datum/surgery_step/experimental_dissection/dissect = allocate(/datum/surgery_step/experimental_dissection) + dissect.success(surgeon, corpse, BODY_ZONE_CHEST, null, null) + TEST_ASSERT(corpse.dissection_points_paid > 0, "The dissection paid nothing") + TEST_ASSERT(done("vc/surgery/dissect"), "A paying dissection was not credited") + reset_progress() + dissect.success(surgeon, corpse, BODY_ZONE_CHEST, null, null) + TEST_ASSERT(!done("vc/surgery/dissect"), "Re-dissecting a spent body was credited") + for(var/obj/item/research_notes/notes in surgeon.loc) + qdel(notes) + for(var/obj/item/research_notes/notes in surgeon.held_items) + qdel(notes) + +/// Survey console and Shuttle Warfare Systems credit the crew of the ship holding the server. +/datum/unit_test/voidcrew_tutorials/research_nodes/Run() + var/turf/deck = run_loc_floor_bottom_left + var/mob/living/carbon/human/player = make_player(get_step(deck, EAST)) + make_crewed_ship(deck, player) + var/datum/techweb/web = allocate(/datum/techweb) + var/obj/machinery/rnd/server/server = allocate(/obj/machinery/rnd/server, deck) + web.techweb_servers |= server + // TECHWEB_NODE_SURVEY_CONSOLE and TECHWEB_NODE_SHIP_COMBAT + TEST_ASSERT(web.research_node(SSresearch.techweb_node_by_id("survey_console"), TRUE, FALSE, FALSE), "The survey console node could not be researched") + TEST_ASSERT(done("vc/research/node"), "Researching the survey console was not credited to the crew") + TEST_ASSERT(!done("vc/zones/warfare"), "The survey node counted as warfare research") + TEST_ASSERT(web.research_node(SSresearch.techweb_node_by_id("ship_combat"), TRUE, FALSE, FALSE), "The warfare node could not be researched") + TEST_ASSERT(done("vc/zones/warfare"), "Researching Shuttle Warfare Systems was not credited to the crew") + web.techweb_servers -= server + +/// Buying chrome at a trader counts for trade and for cyberware. +/datum/unit_test/voidcrew_tutorials/purchase/Run() + var/mob/living/carbon/human/player = make_player() + var/obj/structure/overmap/ship/ship = make_crewed_ship(get_step(run_loc_floor_bottom_left, EAST), player) + var/datum/bank_account/account = allocate(/datum/bank_account, "Tutorial test", null, 1, FALSE) + account.account_balance = 1000 + ship.ship_account = account + var/datum/shop_sku/sku = allocate(/datum/shop_sku) + sku.item_path = /obj/item/organ/cyberimp/cyberware/shock_coils + sku.price_credits = 500 + sku.stock = 1 + TEST_ASSERT(sku.try_purchase(player, null), "The stocked general SKU would not sell") + TEST_ASSERT_EQUAL(account.account_balance, 500, "The purchase charged the wrong amount") + TEST_ASSERT(done("vc/trade/buy"), "Buying from a trader was not credited") + TEST_ASSERT(done("vc/chrome/buy"), "Buying chrome was not credited") + ship.ship_account = null + for(var/obj/item/organ/cyberimp/bought in player.held_items) + qdel(bought) + for(var/obj/item/organ/cyberimp/bought in player.loc) + qdel(bought) + +/// A real install counts for the body that gets it; a special (spawn/clone) insert doesn't. +/datum/unit_test/voidcrew_tutorials/chrome_install/Run() + var/mob/living/carbon/human/player = make_player() + var/obj/item/organ/cyberimp/chest/nutriment/silent = allocate(/obj/item/organ/cyberimp/chest/nutriment) + silent.Insert(player, TRUE) + TEST_ASSERT(silent.owner == player, "The special insert failed") + TEST_ASSERT(!done("vc/chrome/install"), "A special insert was credited") + silent.Remove(player, TRUE) + var/obj/item/organ/cyberimp/chest/nutriment/implant = allocate(/obj/item/organ/cyberimp/chest/nutriment) + implant.Insert(player) + TEST_ASSERT(implant.owner == player, "The install failed") + TEST_ASSERT(done("vc/chrome/install"), "Installing an implant was not credited") + +/// Redeeming research notes at an R&D console. +/datum/unit_test/voidcrew_tutorials/research_notes/Run() + var/mob/living/carbon/human/player = make_player() + var/obj/machinery/computer/rdconsole/console = allocate(/obj/machinery/computer/rdconsole, get_step(run_loc_floor_bottom_left, EAST)) + var/datum/techweb/web = allocate(/datum/techweb) + console.stored_research = web + var/obj/item/research_notes/notes = allocate(/obj/item/research_notes, run_loc_floor_bottom_left) + notes.value = 10 + console.attackby(notes, player) + TEST_ASSERT(QDELETED(notes), "The console did not take the notes") + TEST_ASSERT(done("vc/research/notes"), "Turning in research notes was not credited") + console.stored_research = null + +/// Starting a PACMAN counts; stopping one doesn't. +/datum/unit_test/voidcrew_tutorials/generator/Run() + var/mob/living/carbon/human/player = make_player() + var/obj/machinery/power/port_gen/pacman/generator = allocate(/obj/machinery/power/port_gen/pacman, get_step(run_loc_floor_bottom_left, EAST)) + generator.sheets = 50 + var/datum/tgui/generator_ui = allocate(/datum/tgui, player, generator, "PortableGenerator") + generator.ui_act("toggle_power", list(), generator_ui, generator_ui.state) + TEST_ASSERT(generator.active, "The generator did not start") + TEST_ASSERT(done("vc/power/generator"), "Starting the generator was not credited") + reset_progress() + generator.ui_act("toggle_power", list(), generator_ui, generator_ui.state) + TEST_ASSERT(!generator.active, "The generator did not stop") + TEST_ASSERT(!done("vc/power/generator"), "Stopping the generator was credited") + +/// Dock and undock credit crew aboard; a ship with no berth doesn't count as docked. +/datum/unit_test/voidcrew_tutorials/dock_undock/Run() + var/turf/deck = run_loc_floor_bottom_left + var/mob/living/carbon/human/player = make_player(deck) + var/obj/structure/overmap/ship/ship = make_crewed_ship(deck, player) + SEND_SIGNAL(ship, "voidcrew_ship_docked") // COMSIG_VOIDCREW_SHIP_DOCKED + TEST_ASSERT(!done("vc/ship/dock"), "A rebuild with no berth counted as docking") + var/obj/structure/overmap/ship/berth = allocate(/obj/structure/overmap/ship, get_step(run_loc_floor_top_right, WEST)) + ship.docked = berth + SEND_SIGNAL(ship, "voidcrew_ship_docked") // COMSIG_VOIDCREW_SHIP_DOCKED + ship.docked = null + TEST_ASSERT(done("vc/ship/dock"), "Docking was not credited to the crew aboard") + TEST_ASSERT(!done("vc/ruin/land"), "Docking at a ship counted as landing on a planet") + SEND_SIGNAL(ship, "voidcrew_ship_undocked") // COMSIG_VOIDCREW_SHIP_UNDOCKED + TEST_ASSERT(done("vc/ship/undock"), "Undocking was not credited to the crew aboard") + +/// Pact, completed trial and claimed boon. +/datum/unit_test/voidcrew_tutorials/vestige/Run() + var/mob/living/carbon/human/player = make_player() + var/datum/mind/keeper = player.mind + var/datum/vestige_trial/trial = allocate(/datum/vestige_trial, keeper, "Test patron", list()) + keeper.active_vestige_trial = trial + trial.begin(player) + TEST_ASSERT(done("vc/vestige/pact"), "Making a pact was not credited") + trial.complete() + TEST_ASSERT(QDELETED(trial), "The completed trial was not cleaned up") + TEST_ASSERT(done("vc/vestige/trial"), "Completing a trial was not credited") + reset_progress() + trial.complete() + TEST_ASSERT(!done("vc/vestige/trial"), "A stale completion was credited") + + var/datum/vestige_record/record = get_vestige_record(keeper, create = TRUE) + var/list/candidates = list(/datum/vestige_boon/unit_test_a) + record.pending_candidates = candidates.Copy() + var/datum/action/vestige_reward/reward = allocate(/datum/action/vestige_reward, keeper, candidates, "Test patron") + reward.Grant(player) + reward.claim(player, keeper, /datum/vestige_boon/unit_test_a) + TEST_ASSERT(/datum/vestige_boon/unit_test_a in keeper.vestige_boons, "The boon was not paid") + TEST_ASSERT(done("vc/vestige/claim"), "Claiming a boon was not credited") + GLOB.vestige_records -= ckey(keeper.key) + qdel(record) + +/// Taking a contract credits whoever accepted it; work and turn-in credit the crew. +/datum/unit_test/voidcrew_tutorials_contract + parent_type = /datum/unit_test/voidcrew_zone_missions + +/datum/unit_test/voidcrew_tutorials_contract + var/mob/keyed_member + +/datum/unit_test/voidcrew_tutorials_contract/Destroy() + SSvc_tutorials.progress -= TUTORIAL_TEST_CKEY + keyed_member?.key = null + return ..() + +/datum/unit_test/voidcrew_tutorials_contract/Run() + SSvc_tutorials.progress -= TUTORIAL_TEST_CKEY + var/obj/structure/overmap/ship/ship = make_ship(0, FALSE) + // The fixture's bare account has no job, so it can't unregister itself on delete. + var/datum/bank_account/fixture_account = ship.ship_account + SSeconomy.bank_accounts_by_id -= "[fixture_account.account_id]" + fixture_account.add_to_accounts = FALSE + var/mob/living/carbon/human/member = allocate(/mob/living/carbon/human/consistent) + member.mind_initialize() + allocated += member.mind + member.mind.key = TUTORIAL_TEST_CKEY + member.key = TUTORIAL_TEST_CKEY + keyed_member = member + ship.enlist_crewmember(member) + var/datum/mission/mission = allocate(/datum/mission/zone_missions_test, null, 1) + ship.available_missions += mission + usr = member + TEST_ASSERT_EQUAL(ship.accept_mission(mission), TRUE, "The contract was refused") + usr = null + TEST_ASSERT(SSvc_tutorials.has_key(TUTORIAL_TEST_CKEY, "vc/contract/take"), "Taking a contract was not credited") + TEST_ASSERT(!SSvc_tutorials.has_key(TUTORIAL_TEST_CKEY, "vc/contract/turn_in"), "Taking a contract counted as turning it in") + TEST_ASSERT(mission.turn_in(run_loc_floor_bottom_left, null), "The contract did not settle") + TEST_ASSERT(SSvc_tutorials.has_key(TUTORIAL_TEST_CKEY, "vc/contract/work"), "Doing the contract was not credited to the crew") + TEST_ASSERT(SSvc_tutorials.has_key(TUTORIAL_TEST_CKEY, "vc/contract/turn_in"), "Turning in the contract was not credited to the crew") + TEST_ASSERT(SSvc_tutorials.has_key(TUTORIAL_TEST_CKEY, "vc/contract"), "Finishing every contract step did not complete the item") + +#undef TUTORIAL_TEST_CKEY From c302e661f8711500afc0dfc81e77fe110e09b817 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:51:35 -0800 Subject: [PATCH 50/72] Add dock terminal lobby bezel art and bezel classes Resizable bezel pieces at 1x (9-slice frame, deck with hazard-post ends, keycaps in idle/hover/pressed/disabled, lamps, grille, button tray), bezel.json describing their insets and positions, and lobby_terminal.css classes that assemble them at an integer scale. Ready lamps are cropped from icons/hud/lobby/collapse_expand.dmi. --- .../modules/lobby_terminal/html/bezel.json | 146 ++++++++++ voidcrew/modules/lobby_terminal/html/deck.png | Bin 0 -> 450 bytes .../lobby_terminal/html/frame_9slice.png | Bin 0 -> 429 bytes .../modules/lobby_terminal/html/grille.png | Bin 0 -> 97 bytes .../html/key_character_disabled.png | Bin 0 -> 469 bytes .../html/key_character_hover.png | Bin 0 -> 487 bytes .../html/key_character_idle.png | Bin 0 -> 508 bytes .../html/key_character_pressed.png | Bin 0 -> 490 bytes .../lobby_terminal/html/key_join_disabled.png | Bin 0 -> 382 bytes .../lobby_terminal/html/key_join_hover.png | Bin 0 -> 396 bytes .../lobby_terminal/html/key_join_idle.png | Bin 0 -> 404 bytes .../lobby_terminal/html/key_join_pressed.png | Bin 0 -> 401 bytes .../html/key_notready_disabled.png | Bin 0 -> 401 bytes .../html/key_notready_hover.png | Bin 0 -> 413 bytes .../lobby_terminal/html/key_notready_idle.png | Bin 0 -> 420 bytes .../html/key_notready_pressed.png | Bin 0 -> 418 bytes .../html/key_observe_disabled.png | Bin 0 -> 440 bytes .../lobby_terminal/html/key_observe_hover.png | Bin 0 -> 459 bytes .../lobby_terminal/html/key_observe_idle.png | Bin 0 -> 465 bytes .../html/key_observe_pressed.png | Bin 0 -> 462 bytes .../html/key_ready_disabled.png | Bin 0 -> 401 bytes .../lobby_terminal/html/key_ready_hover.png | Bin 0 -> 413 bytes .../lobby_terminal/html/key_ready_idle.png | Bin 0 -> 420 bytes .../lobby_terminal/html/key_ready_pressed.png | Bin 0 -> 418 bytes .../lobby_terminal/html/lamp_notready.png | Bin 0 -> 177 bytes .../modules/lobby_terminal/html/lamp_off.png | Bin 0 -> 143 bytes .../lobby_terminal/html/lamp_ready.png | Bin 0 -> 179 bytes .../lobby_terminal/html/lobby_terminal.css | 256 ++++++++++++++++++ .../modules/lobby_terminal/html/strip_top.png | Bin 0 -> 157 bytes voidcrew/modules/lobby_terminal/html/tray.png | Bin 0 -> 194 bytes 30 files changed, 402 insertions(+) create mode 100644 voidcrew/modules/lobby_terminal/html/bezel.json create mode 100644 voidcrew/modules/lobby_terminal/html/deck.png create mode 100644 voidcrew/modules/lobby_terminal/html/frame_9slice.png create mode 100644 voidcrew/modules/lobby_terminal/html/grille.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/lamp_notready.png create mode 100644 voidcrew/modules/lobby_terminal/html/lamp_off.png create mode 100644 voidcrew/modules/lobby_terminal/html/lamp_ready.png create mode 100644 voidcrew/modules/lobby_terminal/html/lobby_terminal.css create mode 100644 voidcrew/modules/lobby_terminal/html/strip_top.png create mode 100644 voidcrew/modules/lobby_terminal/html/tray.png diff --git a/voidcrew/modules/lobby_terminal/html/bezel.json b/voidcrew/modules/lobby_terminal/html/bezel.json new file mode 100644 index 00000000000..90ab078da92 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/html/bezel.json @@ -0,0 +1,146 @@ +{ + "_note": "All numbers are 1x pixels. Multiply by the integer scale s. Built by ~/code/tg-tools/lobby-terminal-art/make_pieces.py.", + "scale": { + "rule": "s = clamp(min(floor(paneH / 360), floor(paneW / min_width)), 1, 4)", + "min_width": 472, + "base_height": 360 + }, + "frame": { + "file": "frame_9slice.png", + "size": [ + 132, + 28 + ], + "slice": { + "top": 18, + "right": 64, + "bottom": 6, + "left": 64 + }, + "note": "Covers the pane from the top down to the deck. Centre is transparent; draw it over the screen." + }, + "screen": { + "inset": { + "top": 15, + "left": 13, + "right": 13, + "bottom": 52 + }, + "note": "Glass rect inside the whole bezel. The frame paints rounded corner pixels on top of it." + }, + "strip_top": { + "file": "strip_top.png", + "size": [ + 61, + 8 + ], + "top": 3, + "center_x": true + }, + "deck": { + "file": "deck.png", + "height": 49, + "slice": { + "left": 26, + "right": 26 + }, + "note": "Full width, bottom of the pane. Hazard posts and bottom screws live in the end caps." + }, + "keys": { + "left": 30, + "top": 6, + "gap": 4, + "sizes": { + "join": [ + 68, + 34 + ], + "ready": [ + 68, + 34 + ], + "notready": [ + 68, + 34 + ], + "observe": [ + 84, + 34 + ], + "character": [ + 100, + 34 + ] + }, + "files": "key__.png", + "order": [ + "join or ready/notready", + "observe", + "character" + ], + "note": "Images include the 2 px dark well. ready = player readied (green legend), notready = red legend." + }, + "lamp": { + "files": "lamp_.png", + "size": [ + 10, + 9 + ], + "after_key": "ready/notready", + "margin_left": 2, + "margin_right": 1, + "top": 17, + "note": "Sits between the READY key and OBSERVE (pre-round only). Cropped from collapse_expand.dmi ready_blip_*." + }, + "grille": { + "file": "grille.png", + "tile": [ + 4, + 14 + ], + "max_width": 40, + "top": 14, + "min_gap": 12, + "note": "Centred between the key bank and the tray; hidden when there is no room." + }, + "tray": { + "file": "tray.png", + "size": [ + 142, + 31 + ], + "right": 29, + "top": 7, + "buttons": { + "first": [ + 3, + 3 + ], + "step": 28, + "size": [ + 24, + 24 + ], + "order": [ + "wiki", + "poll", + "crew_manifest", + "settings", + "changelog" + ], + "source": "icons/hud/lobby/bottom_buttons.dmi (+ _highlighted/_pressed/_disabled)" + }, + "new_poll": { + "source": "icons/hud/lobby/poll_overlay.dmi new_poll", + "size": [ + 24, + 26 + ], + "over": "poll", + "offset": [ + 0, + -1 + ] + } + } +} diff --git a/voidcrew/modules/lobby_terminal/html/deck.png b/voidcrew/modules/lobby_terminal/html/deck.png new file mode 100644 index 0000000000000000000000000000000000000000..883f896bed5ecb7f89da7333190ec6e5d997d8db GIT binary patch literal 450 zcmeAS@N?(olHy`uVBq!ia0vp^7C>ys!3HGzJF~7aFfev{x;TbZ%y~O&qZhNIfP?zs zvR~XuKACU%Be?3CdgUi5xkw*$JLtCN9rL9htV%H}Cuj+r;AipwD841^+X=bwiqr4T z1thKPN*33cJ0ofe1C#L943?}wCJvt~ ziv(A=aw-@EcDpSQQ*AilqAS%%SV73AnhiBIS;yRux2trw)jeD`YX{GXizU7?eEzRL zCX_FKXZe!rLMY4E93>OCjJ{*vA1s<-;<)78nWc|K=E>|8+xow2c7}CZopN)7Onr>w z?ki;{@4BqKI!VRk_`KJl#pchCEV{e;neo&3B{7oU>PxJ8jcRNYUH7t^&vo(I$GAEv W>E7RS@&drPVeoYIb6Mw<&;$U#Fu#NV literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/frame_9slice.png b/voidcrew/modules/lobby_terminal/html/frame_9slice.png new file mode 100644 index 0000000000000000000000000000000000000000..a4827c1781390d4fc93c02d92029be0379d65f9c GIT binary patch literal 429 zcmV;e0aE^nP)tErcEcW{|7B8P3V;Q$$NQ;7X*jD24Kmjx5@NA{tP>++q*O`PtX5>AP=Qqi|nzzde9r|CKp89zQb~^3Fc58ocG2u8qIb#1X=)>*l z${}Moyfg2gUz@8EDp%Cg+(1x@;zZHq2-F{^ryea#(K_VNFJBeZM(IFgMbWFbo>E{Pq>nR*CY1<-KGL382LfTxOVS&wKp+hI+4czQ z5RigEKnel@@<8B8ZT)O}WPOG}DD;x_Ml@s~6#7VeB3`LPDBR-P+$aHe`DJdQf^ps- XDdy;}Tg!Dr00000NkvXXu0mjff@{4d literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/grille.png b/voidcrew/modules/lobby_terminal/html/grille.png new file mode 100644 index 0000000000000000000000000000000000000000..18e74dd6ae59bfc40e543ca7478ee24a72165922 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^EI`c1!3HEbvi3>FS$pHZue(@jT2{^!_ v#B8xrfo1J%^1)AX6;%4kN>QMy`*pC+f}u^)Pt4`njxgN@xNA(To?( literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_character_disabled.png b/voidcrew/modules/lobby_terminal/html/key_character_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..6b0cce1d7944dabbab548fefb3c21b81557e9c10 GIT binary patch literal 469 zcmV;`0V@89P)gM9#_dAW;OVi1>0%91@OETP#od-}#k-5E#%_-Ox@it_yi%c=O@=LfK!lyJkPhrk|gPTyeU{i z+EO6}hbPD7v3_`5i>-cEZQJx;r5ZQ&cm4h9cUjpD1M1Hn+QlTov+p_8ssJmm@MVqi0pb&Rhw&yZ|KZe3+Tp{#$1o6m% z(9=u6i<2O(kCK5qSVM#XL4*N8gaJW>(W9-Tv~#gP*o&P@r}_H)_`h@U;Aj*#qmGj; z_bP0)tk;(eZN~91TGl8XTXg_@6+)JUMO_%>8iM^+f&`&b0ZIfv&LcPGq;W+z00000 LNkvXXu0mjflyKIt literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_character_hover.png b/voidcrew/modules/lobby_terminal/html/key_character_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..bc23c91a29bdbee5992daa7d6645a38831e9cdde GIT binary patch literal 487 zcmVrcnV$tu|&+Uu^=%sFe9gM7<_s5MqNRsVu1;h~1OT4xkIuD?f%3k091R)NN4lyYK8DC!g zzUP7v`+K{g@>y!GZ}0NtDh zey4)iOt|a#v?lJ_*X_EzP3Pls{_K|J;|k$KdVXFk%fzCxZyQeymc)UJC5jZp#gld1 z)>1gcv9T;4R>fjjTvu!Qf6{%=&$&X%iEw&!+Tu#|k)Q2K%2%a0TUK2Hbt|SW`h?8{ z`B-7u9?Jy&7z(3xh0x;>!~+Y`=IJG91}sY$5JVUdL>Lf67*W-B6cC01Z6&3hi^FIo zPzr$f`uv#PxwyVD(oGNmy;q^~9`f=$L7#CxjFvS@wyXvKz6v4B!YWG``8@>ttpo`| dr2>=)egLCD5q9-;Tp<7e002ovPDHLkV1lK(-^~C3 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_character_idle.png b/voidcrew/modules/lobby_terminal/html/key_character_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..3b3f478f115f79b5f494ddcce6d567e361dc2bcc GIT binary patch literal 508 zcmVrcn2gV9)pQ)JOfXF#85VnkUUmA0TU9hQI#U75T4jr5TI*u zl%{QB$GQ5H{@>8ry|}&Px6eJ>tt2`8jerfZSf+ zOy5&Mh?C=^Q2uN*506i2c}@^w^zyoHe?W$IwgSQfL4>govWn>U@Ay1vAXk?c&QpMs zAjD)mbZr7S1uyC0wXXAfC*^bk&@`&UcIDr4EOuWDB%A&CQ@kSJ0R zpO|dywjqT>9Gj3;N2p>6SzNtU{oi!c{qr6nc}F;7w5re(>+sL^pHy8VrA0f2v_VDt zA+-EnlAqEtEH3S^LV-+LK{>_424lSWSL$7dq6>G^%C&nBnWmB z`N13_3rcn73TJVPD;HlBeeKw>BxNUS^qQzc#lRUoJknb=qmphGxH z(>AtaUwlgbZ>a2Eot%BS-)`(dnjZcrAchfrBs<%&{Qyd-{QW}>F%q&sKqN{QadLJV z*OQ)eb9KEO5aM`p6w9Apb3c11%1Z$u=Fcx}`vXc~4iN?dB8(Nus$)96<@2P2Twa_z zPXSH=Ar?=Mu1x@EKs0%tZ?q*z(tCSTFo$#{Lo|2uSy9gRCo8t{ch$O0`!ZF#slDs< ztG{uzue*P%0kIiy-}YIJ-M6mGb$*-rad1VcFix1pXKbqj84N>k-%^2ZY{U0$!W~g55-ZFo#Iakn(0bh{J%6lG4e= z;b<$83xN3k`usb&SY*iV-dGnw1oTeq#yRBeb%8qLco-#XoE}&;0DKvO!`1>R<`A4F g5fl(g6`(}$3kV7qGDs86m;e9(07*qoM6N<$f<@lbZU6uP literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_join_disabled.png b/voidcrew/modules/lobby_terminal/html/key_join_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..5138356a4f91eae6f42ec84d32fecd0cfda3d9cf GIT binary patch literal 382 zcmV-^0fGLBP)uub)=j9nlREf*>IX5`yrH z>Nkvr6VQonrw!xjEIuKE&1wm0LRyH2>$(sIV@!GcS5OU6l_5cjwW|M}&lexZK4}}g zhp)BG5Pt7u*nqqvIQXcx{~~cyruagl(L#7gW*I9)wG=B!=^R8=jj>fdIsT%<_{qLo zWmK#nu`IhhVh}QM98)1Iku@X{8Nwo7pH;`T5Yp|mA#F$r8B6}u))A^92oi!IAqWzJ z$cU{>v2$nHSumN?@$kHJS16ajWW4`|L#NDky-D?T cqzVC7F7KtJ^Cno5y8r+H07*qoM6N<$g1!%>+W-In literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_join_hover.png b/voidcrew/modules/lobby_terminal/html/key_join_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..74c4164c05da7a7e1236689a55d9c475856aca0b GIT binary patch literal 396 zcmV;70dxL|P)7&?E@rjQ7zBXM~MhLJr_NH4v> zkjpYFums|W4v+{ODQm!FaQ9Cpp@SOLq}x*0000Mo+ zg!Ix247n^5hb0h4bbu_vk+Mc?fY3Tnn1)hvMh3JhV_0YvbBnMz7(Hh3)iSiGK?bDS zT|~eWl*psY(IUWxWEnCfQivFV<}!FaQAe-k$N}c>KZu0000=XqXq~FxH;G| zj6|^Oz+pHWB|t#n-owWXBN>dAA!uMUK+ph2mLUa9o;;snFoFJ_I*PMAMF9c=PaoW2 z7*2rV0D*y5uU;|W0%BrfG?%9q7%f9+l|qogihBp(>nHsKw%n6$r%~Ys*GWwRm??TaWHz!;;Ut7 zQG*OfwHrae6O_oK%h4jhhGZEsBvObNf#x%<QVj){enVI+fpf4+lf?%*jzQbw9#Bm>yUAI>u5`ID!^nolsxNGqr? vjPw*TI@yf{ungDHbBY24lvG9m0~i1R+UJ^^VnL2Z00000NkvXXu0mjfh&G+O literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_notready_disabled.png b/voidcrew/modules/lobby_terminal/html/key_notready_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..c3442d2fdb0b793b2454000a24c9b59c1f6d3fad GIT binary patch literal 401 zcmV;C0dD?@P)B=W{HZQZhy*&&=$xSfK7E4e}m5-~f9PPyDNUiUfccbP&tL@}?&dB^V}$e`Z? z+tCtatobQjMq~{kh#&+Jgdl4%P)DT++i5bfaCyyfmg3y zG2jAXVqya>Pc9fOL#UNPK;V8;4+An#tSiAQPe=_R{n+G@#nAb54-gO_` zXtLK==AselnQl?j@;V`AZC7|Gz@pYNzMcak#F3?mu9 z#)@&4Anxg<+(p5KxvGt!2;v%Sf4TAt^vWNo5o;fB^tSHo<7M{9^O~ O0000=XqXq~FxH;G| zj6|^Oz+pHWB|t#n-owWXBN>dAA!uMUK+ph2mca)mPoB>(lt6z^9mz!iNdW=^PaoW2 z7|wv?0D*y5uU;|W0%Brf11?W47%fAnl|n$^ep3$vGEl55!7EQl4I%y5-gLmg0@LI|Z4LNx^;Kq$)%ssK?WP@|BNUC7Z-s=8rc%M7X%B1WKjJkauV36TDt zI)>p6ND7d9BVaUbv<$(QLUthywvJT52%5?S&D=4ua59W!@bAxe@XQ@Ng-FUsGmK;a z8wtc&hCF}rbXfBVW*KP(6^4h8lGMY|OfPj+9C}02s04Eu_mcUejwg3PC M07*qoM6N<$g7Xlh%m4rY literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_observe_disabled.png b/voidcrew/modules/lobby_terminal/html/key_observe_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..1a97fedb99ad6ef1ad94ca9004f9590d3b5d500b GIT binary patch literal 440 zcmV;p0Z0CcP)_1B z@sT+AenbC#@%8e_cWx8MMX~&h2sBc7V2eY%hyf#B-+l}QRu|{fW;1hcuW!Delc^K= zZ;+wLkfF$sp~$q0k_q>Bk5NcmtuLd8r{}*j6sWfw+aW<=ZD@lKLa8yvX0H#0dZ;fm zv>`h?ZC~@UQfghg?ig9q>0RG1A8!+XbVGFs1G*E^oq&yQ63;fiEmZQpmAp$!;APdT z>&BbtF8O(BD0gTHvaX6hq|{C02U+r5n(1AiyO=)O40SEp%<)%1S1ziyq8eu!f3%YC zOMoM;;3j&X*+b$7Tk<|Jc!ui!p0SzhqZkTTUkF4)Z7*eo2=!28$WUa+P-MtZWMbHg z13O2O^Z+|{P<-s)fA1VQI?kufXy!Ec+LR^#va9KRk008S&x;eI0fc&nLdnyjgiPB* i>3%z+p)gg62!$^&oCXV0u?1fD*)L$W0! z36LD1F!1WtE1Ys-Vq!yHo?I|mh7Oh#3Ii+d9e}eVlpJu%VUs7M23?MjJU08$)stdA z-2;@6gF%3hhX{Fq6nSFIBh`FT!DyBrY+0U?A|XP_0k1qE{lv&q;=Vx{pb-=l3Fz{; z%qJ(S6Knp6%<>UvDIJ?QC4o-26iR89T|r?h0GnHI6)U*(5oSC?EsC=Gt(Uq0gT@9p&Q*4}w`#002ovPDHLkV1i5Y Bw`KqU literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_observe_idle.png b/voidcrew/modules/lobby_terminal/html/key_observe_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..fbb5c3b1da704dabf830c2f78bdc2e127afed383 GIT binary patch literal 465 zcmV;?0WSWDP)W8X{Z7 zcz5<^Slik8ip0tDPR36=W5?@WnjU{83>gD>5eNHp5hEslecKocOwY4sv*|du*Ec`! zrJ=yt=}A-hbej47L%F?WD6n{b37yYQ(Qi6|U4soJ=1I7# z9&CpCmh8myDxfYGRa;SwGmRgt+tVMB>wL;tgf{$37c+~~ohIPm01f3W8mf&o51-nX9|86J*`O~!#@txefO z*UJ@u*7$*z{3ty#8o;P$D3m;(<%ubKD6h8@8VXaDNl5qxd)n|o8ki5?00000NkvXX Hu0mjfh%VLO literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_observe_pressed.png b/voidcrew/modules/lobby_terminal/html/key_observe_pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..52b36c5ec25e5b53ae4478ae15d38ebbe2c38e22 GIT binary patch literal 462 zcmV;<0WtoGP)%d_+8zn$N;NHW>^mfm%0;6RpmBDC$QW;=md3<2<gZ%87}I4S9KT!DtyeSW+kqthjdo&W=!Wz$u4Ko{$=J zIYRQ->_=BmiurU8P(ls{0YV-kB=W{HZQZhy*&&=$xSfK7E4e}m5-~f9PPyDNUiUfccbP&tL@}?&dB^V}$e`Z? z+tCtatobQjMq~{kh#&+Jgdl4%P)DT++i5bfaCyyfmg3y zG2jAXVqya>Pc9fOL#UNPKw!na0}RL@LdgNIJRvoN^kb7p7DMOLJwQN!kc03!kQfh9 z+rz^;%S0$Sz*7;m0*m4lvI0+%Csm%3K%rL3p-PrnfyY6F9EzSL1{yF?JLwK}KyeBo zlu`)Q6odewEH|hEB!WPVLP~ZaM?b0RhJ7tFs8Wa+f#&f*%hM%5`pM`$4mB{EWzfKA zfS`fV06_zz0fMhkU4}FqKT-iBXetvlbH~KO$uN?^zdzqmXYM3rq!~ssfQ=R7EJL0@ zc{;551hb5^f(pY(Pa&WzGg`}_0hW<6-$GJ=fRf56U;qOEwr{Uhpve0A00000NkvXX Hu0mjf-j1Fp literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_ready_idle.png b/voidcrew/modules/lobby_terminal/html/key_ready_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..223cefc4e99660d596a6cb75dea906f99435c3bf GIT binary patch literal 420 zcmV;V0bBlwP)F9;SNX& zkb5IwG;OpDK?9=!f(Awd1Ya4l3u!ohqyk3JR3>QVj){enVI+fpf4-y6+)2tvGmK;a z8!N_HhCF}rbXfBVW*KP(6^4=XqXq~FxH;G| zj6|^Oz+pHWB|t#n-owWXBN>dAA!uMUK+ph2mca)mPoB>(lt6z^9mz!iNdW=^PaoW2 z7|wv?0D*y5uU;|W0%Brf11?W47%fAnl|n#Z#k~Uz$RI+=0k1qEHH7qIlSdXq=hHnv zK!A{g@Hvne4^i90!#c}EC^^7W5w!w~;uNw1Pm(89o{~VJR?4ADmRW(vL4+KNo+Snv zFi|_{4s}3r3L%tI2-OsX0HG{5r~)K{K#f95b|FVUsp^J(EiKKMQASpoZjeya#(J};I3fYA;*g8@HBWNlUG;_zq!pSg_!M{J>!83R86e1}j%`lPy zY$On88S?ze(_zggm}R6DR2W8j3K^a3#sXMI%4j-C0Rl=YqksVn0G*<>5on2FgnO~TJ@jTSNG$}WQ+P=7R=mpieE{DxwfT+B}lEiFgLi<@vXGF^TcM$8TZQ{ zs2tW^_nv3{f=@mbZ(W$KgmaWX){f=eP~BIvpHa}@d3r(uLn224OKY-Ikk-9|7=wH> aWrlSk3Xz9p&Nu?yz~JfX=d#Wzp$Pzqb3aA^ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/lamp_off.png b/voidcrew/modules/lobby_terminal/html/lamp_off.png new file mode 100644 index 0000000000000000000000000000000000000000..2737a103a5559915ef93f1f0b72167afdaf6e45c GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^AT}ol8<6B)wpSQPg?YL-hE&W+wkdePaCq(U@BfV- zFlKG)>gnO~TJ@jTSNG$}WQ+P=7R=mpieE{DxwfT+B}lCc@bTeUztX27HFK4~JGV?$ r(LG#}jt^%{n9^k~BYtM~Sq_Hqr6O}#Co5|JZD8bP0l+XkK6v{6~ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/lamp_ready.png b/voidcrew/modules/lobby_terminal/html/lamp_ready.png new file mode 100644 index 0000000000000000000000000000000000000000..437e1fac4b920fe98bd39a960b463aa158e5f9eb GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^AT}ol8<6B)wpSQPRe8ENhE&W+wkdePaCq(U@BfV- zFlKG)>gnO~TJ@jTSNG$}WQ+P=7R=mpieE{DxwfT+B}lCcI1}6ZaQ}9Z=576QGwMrE ztFj$idtO4~p7WELA8vKBtXMDb&h6!QB_1B{I>VZnBDOQr87C_hwDv40@bU0ED&5EZ c>}(nf!?lG9yM;Snf}F+R>FVdQ&MBb@0Ir)vBme*a literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/lobby_terminal.css b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css new file mode 100644 index 00000000000..bde1355098d --- /dev/null +++ b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css @@ -0,0 +1,256 @@ +/* + * Dock terminal lobby: bezel pieces. + * Every size is a 1x pixel count times --s, the integer pixel-art scale set on .lt-bezel + * (see bezel.json "scale"). Image URLs are relative: this file is served in the same asset + * namespace as the PNGs next to it. + * + * Structure: + * .lt-bezel (--s; fills the pane) + * .lt-screen glass rect; the terminal draws inside it + * .lt-frame 9-slice frame, drawn over the screen + * .lt-strip service lamps, top centre + * .lt-deck + * .lt-deck-row + * .lt-keys > .lt-key.lt-key--join (or --ready/--notready + .lt-lamp), --observe, --character + * .lt-grille-slot > .lt-grille + * .lt-tray > .lt-sbtn x5 (spritesheet icon inside; .lt-sbtn-mark for the new-poll lamp) + */ + +.lt-bezel { + --s: 1; + position: absolute; + inset: 0; + overflow: hidden; + background: #9aa39f; + image-rendering: pixelated; + user-select: none; +} + +.lt-bezel * { + image-rendering: pixelated; +} + +/* Glass rect. bottom = 3 (recess) + 49 (deck). */ +.lt-screen { + position: absolute; + top: calc(var(--s) * 15px); + left: calc(var(--s) * 13px); + right: calc(var(--s) * 13px); + bottom: calc(var(--s) * 52px); + overflow: hidden; + z-index: 1; +} + +.lt-frame { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: calc(var(--s) * 49px); + box-sizing: border-box; + border-style: solid; + border-width: calc(var(--s) * 18px) calc(var(--s) * 64px) calc(var(--s) * 6px); + border-image-source: url('frame_9slice.png'); + border-image-slice: 18 64 6 64; + border-image-repeat: stretch; + pointer-events: none; + z-index: 2; +} + +.lt-strip { + position: absolute; + /* relative to the frame's padding box, which starts inside the 18px top border */ + top: calc(var(--s) * -15px); + left: calc(50% - var(--s) * 30px); + /* snap the centre to the pixel grid so an odd width doesn't split a pixel */ + left: calc(round(down, 50%, var(--s) * 1px) - var(--s) * 30px); + width: calc(var(--s) * 61px); + height: calc(var(--s) * 8px); + background: url('strip_top.png') 0 0 / 100% 100% no-repeat; +} + +.lt-deck { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: calc(var(--s) * 49px); + z-index: 2; +} + +.lt-deck::before { + content: ''; + position: absolute; + inset: 0; + box-sizing: border-box; + border-style: solid; + border-width: 0 calc(var(--s) * 26px); + border-image-source: url('deck.png'); + border-image-slice: 0 26 fill; + border-image-repeat: stretch; + pointer-events: none; +} + +.lt-deck-row { + position: absolute; + top: calc(var(--s) * 6px); + left: calc(var(--s) * 30px); + right: calc(var(--s) * 29px); + display: flex; + align-items: flex-start; +} + +/* Keycaps */ +.lt-keys { + display: flex; + align-items: flex-start; + flex: none; +} + +.lt-key { + display: block; + flex: none; + margin: 0; + padding: 0; + border: 0; + outline: 0; + height: calc(var(--s) * 34px); + background: transparent 0 0 / 100% 100% no-repeat; + cursor: pointer; +} + +.lt-key + .lt-key { + margin-left: calc(var(--s) * 4px); +} + +.lt-key--join, +.lt-key--ready, +.lt-key--notready { + width: calc(var(--s) * 68px); +} + +.lt-key--observe { + width: calc(var(--s) * 84px); +} + +.lt-key--character { + width: calc(var(--s) * 100px); +} + +.lt-key--join { background-image: url('key_join_idle.png'); } +.lt-key--join:hover { background-image: url('key_join_hover.png'); } +.lt-key--join:active, +.lt-key--join.is-pressed { background-image: url('key_join_pressed.png'); } +.lt-key--join.is-disabled, +.lt-key--join:disabled { background-image: url('key_join_disabled.png'); } + +.lt-key--ready { background-image: url('key_ready_idle.png'); } +.lt-key--ready:hover { background-image: url('key_ready_hover.png'); } +.lt-key--ready:active, +.lt-key--ready.is-pressed { background-image: url('key_ready_pressed.png'); } +.lt-key--ready.is-disabled, +.lt-key--ready:disabled { background-image: url('key_ready_disabled.png'); } + +.lt-key--notready { background-image: url('key_notready_idle.png'); } +.lt-key--notready:hover { background-image: url('key_notready_hover.png'); } +.lt-key--notready:active, +.lt-key--notready.is-pressed { background-image: url('key_notready_pressed.png'); } +.lt-key--notready.is-disabled, +.lt-key--notready:disabled { background-image: url('key_notready_disabled.png'); } + +.lt-key--observe { background-image: url('key_observe_idle.png'); } +.lt-key--observe:hover { background-image: url('key_observe_hover.png'); } +.lt-key--observe:active, +.lt-key--observe.is-pressed { background-image: url('key_observe_pressed.png'); } +.lt-key--observe.is-disabled, +.lt-key--observe:disabled { background-image: url('key_observe_disabled.png'); } + +.lt-key--character { background-image: url('key_character_idle.png'); } +.lt-key--character:hover { background-image: url('key_character_hover.png'); } +.lt-key--character:active, +.lt-key--character.is-pressed { background-image: url('key_character_pressed.png'); } +.lt-key--character.is-disabled, +.lt-key--character:disabled { background-image: url('key_character_disabled.png'); } + +.lt-key.is-disabled, +.lt-key:disabled { + cursor: default; +} + +/* Ready lamp, between the READY key and OBSERVE. Overrides the key gap that follows it. */ +.lt-lamp { + flex: none; + width: calc(var(--s) * 10px); + height: calc(var(--s) * 9px); + margin: calc(var(--s) * 11px) calc(var(--s) * 1px) 0 calc(var(--s) * 2px); + background: transparent 0 0 / 100% 100% no-repeat; +} + +.lt-lamp + .lt-key { + margin-left: 0; +} + +.lt-lamp--ready { background-image: url('lamp_ready.png'); } +.lt-lamp--notready { background-image: url('lamp_notready.png'); } +.lt-lamp--off { background-image: url('lamp_off.png'); } + +/* Speaker grille: fills up to 40px of the gap between keys and tray, whole dots only. */ +.lt-grille-slot { + flex: 1 1 0; + min-width: 0; + display: flex; + justify-content: center; + padding: calc(var(--s) * 8px) calc(var(--s) * 12px) 0; + overflow: hidden; +} + +.lt-grille { + width: 100%; + max-width: calc(var(--s) * 40px); + height: calc(var(--s) * 14px); + background: url('grille.png') 0 0 / calc(var(--s) * 4px) calc(var(--s) * 14px) space no-repeat; +} + +/* Small lobby buttons. The icon inside each slot comes from the bottom_buttons spritesheet + * at 1x; .lt-sbtn-icon scales it by --s. */ +.lt-tray { + flex: none; + position: relative; + display: flex; + margin-top: calc(var(--s) * 1px); + padding: calc(var(--s) * 3px); + width: calc(var(--s) * 142px); + height: calc(var(--s) * 31px); + box-sizing: border-box; + background: url('tray.png') 0 0 / 100% 100% no-repeat; +} + +.lt-sbtn { + position: relative; + flex: none; + margin: 0; + padding: 0; + border: 0; + background: none; + width: calc(var(--s) * 24px); + height: calc(var(--s) * 24px); + cursor: pointer; +} + +.lt-sbtn + .lt-sbtn { + margin-left: calc(var(--s) * 4px); +} + +.lt-sbtn-icon, +.lt-sbtn-mark { + position: absolute; + left: 0; + top: 0; + transform: scale(var(--s)); + transform-origin: 0 0; + pointer-events: none; +} + +.lt-sbtn-mark { + top: calc(var(--s) * -1px); +} diff --git a/voidcrew/modules/lobby_terminal/html/strip_top.png b/voidcrew/modules/lobby_terminal/html/strip_top.png new file mode 100644 index 0000000000000000000000000000000000000000..17366f8bee19954341d4069fa82ef982259c6f22 GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^wm{6m!3HF~u73Xxq*6Rx978JRB=hj}|2}Wf! z3`!odyZ!uVjSe(Z*qe>bhWU4CH6Z_9b>kH4|jd-M6;@weB#Kg@4& zR+15VIDw Date: Tue, 10 Nov 2026 12:01:24 -0800 Subject: [PATCH 51/72] Add dock terminal lobby shell and section seams A BROWSER control over the map pane renders the LobbyTerminal tgui interface for new players. The terminal datum declares the section procs for crews, shipyard and housing, shared code-lockout and join-lock helpers, and the data contract in types.ts. term.tsx holds the text-grid primitives the tabs draw with. The classic HUD lobby stays underneath as the fallback. --- interface/skin.dmf | 9 + tgstation.dme | 7 + .../interfaces/LobbyTerminal/Bezel.tsx | 237 +++++++++++++ .../interfaces/LobbyTerminal/CrewsTab.tsx | 28 ++ .../interfaces/LobbyTerminal/Header.tsx | 108 ++++++ .../interfaces/LobbyTerminal/HousingTab.tsx | 28 ++ .../interfaces/LobbyTerminal/Notice.tsx | 57 +++ .../interfaces/LobbyTerminal/YardTab.tsx | 25 ++ .../interfaces/LobbyTerminal/index.tsx | 213 ++++++++++++ .../interfaces/LobbyTerminal/layout.ts | 90 +++++ .../interfaces/LobbyTerminal/term.tsx | 316 +++++++++++++++++ .../interfaces/LobbyTerminal/types.ts | 269 +++++++++++++++ .../modules/lobby_terminal/_lobby_terminal.dm | 324 ++++++++++++++++++ .../modules/lobby_terminal/lobby_assets.dm | 35 ++ .../modules/lobby_terminal/lobby_bezel.dm | 178 ++++++++++ .../modules/lobby_terminal/lobby_crews.dm | 2 + .../modules/lobby_terminal/lobby_housing.dm | 2 + voidcrew/modules/lobby_terminal/lobby_tgui.dm | 119 +++++++ voidcrew/modules/lobby_terminal/lobby_yard.dm | 2 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/lobby_terminal_bill.dm | 1 + 21 files changed, 2051 insertions(+) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts create mode 100644 voidcrew/modules/lobby_terminal/_lobby_terminal.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_assets.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_bezel.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_crews.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_housing.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_tgui.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_yard.dm create mode 100644 voidcrew/modules/unit_tests/lobby_terminal_bill.dm diff --git a/interface/skin.dmf b/interface/skin.dmf index 3b46689c881..c6c56fc9805 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -137,6 +137,15 @@ window "mapwindow" saved-params = "" text = "" align = left + elem "lobby_browser" + type = BROWSER + pos = 0,0 + size = 640x480 + anchor1 = 0,0 + anchor2 = 100,100 + is-visible = false + background-color = #0b0b0e + saved-params = "" window "info_and_buttons" elem "info_and_buttons" diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..f64dbcd5cad 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7242,6 +7242,13 @@ #include "voidcrew\modules\lich\lich_spells.dm" #include "voidcrew\modules\lich\lich_thrall.dm" #include "voidcrew\modules\lich\lich_wards.dm" +#include "voidcrew\modules\lobby_terminal\_lobby_terminal.dm" +#include "voidcrew\modules\lobby_terminal\lobby_assets.dm" +#include "voidcrew\modules\lobby_terminal\lobby_bezel.dm" +#include "voidcrew\modules\lobby_terminal\lobby_crews.dm" +#include "voidcrew\modules\lobby_terminal\lobby_housing.dm" +#include "voidcrew\modules\lobby_terminal\lobby_tgui.dm" +#include "voidcrew\modules\lobby_terminal\lobby_yard.dm" #include "voidcrew\modules\loot\abandoned_crate.dm" #include "voidcrew\modules\loot\loot_guns.dm" #include "voidcrew\modules\loot\zone_loot.dm" diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx new file mode 100644 index 00000000000..76795c08df6 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx @@ -0,0 +1,237 @@ +/** + * The terminal's bezel: frame, top strip, deck with the big keys, grille and the tray of + * small lobby buttons. Class tree from the art's lobby_terminal.css: + * + * .lt-bezel (--s) > .lt-screen, .lt-frame > .lt-strip, .lt-deck > .lt-deck-row > + * .lt-keys > .lt-key..., .lt-grille-slot > .lt-grille, .lt-tray > .lt-sbtn... + * + * Without the art (static.bezel null) the same tree is drawn by PLACEHOLDER_CSS. + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import type { LobbyData } from './types'; + +type KeyId = 'join' | 'ready' | 'notready' | 'observe' | 'character' | 'start'; + +const KEY_LABEL: Record = { + join: 'JOIN', + ready: 'READY', + notready: 'READY', + observe: 'OBSERVE', + character: 'CHARACTER', + start: 'START', +}; + +function Key(props: { + id: KeyId; + disabled?: boolean; + title: string; + onClick: () => void; +}) { + const { id, disabled, title, onClick } = props; + return ( + + ); +} + +type SmallId = 'wiki' | 'poll' | 'crew_manifest' | 'settings' | 'changelog'; + +function SmallButton(props: { + id: SmallId; + title: string; + disabled?: boolean; + mark?: boolean; + onClick: () => void; +}) { + const { id, title, disabled, mark, onClick } = props; + const [hover, setHover] = useState(false); + const state = disabled ? '_disabled' : hover ? '_highlighted' : ''; + return ( + + ); +} + +export function Bezel(props: { s: number; children: ReactNode }) { + const { s, children } = props; + const { data, act } = useBackend(); + const art = !!data.bezel; + const preRound = data.phase === 'loading' || data.phase === 'pregame'; + const ready = !!data.ready; + const showReady = preRound || data.phase === 'setup'; + + return ( +
+ {!art && } +
{children}
+
+
+
+
+
+
+ {showReady ? ( + <> + act('ready')} + /> + + + ) : ( + act('join')} + /> + )} + act('observe')} + /> + act('character')} + /> + {data.localhost_admin && preRound ? ( + act('start_now')} + /> + ) : null} +
+
+
+
+
+ act('wiki')} + /> + act('polls')} + /> + act('manifest')} + /> + act('settings')} + /> + act('changelog')} + /> +
+
+
+
+ ); +} + +/** + * Keycap legends are baked into the art; only the placeholder (and the START key, which has + * no art) shows the text legend. + */ +const LEGEND_CSS = ` +.lt-key-legend { display: none; } +.lt-key--start { width: calc(var(--s) * 52px); height: calc(var(--s) * 34px); position: relative; + background: #9aa39f; box-shadow: inset 0 0 0 calc(var(--s) * 2px) #20202a; } +.lt-key--start .lt-key-legend, .lt-ph .lt-key-legend { display: block; position: absolute; + left: calc(var(--s) * 6px); right: calc(var(--s) * 6px); top: calc(var(--s) * 9px); height: calc(var(--s) * 13px); + background: #36373a; color: #7effbf; font-family: 'VCR', monospace; font-size: calc(var(--s) * 9px); + line-height: calc(var(--s) * 13px); text-align: center; letter-spacing: calc(var(--s) * 1px); } +.lt-key--start:hover .lt-key-legend { color: #ffc41f; } +.lt-key-legend { pointer-events: none; } +`; + +export const BEZEL_EXTRA_CSS = LEGEND_CSS; + +/** Stand-in for the art: the same layout in flat lobby colours. */ +const PLACEHOLDER_CSS = ` +.lt-ph.lt-bezel { --s: 1; position: absolute; inset: 0; overflow: hidden; background: #9aa39f; user-select: none; } +.lt-ph .lt-screen { position: absolute; top: calc(var(--s) * 15px); left: calc(var(--s) * 13px); + right: calc(var(--s) * 13px); bottom: calc(var(--s) * 52px); overflow: hidden; z-index: 1; } +.lt-ph .lt-frame { position: absolute; top: calc(var(--s) * 12px); left: calc(var(--s) * 10px); + right: calc(var(--s) * 10px); bottom: calc(var(--s) * 49px); box-sizing: border-box; + border: calc(var(--s) * 3px) solid #51575e; border-bottom-color: #e0e3dc; border-right-color: #e0e3dc; + pointer-events: none; z-index: 2; } +.lt-ph .lt-strip { position: absolute; top: calc(var(--s) * -10px); left: calc(50% - var(--s) * 30px); + width: calc(var(--s) * 61px); height: calc(var(--s) * 6px); background: #36373a; } +.lt-ph .lt-deck { position: absolute; left: 0; right: 0; bottom: 0; height: calc(var(--s) * 49px); z-index: 2; + border-top: calc(var(--s) * 2px) solid #e0e3dc; box-sizing: border-box; } +.lt-ph .lt-deck-row { position: absolute; top: calc(var(--s) * 6px); left: calc(var(--s) * 30px); + right: calc(var(--s) * 29px); display: flex; align-items: flex-start; } +.lt-ph .lt-keys { display: flex; align-items: flex-start; flex: none; } +.lt-ph .lt-key { display: block; flex: none; margin: 0; padding: 0; border: 0; outline: 0; position: relative; + height: calc(var(--s) * 34px); background: #bec4bf; box-shadow: inset 0 0 0 calc(var(--s) * 2px) #20202a; cursor: pointer; } +.lt-ph .lt-key + .lt-key { margin-left: calc(var(--s) * 4px); } +.lt-ph .lt-key--join, .lt-ph .lt-key--ready, .lt-ph .lt-key--notready { width: calc(var(--s) * 68px); } +.lt-ph .lt-key--observe { width: calc(var(--s) * 84px); } +.lt-ph .lt-key--character { width: calc(var(--s) * 100px); } +.lt-ph .lt-key:hover { background: #e0e3dc; } +.lt-ph .lt-key:active { background: #9aa39f; } +.lt-ph .lt-key.is-disabled { cursor: default; background: #9aa39f; } +.lt-ph .lt-key.is-disabled .lt-key-legend { color: #33676a; } +.lt-ph .lt-key--notready .lt-key-legend { color: #ff4f6a; } +.lt-ph .lt-lamp { flex: none; width: calc(var(--s) * 10px); height: calc(var(--s) * 9px); + margin: calc(var(--s) * 11px) calc(var(--s) * 1px) 0 calc(var(--s) * 2px); background: #20202a; } +.lt-ph .lt-lamp + .lt-key { margin-left: 0; } +.lt-ph .lt-lamp--ready { background: #7effbf; } +.lt-ph .lt-lamp--notready { background: #ff4f6a; } +.lt-ph .lt-grille-slot { flex: 1 1 0; min-width: 0; } +.lt-ph .lt-tray { flex: none; position: relative; display: flex; margin-top: calc(var(--s) * 1px); + padding: calc(var(--s) * 3px); width: calc(var(--s) * 142px); height: calc(var(--s) * 31px); + box-sizing: border-box; background: #36373a; } +.lt-ph .lt-sbtn { position: relative; flex: none; margin: 0; padding: 0; border: 0; background: none; + width: calc(var(--s) * 24px); height: calc(var(--s) * 24px); cursor: pointer; } +.lt-ph .lt-sbtn + .lt-sbtn { margin-left: calc(var(--s) * 4px); } +.lt-ph .lt-sbtn-icon, .lt-ph .lt-sbtn-mark { position: absolute; left: 0; top: 0; + transform: scale(var(--s)); transform-origin: 0 0; pointer-events: none; image-rendering: pixelated; } +.lt-ph .lt-sbtn-mark { top: calc(var(--s) * -1px); } +`; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx new file mode 100644 index 00000000000..81e6a2beacf --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx @@ -0,0 +1,28 @@ +/** + * CREWS tab. STUB from the shell package: package P2 owns and replaces this file. + * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2 + * (rows - 1 is the shell's notice line). data.crews: see types.ts. + */ +import { useBackend } from 'tgui/backend'; + +import { Frame, Put, TOP, useGrid } from './term'; +import type { LobbyData } from './types'; + +export function CrewsTab() { + const { data } = useBackend(); + const { cols, rows } = useGrid(); + const crews = data.crews || []; + return ( + + + NO CREWS ARE SIGNING ON. + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx new file mode 100644 index 00000000000..1eca673b51b --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { Put, pad, RPut, Rule, useGrid } from './term'; +import type { LobbyData, TabId } from './types'; + +const TABS: [TabId, string][] = [ + ['crews', 'CREWS'], + ['shipyard', 'SHIPYARD'], + ['housing', 'HOUSING'], +]; + +const two = (n: number) => String(Math.floor(n)).padStart(2, '0'); + +function hms(seconds: number) { + const t = Math.max(0, Math.floor(seconds)); + return `${two(t / 3600)}:${two((t % 3600) / 60)}:${two(t % 60)}`; +} + +function ms(seconds: number) { + const t = Math.max(0, Math.floor(seconds)); + return `${two(t / 60)}:${two(t % 60)}`; +} + +/** Seconds since the last server value arrived, ticking once a second. */ +function useElapsed(stamp: number) { + const [since, setSince] = useState(() => Date.now()); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + setSince(Date.now()); + setNow(Date.now()); + }, [stamp]); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return Math.max(0, (now - since) / 1000); +} + +function clockText(data: LobbyData, elapsed: number): string { + const { phase, clock } = data; + switch (phase) { + case 'playing': + return hms(clock / 10 + elapsed); + case 'loading': + case 'pregame': + if (clock < 0) { + return 'DEPARTURE DELAYED'; + } + return clock > 0 ? `DEPARTURE ${ms(clock - elapsed)}` : 'DEPARTURE SOON'; + case 'setup': + return 'DEPARTING'; + default: + return 'PORT CLOSED'; + } +} + +/** Title bar (row 0), tab bar (row 2) and its rule (row 3). */ +export function Header(props: { tab: TabId; setTab: (tab: TabId) => void }) { + const { tab, setTab } = props; + const { data } = useBackend(); + const { cols, narrow } = useGrid(); + const elapsed = useElapsed(data.clock); + + const title = data.port + ? ` ${data.port.toUpperCase()} : BERTH OFFICE` + : ' BERTH OFFICE'; + let right = clockText(data, elapsed); + if (data.phase !== 'playing' || !narrow) { + right = `${data.online} ONLINE ${right}`; + } + if (data.admin_ready) { + const [ready, admins, adminTotal] = data.admin_ready; + right = `${ready} READY ${admins}/${adminTotal} ADM ${right}`; + } + + let x = 1; + const tabs = TABS.map(([id, label]) => { + const text = ` ${label} `; + const at = x; + x += text.length + 3; + return ( + setTab(id)} + > + {text} + + ); + }); + + return ( + <> + + {pad(title, cols)} + + + {right} + + {tabs} + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx new file mode 100644 index 00000000000..2c393ebbf79 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -0,0 +1,28 @@ +/** + * HOUSING tab. STUB from the shell package: package P5 owns and replaces this file. + * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. + * data.outposts: see types.ts. + */ +import { useBackend } from 'tgui/backend'; + +import { Frame, Put, TOP, useGrid } from './term'; +import type { LobbyData } from './types'; + +export function HousingTab() { + const { data } = useBackend(); + const { cols, rows } = useGrid(); + const outposts = data.outposts || []; + return ( + + + NO ROOMS TO LET. + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx new file mode 100644 index 00000000000..90cd0935028 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx @@ -0,0 +1,57 @@ +import { useBackend } from 'tgui/backend'; + +import { Btn, Frame, Put, pad, TOP, useGrid, wrap } from './term'; +import type { LobbyData } from './types'; + +/** The CONFIRMED box (kind "ok"), drawn instead of the tab content. */ +export function Confirmed() { + const { data, act } = useBackend(); + const { cols } = useGrid(); + const notice = data.notice; + if (!notice) { + return null; + } + const w = Math.min(44, cols - 2); + const x = Math.floor(cols / 2) - Math.floor(w / 2); + const lines = wrap(notice.text.toUpperCase(), w - 6).slice(0, 3); + const h = 7 + lines.length; + return ( + + {lines.map((line, i) => ( + + {line} + + ))} + + STAND BY. + + act('dismiss')} + /> + + ); +} + +/** One amber or red line on the bottom row. Click to clear it. */ +export function NoticeLine() { + const { data, act } = useBackend(); + const { cols, rows } = useGrid(); + const notice = data.notice; + if (!notice || notice.kind === 'ok') { + return null; + } + return ( + act('dismiss')} + > + {pad(notice.text.toUpperCase(), cols - 2)} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx new file mode 100644 index 00000000000..b4021a975be --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -0,0 +1,25 @@ +/** + * SHIPYARD tab. STUB from the shell package: package P4 owns and replaces this file. + * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. + * The catalog arrives in static data only after `yard_open`. data/static keys: see types.ts. + */ +import { useEffect } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { Frame, Put, TOP, useGrid } from './term'; +import type { LobbyData } from './types'; + +export function YardTab() { + const { act } = useBackend(); + const { cols, rows } = useGrid(); + useEffect(() => { + act('yard_open'); + }, []); + return ( + + + THE YARD IS CLOSED. + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx new file mode 100644 index 00000000000..0bfbbfe45b3 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx @@ -0,0 +1,213 @@ +/** + * Dock terminal lobby. Rendered into the `lobby_browser` control over the map pane, not + * a popup window. + * + * Never wrap this in : Window winsets size and position on Byond.windowId, which + * here is the skin control, and would wreck the map pane. The root is a bare full-viewport + * div that follows the control's size. + * + * UI-local state (tab, selections, pages, fits) lives in React; only actions round-trip. + */ +import { useEffect, useMemo, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; +import { useBackend } from 'tgui/backend'; + +import { BEZEL_EXTRA_CSS, Bezel } from './Bezel'; +import { CrewsTab } from './CrewsTab'; +import { Header } from './Header'; +import { HousingTab } from './HousingTab'; +import { computeLayout } from './layout'; +import { Confirmed, NoticeLine } from './Notice'; +import { type Grid, GridContext, TERM_CSS } from './term'; +import type { LobbyData, TabId } from './types'; +import { YardTab } from './YardTab'; + +const FONT = 'VCR'; +/** Advance of one character at 1px, for when the font hasn't loaded yet. */ +const FALLBACK_ADVANCE = 0.6; + +function usePaneSize() { + const read = () => ({ + w: document.documentElement.clientWidth || window.innerWidth, + h: document.documentElement.clientHeight || window.innerHeight, + dpr: window.devicePixelRatio || 1, + }); + const [size, setSize] = useState(read); + useEffect(() => { + const update = () => { + const next = read(); + setSize((prev) => + prev.w === next.w && prev.h === next.h && prev.dpr === next.dpr + ? prev + : next, + ); + }; + window.addEventListener('resize', update); + let observer: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(update); + observer.observe(document.documentElement); + } + return () => { + window.removeEventListener('resize', update); + observer?.disconnect(); + }; + }, []); + return size; +} + +function measureAdvance(family: string): number { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return FALLBACK_ADVANCE; + } + ctx.font = `100px ${family}`; + const w = ctx.measureText('M'.repeat(50)).width / 50 / 100; + return w > 0.3 && w < 1 ? w : FALLBACK_ADVANCE; +} + +/** Loads VCR OSD Mono from the lobby asset and returns the measured advance. */ +function useTerminalFont() { + const [advance, setAdvance] = useState(FALLBACK_ADVANCE); + useEffect(() => { + let cancelled = false; + let tries = 0; + const load = () => { + const url = resolveAsset('VCR_OSD_Mono.ttf'); + const face = new FontFace(FONT, `url(${url})`); + face + .load() + .then((loaded) => { + if (cancelled) { + return; + } + document.fonts.add(loaded); + setAdvance(measureAdvance(`'${FONT}'`)); + }) + .catch(() => { + // The asset mapping can arrive a moment after the first render. + if (!cancelled && tries++ < 5) { + setTimeout(load, 1000); + } + }); + }; + load(); + return () => { + cancelled = true; + }; + }, []); + return advance; +} + +export function LobbyTerminal() { + const { data, act } = useBackend(); + const pane = usePaneSize(); + const advance = useTerminalFont(); + const layout = useMemo( + () => computeLayout(pane.w, pane.h, pane.dpr, advance), + [pane.w, pane.h, pane.dpr, advance], + ); + const grid: Grid = useMemo( + () => ({ + cols: layout.cols, + rows: layout.rows, + cw: layout.cw, + ch: layout.ch, + s: layout.s, + narrow: layout.narrow, + }), + [layout], + ); + + const [tab, setTab] = useState('crews'); + const request = data.tab_request; + useEffect(() => { + if (request) { + setTab(request.tab); + } + }, [request?.serial]); + + // Report display metrics once the font settles (Windows scaling, pane size), for tuning. + useEffect(() => { + const id = setTimeout( + () => + act('metrics', { + dpr: layout.dpr, + w: pane.w, + h: pane.h, + s: layout.s, + cols: layout.cols, + rows: layout.rows, + }), + 3000, + ); + return () => clearTimeout(id); + }, [advance]); + + const notice = data.notice; + const confirmed = !!notice && notice.kind === 'ok'; + const { s } = layout; + const scan = Math.max(2, Math.round(s)); + + let body; + if (confirmed) { + body = ; + } else if (tab === 'crews') { + body = ; + } else if (tab === 'shipyard') { + body = ; + } else { + body = ; + } + + return ( +
+ + +
+
+ +
{ + setTab(next); + if (confirmed) { + act('dismiss'); + } + }} + /> + {body} + + +
+
+
+
+ +
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts new file mode 100644 index 00000000000..04257aefb65 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts @@ -0,0 +1,90 @@ +/** + * Terminal geometry: bezel scale, the screen rect and the text grid, from the pane size. + * + * Bezel: integer pixel-art scale s = clamp(min(floor(H / 360), floor(W / 472)), 1, 4), + * snapped to whole device pixels so Windows display scaling doesn't blur the art. + * Grid: font size aims at about 110 columns, independent of s. + */ + +/** 1x pixel numbers of the bezel (bezel.json). */ +export const BEZEL = { + baseHeight: 360, + minWidth: 472, + screenTop: 15, + screenSide: 13, + screenBottom: 52, + deck: 49, +} as const; + +/** Below this many columns or rows, tabs use their narrow (one panel at a time) layout. */ +export const NARROW_COLS = 96; +export const NARROW_ROWS = 22; + +export type Layout = { + /** CSS pixels per art pixel (a whole number of device pixels). */ + s: number; + dpr: number; + /** Screen rect in CSS px, relative to the pane. */ + screen: { x: number; y: number; w: number; h: number }; + fs: number; + /** Cell size in CSS px. */ + cw: number; + ch: number; + cols: number; + rows: number; + /** Grid origin inside the screen. */ + ox: number; + oy: number; + narrow: boolean; +}; + +const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + +/** + * @param advance width of one character at 1px font size (measured from the real font). + */ +export function computeLayout( + paneW: number, + paneH: number, + dpr: number, + advance: number, +): Layout { + const ratio = dpr > 0 ? dpr : 1; + const sCss = clamp( + Math.min( + Math.floor(paneH / BEZEL.baseHeight), + Math.floor(paneW / BEZEL.minWidth), + ), + 1, + 4, + ); + const sDevice = Math.max(1, Math.round(sCss * ratio)); + const s = sDevice / ratio; + + const sx = BEZEL.screenSide * s; + const sy = BEZEL.screenTop * s; + const sw = Math.max(0, paneW - 2 * sx); + const sh = Math.max(0, paneH - sy - BEZEL.screenBottom * s); + + const fs = clamp(Math.floor(sw / (0.6 * 110)), 11, 24); + const cw = fs * advance; + const ch = Math.round(fs * 1.2); + const cols = Math.max(20, Math.floor((sw - 2 * cw) / cw)); + const rows = Math.max(8, Math.floor((sh - ch) / ch)); + const ox = Math.floor((sw - cols * cw) / 2); + const oy = Math.floor((sh - rows * ch) / 2); + return { + s, + dpr: ratio, + screen: { x: sx, y: sy, w: sw, h: sh }, + fs, + cw, + ch, + cols, + rows, + ox, + oy, + narrow: cols < NARROW_COLS || rows < NARROW_ROWS, + }; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx new file mode 100644 index 00000000000..8cb04e6070c --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx @@ -0,0 +1,316 @@ +/** + * Text-grid primitives for the dock terminal, ported from the approved mockup. + * + * Everything on the screen is placed on a cell grid: x is a column, y a row (fractions are + * allowed, as in the mockup). Tabs draw only through these, so every tab matches the + * mockup look without touching the shell. + * + * Colour classes: '' (phosphor), 'dim', 'faint', 'amb', 'red', 'inv' (inverse phosphor), + * 'inva' (inverse amber). + */ +import { + type CSSProperties, + createContext, + type ReactNode, + useContext, + useState, +} from 'react'; + +export type Grid = { + cols: number; + rows: number; + /** Cell size in CSS px. */ + cw: number; + ch: number; + /** Bezel pixel scale; line weights follow it. */ + s: number; + /** Too small for side-by-side panels: use one panel at a time with [ BACK ]. */ + narrow: boolean; +}; + +export const GridContext = createContext({ + cols: 110, + rows: 30, + cw: 9, + ch: 19, + s: 2, + narrow: false, +}); + +/** {cols, rows, cw, ch, s, narrow} of the current screen. */ +export const useGrid = () => useContext(GridContext); + +/** Rows used by the header and tab bar. Tab content starts here. */ +export const TOP = 4; + +export type Cls = '' | 'dim' | 'faint' | 'amb' | 'red' | 'inv' | 'inva'; + +const cx = (cls?: string) => (cls ? cls : undefined); + +type PutProps = { + x: number; + y: number; + /** Colour class. */ + cls?: Cls | string; + children: ReactNode; + onClick?: () => void; + /** Class while hovered (clickable keys light up). */ + hoverCls?: Cls | string; + /** Paint the terminal background behind the text (over frame lines). */ + bg?: boolean; + title?: string; +}; + +/** Text at a cell. */ +export function Put(props: PutProps) { + const { x, y, cls, children, onClick, hoverCls, bg, title } = props; + const { cw, ch } = useGrid(); + const [hover, setHover] = useState(false); + const style: CSSProperties = { + left: Math.round(x * cw), + top: Math.round(y * ch), + }; + if (bg) { + style.background = 'var(--lt-bg)'; + } + if (onClick) { + style.cursor = 'pointer'; + style.zIndex = 5; + } + const shown = hover && hoverCls ? hoverCls : cls; + return ( + { + e.stopPropagation(); + onClick(); + } + : undefined + } + onMouseEnter={hoverCls ? () => setHover(true) : undefined} + onMouseLeave={hoverCls ? () => setHover(false) : undefined} + > + {children} + + ); +} + +/** Text ending just before column x (right-aligned). */ +export function RPut(props: PutProps & { children: string }) { + return ; +} + +type BtnProps = { + x: number; + y: number; + label: string; + /** Right-align: the button ends just before column x. */ + right?: boolean; + /** Lit (inverse) at rest, like the mockup's primary keys. */ + lit?: boolean; + /** Amber variant (CODE / APPLY rows). */ + amber?: boolean; + disabled?: boolean; + onClick?: () => void; +}; + +/** A `[ LABEL ]` key on the grid. Disabled keys draw faint and ignore clicks. */ +export function Btn(props: BtnProps) { + const { x, y, label, right, lit, amber, disabled, onClick } = props; + const text = `[ ${label} ]`; + const at = right ? x - text.length : x; + if (disabled) { + return ( + + {text} + + ); + } + const rest = lit ? (amber ? 'inva' : 'inv') : amber ? 'amb' : ''; + const hover = amber ? 'inva' : 'inv'; + return ( + + {text} + + ); +} + +type FrameProps = { + x: number; + y: number; + w: number; + h: number; + title?: string; + /** Dim border. */ + dim?: boolean; + children?: ReactNode; +}; + +/** Double-line box from cell (x, y), w columns by h rows, with an optional title. */ +export function Frame(props: FrameProps) { + const { x, y, w, h, title, dim, children } = props; + const { cw, ch, s } = useGrid(); + const bw = Math.max(3, Math.round(s) + 1); + const style: CSSProperties = { + borderWidth: bw, + left: x * cw + cw / 2 - bw / 2, + top: y * ch + ch / 2 - bw / 2, + width: Math.max(0, (w - 1) * cw - bw), + height: Math.max(0, (h - 1) * ch - bw), + }; + return ( + <> +
+ {!!title && ( + + {` ${title} `} + + )} + {children} + + ); +} + +/** Horizontal rule through the middle of row y. */ +export function Rule(props: { x: number; y: number; w: number }) { + const { x, y, w } = props; + const { cw, ch, s } = useGrid(); + return ( +
+ ); +} + +/** n filled cells (block characters drawn as boxes on the grid). frac = height share of a row. */ +export function Blocks(props: { + x: number; + y: number; + n: number; + cls?: '' | 'dim' | 'faint' | 'ablk' | 'rblk'; + frac?: number; +}) { + const { x, y, n, cls, frac = 0.7 } = props; + const { cw, ch } = useGrid(); + return ( +
+ ); +} + +/** Invisible click target over a block of cells (whole list rows). */ +export function Hit(props: { + x: number; + y: number; + w: number; + h: number; + onClick: () => void; + onHover?: (over: boolean) => void; +}) { + const { x, y, w, h, onClick, onHover } = props; + const { cw, ch } = useGrid(); + return ( +
onHover(true) : undefined} + onMouseLeave={onHover ? () => onHover(false) : undefined} + /> + ); +} + +/** Pads or cuts to n characters; a cut ends in '.'. */ +export function pad(str: string | number, n: number): string { + const s = String(str); + if (n <= 0) { + return ''; + } + return s.length > n ? `${s.slice(0, n - 1)}.` : s + ' '.repeat(n - s.length); +} + +/** Left-pads to n characters. */ +export function lpad(str: string | number, n: number): string { + const s = String(str); + return ' '.repeat(Math.max(0, n - s.length)) + s; +} + +/** Word-wraps to lines of at most w characters. */ +export function wrap(text: string, w: number): string[] { + const out: string[] = []; + let line = ''; + for (const word of text.split(' ')) { + if (`${line} ${word}`.trim().length > w) { + if (line.trim()) { + out.push(line.trim()); + } + line = word; + } else { + line += ` ${word}`; + } + } + if (line.trim()) { + out.push(line.trim()); + } + return out; +} + +/** Terminal CSS. Scoped under .lt-tui so the short class names can't leak. */ +export const TERM_CSS = ` +.lt-crt { position: absolute; inset: 0; overflow: hidden; + background: radial-gradient(ellipse at 50% 45%, #0c2018 0%, var(--lt-bg) 60%, #040a07 100%); } +.lt-tui { --lt-ph: #7effbf; --lt-dim: #3f9a76; --lt-faint: #2b6450; --lt-amb: #ffc41f; --lt-red: #ff5f6f; + position: absolute; font-family: 'VCR', 'Consolas', monospace; color: var(--lt-ph); white-space: pre; + text-shadow: 0 0 3px rgba(126,255,191,.55), 0 0 9px rgba(126,255,191,.18); user-select: none; } +.lt-tui span { position: absolute; } +.lt-tui .dim { color: var(--lt-dim); text-shadow: 0 0 3px rgba(63,154,118,.45); } +.lt-tui .faint { color: var(--lt-faint); text-shadow: none; } +.lt-tui .amb { color: var(--lt-amb); text-shadow: 0 0 3px rgba(255,196,31,.55), 0 0 9px rgba(255,196,31,.2); } +.lt-tui .red { color: var(--lt-red); text-shadow: 0 0 3px rgba(255,95,111,.55), 0 0 9px rgba(255,95,111,.2); } +.lt-tui .inv { background: var(--lt-ph); color: var(--lt-bg); text-shadow: 0 0 6px rgba(126,255,191,.6); box-shadow: 0 0 8px rgba(126,255,191,.35); } +.lt-tui .inva { background: var(--lt-amb); color: #160f02; box-shadow: 0 0 8px rgba(255,196,31,.35); text-shadow: none; } +.lt-tui .lt-box { position: absolute; border-style: double; border-color: var(--lt-ph); + box-shadow: 0 0 5px rgba(126,255,191,.35), inset 0 0 5px rgba(126,255,191,.18); } +.lt-tui .lt-box.dimb { border-color: var(--lt-dim); box-shadow: 0 0 4px rgba(63,154,118,.3); } +.lt-tui .lt-rule { position: absolute; border-top-style: solid; border-color: var(--lt-faint); } +.lt-tui .lt-blk { position: absolute; background: var(--lt-ph); box-shadow: 0 0 6px rgba(126,255,191,.5); } +.lt-tui .lt-blk.dim { background: var(--lt-dim); box-shadow: none; } +.lt-tui .lt-blk.ablk { background: var(--lt-amb); box-shadow: 0 0 6px rgba(255,196,31,.5); } +.lt-tui .lt-blk.rblk { background: var(--lt-red); box-shadow: 0 0 6px rgba(255,95,111,.5); } +.lt-tui .lt-blk.faint { background: var(--lt-faint); box-shadow: none; } +.lt-glass { position: absolute; inset: 0; z-index: 2; pointer-events: none; + background: radial-gradient(ellipse at 28% 18%, rgba(255,255,255,.045), transparent 38%), + radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.55) 100%); } +.lt-scan { position: absolute; inset: 0; z-index: 2; pointer-events: none; } +.lt-vscreen { position: absolute; box-sizing: border-box; overflow: hidden; background: #050607; + border: 1px solid #1d3a2e; outline: 3px solid #0a1410; outline-offset: 0; + box-shadow: 0 0 0 4px #1d3a2e, inset 0 0 18px rgba(0,0,0,.9), inset 0 2px 6px rgba(0,0,0,.8); } +.lt-vscan { position: absolute; inset: 0; pointer-events: none; z-index: 3; + background: repeating-linear-gradient(to bottom, rgba(0,0,0,.10) 0 1px, transparent 1px 3px), + radial-gradient(ellipse at 30% 15%, rgba(255,255,255,.04), transparent 45%); + box-shadow: inset 0 0 14px rgba(0,0,0,.75); } +`; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts new file mode 100644 index 00000000000..c7c785fe236 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -0,0 +1,269 @@ +/** + * Data contract for the dock terminal lobby (DM: voidcrew/modules/lobby_terminal/). + * + * Shared keys are fixed. Section owners may ADD optional keys in their own section; never + * rename a shared key (a drifted key reads as undefined, i.e. "off", forever). + * + * DM has no booleans: every flag arrives as 0/1 (BooleanLike). Never write `flag && `. + */ +import type { BooleanLike } from 'tgui-core/react'; + +// --------------------------------------------------------------------------- +// Shell (2.1): header, bezel, notice. Owner: P1. +// --------------------------------------------------------------------------- + +export type Phase = 'loading' | 'pregame' | 'setup' | 'playing' | 'ended'; + +export type NoticeKind = 'ok' | 'warn' | 'bad'; + +export type Notice = { + text: string; + /** ok: the CONFIRMED box. warn: one amber line. bad: one red line. */ + kind: NoticeKind; + /** world.time when it was set. */ + at: number; +}; + +export type TabId = 'crews' | 'shipyard' | 'housing'; + +/** bezel.json from the art package (all numbers are 1x pixels). Null: placeholder bezel. */ +export type BezelSpec = { + scale?: { min_width?: number; base_height?: number }; + [key: string]: unknown; +}; + +export type ShellData = { + phase: Phase; + /** playing: deciseconds since round start. loading/pregame: countdown seconds, -1 = delayed. Else 0. */ + clock: number; + online: number; + ready: BooleanLike; + prefs_ready: BooleanLike; + can_observe: BooleanLike; + /** 0 none or disabled, 1 open polls, 2 new polls. */ + polls: 0 | 1 | 2; + /** Position in the popcap queue, 0 when not queued. */ + queue: number; + /** Admins before the round: [players ready, admins ready, admins]. Null otherwise. */ + admin_ready: [number, number, number] | null; + notice: Notice | null; + /** The server asks the client to switch tab; act on each new serial. */ + tab_request: { tab: TabId; serial: number } | null; +}; + +export type ShellStatic = { + wiki: BooleanLike; + localhost_admin: BooleanLike; + /** Port name for the header, or null (header reads "BERTH OFFICE"). */ + port: string | null; + bezel: BezelSpec | null; +}; + +// --------------------------------------------------------------------------- +// CREWS (2.2). Owner: P2. +// --------------------------------------------------------------------------- + +export type CrewJob = { + /** REF(job), a key of ship.job_slots. */ + ref: string; + title: string; + /** Open slots. */ + open: number; + /** FALSE when banned or short on playtime: SIGN ON greys out. */ + ok: BooleanLike; +}; + +export type Crew = { + ref: string; + name: string; + /** Hull class (source_template.short_name). */ + cls: string; + /** Crew aboard (length(manifest)). */ + crew: number; + memo: string; + locked: BooleanLike; + cleared: BooleanLike; + applied: BooleanLike; + /** Crew-only airlocks. */ + doors: BooleanLike; + /** Seconds until PING CREW is allowed again. */ + ping_wait: number; + /** Seconds until a cryo rejoin is allowed. */ + rejoin_wait: number; + /** Seconds left on a wrong-code lockout. */ + code_wait: number; + jobs: CrewJob[]; +}; + +export type CrewsData = { + crews?: Crew[]; +}; + +// --------------------------------------------------------------------------- +// SHIPYARD (2.3). Owners: P3 (DM), P4 (UI). +// --------------------------------------------------------------------------- + +export type PartClass = 'combat' | 'science' | 'trade' | 'misc'; +export type Parts = Partial>; +/** [job title, slots] */ +export type CrewLine = [string, number]; + +export type Finish = { + id: string; + name: string; + desc: string; + cost: Parts; + def: BooleanLike; + /** Slot keys this finish offers. */ + slots: string[]; + jobs: CrewLine[]; +}; + +export type HullSlot = { + key: string; + label: string; +}; + +export type Module = { + id: string; + name: string; + desc: string; + slot: string; + cost: Parts; + def: BooleanLike; + /** Finish ids this module is offered with, or null for every finish. */ + themes: string[] | null; + /** Crew per finish id ("" = any finish). */ + jobs: Record; +}; + +export type Hull = { + /** "[type]" */ + id: string; + name: string; + short: string; + desc: string; + cost: Parts; + free: BooleanLike; + finishes: Finish[]; + slots: HullSlot[]; + modules: Module[]; + /** build_ship_preview_data(hull), unchanged. */ + preview: unknown; +}; + +export type Owned = { + hulls: string[]; + finishes: Record; + modules: Record; +}; + +/** Saved ship card (2.5). Field names follow the mockup; the ship-saving PR maps to them. */ +export type SaveCard = { + slot: number; + name: string; + /** Class line, e.g. "Goon-class Scout". */ + class: string; + mass?: number; + crew?: number; + /** Why it can't load now, or null. */ + denial: string | null; + /** UNDER WAY: the original hull is still in service. */ + locked: BooleanLike; + funds?: number; + research?: number; + /** Encoded floor plan for the line drawing. */ + plan?: string; + /** [label, count, colour class] */ + fit?: [string, number, string][]; + lab?: number; + cargo?: number; +}; + +export type YardData = { + account?: Parts | null; + owned?: Owned | null; + free_ships?: BooleanLike; + can_requisition?: BooleanLike; + /** Name of the crew the player would leave by founding a new one, or null. */ + crew_change?: string | null; + /** Saved ships, null entries for empty slots. Null hides the SAVED SHIPS box. Always sent by the shell. */ + saves: (SaveCard | null)[] | null; +}; + +export type YardStatic = { + /** Only after yard_open. */ + catalog?: Hull[] | null; +}; + +// --------------------------------------------------------------------------- +// HOUSING (2.4). Owner: P5. +// --------------------------------------------------------------------------- + +export type OutpostMode = 'open' | 'password' | 'approved' | 'closed'; + +export type Outpost = { + ref: string; + name: string; + mode: OutpostMode; + residents: number; + cleared: BooleanLike; + /** Short in-character reason MOVE IN is refused, or null. */ + denial: string | null; + code_wait?: number; +}; + +export type HousingData = { + outposts?: Outpost[]; +}; + +// --------------------------------------------------------------------------- + +export type LobbyData = ShellData & + ShellStatic & + CrewsData & + YardData & + YardStatic & + HousingData; + +/** + * Every action and its params. Shell actions are handled in lobby_bezel.dm; prefixed ones + * go to their section (crew_* P2, yard_* P3, home_* P5). + */ +export type LobbyActions = { + join: Record; + ready: Record; + observe: Record; + character: Record; + settings: Record; + wiki: Record; + polls: Record; + manifest: Record; + changelog: Record; + start_now: Record; + dismiss: Record; + /** Client display metrics, logged once per load. */ + metrics: { + dpr: number; + w: number; + h: number; + s: number; + cols: number; + rows: number; + }; + crew_sign_on: { ship: string; job: string }; + crew_code: { ship: string; code: string }; + crew_apply: { ship: string; note: string }; + crew_ping: { ship: string }; + yard_open: Record; + yard_preview: { hull: string }; + yard_commission: { + hull: string; + finish: string; + modules: Record; + leave_crew: 0 | 1; + }; + yard_requisition: { leave_crew: 0 | 1 }; + yard_load: { slot: number; leave_crew: 0 | 1 }; + home_move_in: { outpost: string; code?: string }; +}; diff --git a/voidcrew/modules/lobby_terminal/_lobby_terminal.dm b/voidcrew/modules/lobby_terminal/_lobby_terminal.dm new file mode 100644 index 00000000000..581087f6744 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/_lobby_terminal.dm @@ -0,0 +1,324 @@ +/** + * # Dock terminal lobby + * + * The new-player lobby drawn as a dock terminal: tgui rendered into the `lobby_browser` + * BROWSER control that sits over the map pane (interface/skin.dmf, window "mapwindow"). + * + * One /datum/lobby_terminal per client. It owns the unpooled tgui_window bound to that + * control, the open /datum/tgui, the per-client notice and cached DB values. + * + * The classic HUD lobby (/datum/hud/new_player) is still built underneath, unchanged. If + * the browser never comes up (for example during a 516 resource download stall), the + * control stays hidden and the classic HUD keeps working. + * + * Sections: the shell (this file, lobby_tgui.dm, lobby_bezel.dm) declares every section + * proc below. Section owners override them in their own file without the `proc/` keyword: + * lobby_crews.dm (crew_* actions), lobby_yard.dm (yard_* actions) and lobby_housing.dm + * (home_* actions). Those files sort after this one, so their overrides win. + */ + +/// How long to wait for the browser to report itself visible before offering a reload link. +#define LOBBY_TERMINAL_LOAD_TIMEOUT (30 SECONDS) +/// Wrong codes allowed per ckey per lock (a ship or an outpost) before a lockout. +#define LOBBY_CODE_MAX_FAILURES 5 +/// How long a code lockout lasts. +#define LOBBY_CODE_LOCKOUT (60 SECONDS) +/// Lockouts in one round for one ckey before admins are told. +#define LOBBY_CODE_ADMIN_NOTICE_LOCKOUTS 3 + +/client/var/datum/lobby_terminal/lobby_terminal + +/// Code failures keyed "[ckey]|[lock key]" = list(failures, locked_until). Keyed by ckey, so a reconnect does not reset it. +GLOBAL_LIST_EMPTY(lobby_code_failures) +/// Lockouts this round per ckey, for the admin notice. +GLOBAL_LIST_EMPTY(lobby_code_lockouts) +/// Ckeys with a join route in flight (sign on, commission, requisition, load, move in). Keyed by ckey so a reconnect cannot double it. +GLOBAL_LIST_EMPTY(lobby_join_locks) + +/datum/lobby_terminal + /// The client this terminal belongs to. + var/client/owner + /// Unpooled tgui window bound to the lobby_browser control. + var/datum/tgui_window/window + /// The open tgui, if any. + var/datum/tgui/lobby_terminal/ui + /// TRUE once the browser has reported itself visible since the last show(). + var/visible = FALSE + /// Current notice: list("text", "kind" = "ok"/"warn"/"bad", "at" = world.time), or null. + var/list/notice + /// Cached poll lamp: 0 none or disabled, 1 open polls, 2 new polls. Filled async at show(). + var/polls_state = 0 + /// Tab the shell asks the client to switch to, and a serial so the client can tell a repeat request apart. + var/tab_request + var/tab_request_serial = 0 + /// Load timeout timer id. + var/load_timer + /// The skin.dmf BROWSER control the terminal renders into. + var/control_id = "lobby_browser" + +/datum/lobby_terminal/New(client/owner) + . = ..() + src.owner = owner + window = new /datum/tgui_window(owner, control_id) + RegisterSignal(window, COMSIG_TGUI_WINDOW_VISIBLE, PROC_REF(on_window_visible)) + RegisterSignal(owner, COMSIG_CLIENT_MOB_LOGIN, PROC_REF(on_mob_login)) + +/datum/lobby_terminal/Destroy() + if(owner) + UnregisterSignal(owner, COMSIG_CLIENT_MOB_LOGIN) + if(owner.lobby_terminal == src) + owner.lobby_terminal = null + if(window) + UnregisterSignal(window, COMSIG_TGUI_WINDOW_VISIBLE) + if(ui) + ui.close(can_be_suspended = FALSE) + ui = null + window = null + owner = null + return ..() + +/// TRUE when the terminal is up and drawing over the map for this client. +/datum/lobby_terminal/proc/is_active() + return ui && !QDELETED(ui) && visible + +/// Opens the terminal over the map. Safe to call again; it reuses an open UI. +/datum/lobby_terminal/proc/show(mob/dead/new_player/user) + if(!owner || !istype(user) || user.client != owner) + return + if(!ui || QDELETED(ui)) + visible = FALSE + refresh_polls(user) + ui_interact(user) + if(load_timer) + deltimer(load_timer) + load_timer = addtimer(CALLBACK(src, PROC_REF(on_load_timeout)), LOBBY_TERMINAL_LOAD_TIMEOUT, TIMER_STOPPABLE) + +/// show() one tick later, after the client constructor has finished (as the chat panel does). +/datum/lobby_terminal/proc/show_deferred(mob/dead/new_player/user) + set waitfor = FALSE + sleep(1 TICKS) + if(QDELETED(src) || QDELETED(user) || !owner || user.client != owner) + return + show(user) + +/// Takes the terminal off the map and closes its UI. The classic HUD (or the game) shows again. +/datum/lobby_terminal/proc/hide() + visible = FALSE + if(load_timer) + deltimer(load_timer) + load_timer = null + if(ui && !QDELETED(ui)) + ui.close(can_be_suspended = FALSE) // ui_close() below does the skin work + else + hide_control() + +/// Skin side of hiding: control off, status bar back. +/datum/lobby_terminal/proc/hide_control() + if(!owner) + return + winset(owner, control_id, "is-visible=false") + winset(owner, "mapwindow.status_bar", "is-visible=true") + +/datum/lobby_terminal/proc/on_window_visible(datum/source, client/client) + SIGNAL_HANDLER + if(!ui || QDELETED(ui) || !istype(owner?.mob, /mob/dead/new_player)) + hide_control() + return + visible = TRUE + if(load_timer) + deltimer(load_timer) + load_timer = null + winset(owner, null, list( + "[control_id].is-visible" = "true", + "mapwindow.status_bar.is-visible" = "false", + )) + +/// Any mob change. A new_player Login shows the terminal itself (lobby_bezel.dm); anything else hides it. +/datum/lobby_terminal/proc/on_mob_login(datum/source, mob/new_mob) + SIGNAL_HANDLER + if(istype(new_mob, /mob/dead/new_player)) + return + hide() + +/datum/lobby_terminal/proc/on_load_timeout() + load_timer = null + if(visible || !owner || !istype(owner.mob, /mob/dead/new_player)) + return + log_tgui(owner, "Lobby terminal did not report visible in time", window = window) + to_chat(owner, span_warning("The dock terminal didn't load. Try again.")) + +/datum/lobby_terminal/Topic(href, list/href_list) + . = ..() + if(!href_list["lobby_reload"] || !owner || usr?.client != owner) + return + var/mob/dead/new_player/player = owner.mob + if(!istype(player)) + return + if(ui && !QDELETED(ui)) + ui.close(can_be_suspended = FALSE) + show(player) + +/// Sets the notice line. kind is "ok" (the CONFIRMED box), "warn" (amber line) or "bad" (red line). Null text clears it. +/datum/lobby_terminal/proc/set_notice(text, kind = "ok") + if(!text) + notice = null + else + notice = list("text" = "[text]", "kind" = kind, "at" = world.time) + if(ui && !QDELETED(ui)) + SStgui.update_uis(src) + +/// Asks the client to switch tabs ("crews", "shipyard", "housing"). +/datum/lobby_terminal/proc/goto_tab(tab) + tab_request = tab + tab_request_serial++ + if(ui && !QDELETED(ui)) + SStgui.update_uis(src) + +/// Runs the new-poll query once, off the UI tick, and caches the lamp state. +/datum/lobby_terminal/proc/refresh_polls(mob/dead/new_player/user) + set waitfor = FALSE + polls_state = 0 + if(!user || is_guest_key(user.key) || !SSdbcore.Connect()) + return + var/player_ckey = user.ckey + var/datum/db_query/query_get_new_polls = SSdbcore.NewQuery({" + SELECT id FROM [format_table_name("poll_question")] + WHERE (adminonly = 0 OR :isadmin = 1) + AND Now() BETWEEN starttime AND endtime + AND deleted = 0 + AND id NOT IN ( + SELECT pollid FROM [format_table_name("poll_vote")] + WHERE ckey = :ckey + AND deleted = 0 + ) + AND id NOT IN ( + SELECT pollid FROM [format_table_name("poll_textreply")] + WHERE ckey = :ckey + AND deleted = 0 + ) + "}, list("isadmin" = user.client?.holder ? TRUE : FALSE, "ckey" = player_ckey)) + if(!query_get_new_polls.Execute()) + qdel(query_get_new_polls) + return + var/has_new = query_get_new_polls.NextRow() + qdel(query_get_new_polls) + if(QDELETED(src)) + return + polls_state = has_new ? 2 : 1 + +// --------------------------------------------------------------------------- +// Shared helpers for every section +// --------------------------------------------------------------------------- + +/** + * Name of the crew the player would leave by founding a new one (commission, requisition, + * saved ship), or null when nothing would change. Never prompts. + */ +/datum/lobby_terminal/proc/crew_change_name(mob/user) + var/datum/voidcrew_crew_record/record = user?.mind?.recover_reputation_crew() + if(!record || record.leader == user.mind) + return null + for(var/datum/team/voidcrew/team in record.teams) + return team.name + return "your crew" + +/// Seconds left on a code lockout for this ckey and lock key (a ship or outpost ref), 0 when none. +/datum/lobby_terminal/proc/code_lock_wait(ckey, key) + var/list/entry = GLOB.lobby_code_failures["[ckey]|[key]"] + if(!entry) + return 0 + var/left = entry[2] - world.time + return left > 0 ? CEILING(left / 10, 1) : 0 + +/// Records a wrong code. Returns TRUE when this failure started a lockout. +/datum/lobby_terminal/proc/note_code_failure(ckey, key) + var/entry_key = "[ckey]|[key]" + var/list/entry = GLOB.lobby_code_failures[entry_key] + if(!entry) + entry = list(0, 0) + GLOB.lobby_code_failures[entry_key] = entry + entry[1]++ + log_game("[key_name(owner)] entered a wrong lobby code for [key] ([entry[1]] in a row)") + if(entry[1] < LOBBY_CODE_MAX_FAILURES) + return FALSE + entry[1] = 0 + entry[2] = world.time + LOBBY_CODE_LOCKOUT + var/lockouts = (GLOB.lobby_code_lockouts[ckey] || 0) + 1 + GLOB.lobby_code_lockouts[ckey] = lockouts + log_game("[key_name(owner)] was locked out of lobby codes for [key] (lockout [lockouts] this round)") + if(lockouts >= LOBBY_CODE_ADMIN_NOTICE_LOCKOUTS) + message_admins("[key_name_admin(owner)] has been locked out of lobby join codes [lockouts] times this round.") + return TRUE + +/// Clears the failure count after a right code, or for every ckey when a lock's code changes (ckey null). +/datum/lobby_terminal/proc/clear_code_failures(ckey, key) + if(ckey) + GLOB.lobby_code_failures -= "[ckey]|[key]" + return + var/suffix = "|[key]" + for(var/entry_key in GLOB.lobby_code_failures.Copy()) + if(copytext(entry_key, -length(suffix)) == suffix) + GLOB.lobby_code_failures -= entry_key + +/** + * Takes the per-ckey join lock shared by every join route. FALSE when the player is already + * spawning or another route holds the lock. Every caller must release_join_lock() on every path. + */ +/datum/lobby_terminal/proc/try_join_lock(mob/dead/new_player/user) + if(!istype(user) || !user.ckey) + return FALSE + if(user.spawning || user.spawning_ship || GLOB.lobby_join_locks[user.ckey]) + return FALSE + GLOB.lobby_join_locks[user.ckey] = world.time + return TRUE + +/datum/lobby_terminal/proc/release_join_lock(ckey) + GLOB.lobby_join_locks -= ckey + +// --------------------------------------------------------------------------- +// Section seams. Empty here; each owner overrides in its own file. +// Data procs return an assoc list merged into ui_data (or null). Act procs get the full +// action name and return TRUE when the UI should update. +// --------------------------------------------------------------------------- + +/// CREWS (lobby_crews.dm): data.crews. +/datum/lobby_terminal/proc/crews_data(mob/user) + return null + +/// CREWS actions: crew_sign_on, crew_code, crew_apply, crew_ping. +/datum/lobby_terminal/proc/crews_act(action, list/params, mob/user) + return FALSE + +/// SHIPYARD (lobby_yard.dm): data.account, owned, free_ships, can_requisition, crew_change. +/datum/lobby_terminal/proc/yard_data(mob/user) + return null + +/// SHIPYARD static data: static.catalog, only after yard_open. +/datum/lobby_terminal/proc/yard_static(mob/user) + return null + +/// SHIPYARD actions: yard_open, yard_preview, yard_commission, yard_requisition, yard_load. +/datum/lobby_terminal/proc/yard_act(action, list/params, mob/user) + return FALSE + +/// HOUSING (lobby_housing.dm): data.outposts. +/datum/lobby_terminal/proc/housing_data(mob/user) + return null + +/// HOUSING actions: home_move_in. +/datum/lobby_terminal/proc/housing_act(action, list/params, mob/user) + return FALSE + +/// Saved ships hook (feature/ship-saving overrides it): list of save cards (null entries for empty slots), or null to hide the box. +/datum/lobby_terminal/proc/saved_ships_data() + return null + +/// Saved ships hook: loads the save in that slot. form_new is the crew-leave answer. +/datum/lobby_terminal/proc/load_saved_ship(slot, form_new) + set_notice("Shipyard records are offline.", "warn") + return FALSE + +#undef LOBBY_TERMINAL_LOAD_TIMEOUT +#undef LOBBY_CODE_MAX_FAILURES +#undef LOBBY_CODE_LOCKOUT +#undef LOBBY_CODE_ADMIN_NOTICE_LOCKOUTS diff --git a/voidcrew/modules/lobby_terminal/lobby_assets.dm b/voidcrew/modules/lobby_terminal/lobby_assets.dm new file mode 100644 index 00000000000..fc30d5acdfe --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_assets.dm @@ -0,0 +1,35 @@ +/** + * Terminal art and font. Runtime string paths, not 'compiled' resources, so the art stays + * out of the compiled RSC and the external RSC zip and arrives with the page. + * + * Only files that exist are registered: the bezel art (html/, drawn separately) can be + * missing, and then the client draws its placeholder bezel. + */ +/datum/asset/simple/namespaced/lobby_terminal + +/datum/asset/simple/namespaced/lobby_terminal/register() + var/art_dir = "voidcrew/modules/lobby_terminal/html/" + assets = list() + if(fexists("interface/fonts/VCR_OSD_Mono.ttf")) + assets["VCR_OSD_Mono.ttf"] = "interface/fonts/VCR_OSD_Mono.ttf" + var/list/art = list("frame_9slice.png", "strip_top.png", "deck.png", "grille.png", "tray.png", "lamp_ready.png", "lamp_notready.png", "lamp_off.png") + for(var/key in list("join", "ready", "notready", "observe", "character")) + for(var/key_state in list("idle", "hover", "pressed", "disabled")) + art += "key_[key]_[key_state].png" + for(var/name in art) + if(fexists("[art_dir][name]")) + assets[name] = "[art_dir][name]" + parents = list() + if(fexists("[art_dir]lobby_terminal.css")) + parents["lobby_terminal.css"] = "[art_dir]lobby_terminal.css" + return ..() + +/// The bezel's small buttons, from the real lobby sprites so a sprite change reaches the terminal too. +/datum/asset/spritesheet_batched/lobby_terminal_buttons + name = "lobbyterminal" + +/datum/asset/spritesheet_batched/lobby_terminal_buttons/create_spritesheets() + for(var/button in list("wiki", "poll", "crew_manifest", "settings", "changelog")) + for(var/suffix in list("", "_highlighted", "_disabled")) + insert_icon("lt-[button][suffix]", uni_icon('icons/hud/lobby/bottom_buttons.dmi', "[button][suffix]")) + insert_icon("lt-new_poll", uni_icon('icons/hud/lobby/poll_overlay.dmi', "new_poll")) diff --git a/voidcrew/modules/lobby_terminal/lobby_bezel.dm b/voidcrew/modules/lobby_terminal/lobby_bezel.dm new file mode 100644 index 00000000000..94c323d8cce --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_bezel.dm @@ -0,0 +1,178 @@ +/** + * Terminal shell: the header data, the bezel keys and buttons, and the hooks that put the + * terminal up on a new_player Login. + * + * Every bezel action does what the matching classic HUD button does + * (code/_onclick/hud/new_player.dm). The HUD stays underneath as the fallback. + */ + +/// Hide the control before anything else on connect. Whether Dream Seeker keeps skin +/// visibility across a reconnect is not known, so never trust it. +/client/New(TopicData) + winset(src, "lobby_browser", "is-visible=false") + return ..() + +/mob/dead/new_player/Login() + . = ..() + if(!client || client.interviewee) + return + if(!client.lobby_terminal) + client.lobby_terminal = new /datum/lobby_terminal(client) + client.lobby_terminal.show_deferred(src) + +/// The terminal is the join menu: when it is up, the old join paths land on CREWS instead. +/mob/dead/new_player/select_ship() + var/datum/lobby_terminal/terminal = client?.lobby_terminal + if(terminal?.is_active()) + terminal.goto_tab("crews") + return + return ..() + +/// Art spec from the bezel art's bezel.json, or null for the placeholder bezel. +/proc/lobby_terminal_bezel_spec() + var/static/list/spec + var/static/checked = FALSE + if(checked) + return spec + checked = TRUE + var/path = "voidcrew/modules/lobby_terminal/html/bezel.json" + if(!fexists(path)) + return null + var/list/decoded = json_decode(file2text(path)) + spec = islist(decoded) ? decoded : null + return spec + +/datum/lobby_terminal/proc/shell_data(mob/dead/new_player/user, list/data) + var/phase + var/clock = 0 + if(SSticker.IsPostgame()) + phase = "ended" + else + switch(SSticker.current_state) + if(GAME_STATE_STARTUP) + phase = "loading" + if(GAME_STATE_PREGAME) + phase = "pregame" + if(GAME_STATE_SETTING_UP) + phase = "setup" + if(GAME_STATE_PLAYING) + phase = "playing" + else + phase = "ended" + if(phase == "playing") + clock = STATION_TIME_PASSED() + else if(phase == "loading" || phase == "pregame") + var/time_left = SSticker.GetTimeLeft() + if(time_left == -10) + clock = -1 + else + clock = max(0, round(time_left / 10)) + data["phase"] = phase + data["clock"] = clock + data["online"] = LAZYLEN(GLOB.clients) + data["ready"] = user.ready == PLAYER_READY_TO_PLAY + data["prefs_ready"] = SSearly_assets.initialized == INITIALIZATION_INNEW_REGULAR || SSatoms.initialized == INITIALIZATION_INNEW_REGULAR + data["can_observe"] = SSticker.current_state > GAME_STATE_STARTUP + data["polls"] = polls_state + data["queue"] = SSticker.queued_players.Find(user) + data["admin_ready"] = (user.client?.holder && phase != "playing") ? list(SSticker.totalPlayersReady, SSticker.total_admins_ready, length(GLOB.admins)) : null + data["notice"] = notice + data["tab_request"] = tab_request ? list("tab" = tab_request, "serial" = tab_request_serial) : null + +/datum/lobby_terminal/proc/shell_act(action, list/params, mob/dead/new_player/user) + switch(action) + if("join") + return act_join(user) + if("ready") + return act_ready(user) + if("observe") + if(SSticker.current_state <= GAME_STATE_STARTUP) + return FALSE + INVOKE_ASYNC(user, TYPE_PROC_REF(/mob/dead/new_player, make_me_an_observer)) + return FALSE + if("character", "settings") + var/datum/preferences/preferences = user.client?.prefs + if(!preferences) + return FALSE + if(!(SSearly_assets.initialized == INITIALIZATION_INNEW_REGULAR || SSatoms.initialized == INITIALIZATION_INNEW_REGULAR)) + return FALSE + preferences.current_window = action == "character" ? PREFERENCE_TAB_CHARACTER_PREFERENCES : PREFERENCE_TAB_GAME_PREFERENCES + preferences.update_static_data(user) + preferences.ui_interact(user) + return FALSE + if("wiki") + if(CONFIG_GET(string/wikiurl)) + user.client?.wiki() + return FALSE + if("polls") + if(!polls_state) + return FALSE + user.handle_player_polling() + return FALSE + if("manifest") + user.ViewManifest() + return FALSE + if("changelog") + user.client?.changelog() + return FALSE + if("start_now") + if(!user.client?.is_localhost() || !check_rights_for(user.client, R_SERVER)) + return FALSE + SSticker.start_immediately = TRUE + if(SSticker.current_state == GAME_STATE_STARTUP) + set_notice("Starting as soon as the port opens.", "warn") + return TRUE + if("dismiss") + notice = null + return TRUE + if("metrics") + // Display metrics from the client (Windows scaling, pane size), logged once per load for tuning. + log_tgui(user, "Lobby terminal metrics: dpr [text2num(params["dpr"])], pane [text2num(params["w"])]x[text2num(params["h"])], scale [text2num(params["s"])], grid [text2num(params["cols"])]x[text2num(params["rows"])]", window = window) + return FALSE + return FALSE + +/// JOIN: the popcap queue of the classic Join button, then CREWS. The terminal is the join menu. +/datum/lobby_terminal/proc/act_join(mob/dead/new_player/user) + if(!SSticker?.IsRoundInProgress()) + set_notice("The port is not open yet.", "warn") + return TRUE + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + set_notice("No berths are being assigned right now.", "warn") + return TRUE + var/relevant_cap + var/hard_popcap = CONFIG_GET(number/hard_popcap) + var/extreme_popcap = CONFIG_GET(number/extreme_popcap) + if(hard_popcap && extreme_popcap) + relevant_cap = min(hard_popcap, extreme_popcap) + else + relevant_cap = max(hard_popcap, extreme_popcap) + if(SSticker.queued_players.len || (relevant_cap && living_player_count() >= relevant_cap && !(ckey(user.key) in GLOB.admin_datums))) + var/queue_position = SSticker.queued_players.Find(user) + if(!queue_position) + SSticker.queued_players += user + queue_position = SSticker.queued_players.len + to_chat(user, span_danger("[CONFIG_GET(string/hard_popcap_message)]")) + set_notice("Port is full. You are number [queue_position] in line.", "warn") + return TRUE + user.auto_deadmin_on_ready_or_latejoin() + goto_tab("crews") + return TRUE + +/// READY: same as the classic Ready button, and keeps that button's own state in step. +/datum/lobby_terminal/proc/act_ready(mob/dead/new_player/user) + if(SSticker.current_state > GAME_STATE_PREGAME) + return FALSE + var/now_ready = user.ready != PLAYER_READY_TO_PLAY + if(now_ready) + user.auto_deadmin_on_ready_or_latejoin() + user.ready = PLAYER_READY_TO_PLAY + else + user.ready = PLAYER_NOT_READY + var/datum/hud/hud = user.hud_used + if(hud) + for(var/atom/movable/screen/lobby/button/ready/ready_button in hud.static_inventory) + ready_button.ready = now_ready + ready_button.base_icon_state = now_ready ? "ready" : "not_ready" + ready_button.update_appearance(UPDATE_ICON) + SEND_SIGNAL(hud, COMSIG_HUD_PLAYER_READY_TOGGLE) + return TRUE diff --git a/voidcrew/modules/lobby_terminal/lobby_crews.dm b/voidcrew/modules/lobby_terminal/lobby_crews.dm new file mode 100644 index 00000000000..c128e26e3a4 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_crews.dm @@ -0,0 +1,2 @@ +// CREWS section (package P2). Override the section procs declared in _lobby_terminal.dm here: crews_data(), crews_act(). +// Placeholder from the shell package; the section owner replaces this file. diff --git a/voidcrew/modules/lobby_terminal/lobby_housing.dm b/voidcrew/modules/lobby_terminal/lobby_housing.dm new file mode 100644 index 00000000000..90945f0846d --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_housing.dm @@ -0,0 +1,2 @@ +// HOUSING section (package P5). Override the section procs declared in _lobby_terminal.dm here: housing_data(), housing_act(). +// Placeholder from the shell package; the section owner replaces this file. diff --git a/voidcrew/modules/lobby_terminal/lobby_tgui.dm b/voidcrew/modules/lobby_terminal/lobby_tgui.dm new file mode 100644 index 00000000000..b735b8be203 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_tgui.dm @@ -0,0 +1,119 @@ +/** + * The terminal's tgui. Same as /datum/tgui except open(): instead of a pooled popup it uses + * the terminal's own unpooled window bound to the lobby_browser control. + * + * The body below copies /datum/tgui/open() (code/modules/tgui/tgui.dm). If upstream changes + * that proc, change this one to match. Unpooled windows never suspend + * (/datum/tgui_window/can_be_suspended() needs `pooled`), which matters here: a suspend + * would winset the control to 1x1 off-screen and wreck the map pane. + */ +/datum/tgui/lobby_terminal + +/datum/tgui/lobby_terminal/open() + if(!user.client) + return FALSE + if(window) + return FALSE + var/datum/lobby_terminal/terminal = src_object + if(!istype(terminal) || !terminal.window || terminal.owner != user.client) + return FALSE + process_status() + if(status < UI_UPDATE) + return FALSE + window = terminal.window + opened_at = world.time + window.acquire_lock(src) + if(!window.is_ready()) + window.initialize( + strict_mode = TRUE, + fancy = FALSE, + assets = list( + get_asset_datum(/datum/asset/simple/tgui), + )) + else + window.send_message("ping") + send_assets() + window.send_message("update", get_payload( + with_data = TRUE, + with_static_data = TRUE)) + SStgui.on_open(src) + return TRUE + +/** + * Only the owner's new_player body may use the terminal. Anything else closes it, which is + * what stops SStgui.on_transfer() from carrying the lobby onto a freshly spawned body. + */ +/datum/ui_state/lobby_terminal + +GLOBAL_DATUM_INIT(lobby_terminal_state, /datum/ui_state/lobby_terminal, new) + +/datum/ui_state/lobby_terminal/can_use_topic(src_object, mob/user) + var/datum/lobby_terminal/terminal = src_object + if(!istype(terminal) || !istype(user, /mob/dead/new_player) || !user.client || user.client != terminal.owner) + return UI_CLOSE + return UI_INTERACTIVE + +/datum/lobby_terminal/ui_state(mob/user) + return GLOB.lobby_terminal_state + +/datum/lobby_terminal/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new /datum/tgui/lobby_terminal(user, src, "LobbyTerminal") + if(!ui.open()) + qdel(ui) + return + src.ui = ui + +/datum/lobby_terminal/ui_close(mob/user) + . = ..() + ui = null + visible = FALSE + hide_control() + +/datum/lobby_terminal/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/simple/namespaced/lobby_terminal), + get_asset_datum(/datum/asset/spritesheet_batched/lobby_terminal_buttons), + ) + +/datum/lobby_terminal/ui_static_data(mob/user) + var/list/data = list() + data["wiki"] = !!CONFIG_GET(string/wikiurl) + data["localhost_admin"] = !!(user.client?.is_localhost() && check_rights_for(user.client, R_SERVER)) + data["port"] = null + data["bezel"] = lobby_terminal_bezel_spec() + merge_section(data, yard_static(user)) + return data + +/datum/lobby_terminal/ui_data(mob/user) + var/list/data = list() + shell_data(user, data) + data["saves"] = saved_ships_data() + merge_section(data, crews_data(user)) + merge_section(data, yard_data(user)) + merge_section(data, housing_data(user)) + return data + +/// Copies a section's keys into the payload. Sections may return null. +/datum/lobby_terminal/proc/merge_section(list/data, list/section) + if(!islist(section)) + return + for(var/key in section) + data[key] = section[key] + +/datum/lobby_terminal/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/user = ui.user + if(!istype(user, /mob/dead/new_player) || user.client != owner) + return FALSE + var/prefix = copytext(action, 1, 6) + if(prefix == "crew_") + return crews_act(action, params, user) + if(prefix == "yard_") + return yard_act(action, params, user) + if(prefix == "home_") + return housing_act(action, params, user) + return shell_act(action, params, user) diff --git a/voidcrew/modules/lobby_terminal/lobby_yard.dm b/voidcrew/modules/lobby_terminal/lobby_yard.dm new file mode 100644 index 00000000000..502b37382cc --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_yard.dm @@ -0,0 +1,2 @@ +// SHIPYARD section (package P3). Override the section procs declared in _lobby_terminal.dm here: yard_data(), yard_static(), yard_act(). +// Placeholder from the shell package; the section owner replaces this file. diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..163f7f98824 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -10,6 +10,7 @@ #include "glass_floor_baseturfs.dm" #include "hyperspace_jetpack.dm" #include "late_initialization_reentry.dm" +#include "lobby_terminal_bill.dm" #include "map_parser_attributes.dm" #include "mod_night_vision.dm" #include "operating_table_movement.dm" diff --git a/voidcrew/modules/unit_tests/lobby_terminal_bill.dm b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm new file mode 100644 index 00000000000..66b68877287 --- /dev/null +++ b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm @@ -0,0 +1 @@ +// Lobby terminal bill test (package P3). Placeholder from the shell package; the owner replaces this file. From 0d327850cfc29c5bab25b61db28a54db5af394cc Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:22:13 -0800 Subject: [PATCH 52/72] Add the dock terminal HOUSING tab and lobby wiki updates Lists player outposts with their admission mode and residents, moves the player in through join_outpost (which now takes an entered code instead of prompting), rate-limits wrong outpost codes through the shared lobby code limiter, and lets players ask INVITE outposts to take them in. The wiki pages for new players, the shipyard and ship parts describe the terminal. --- .../interfaces/LobbyTerminal/HousingTab.tsx | 274 ++++++++++++++++-- .../interfaces/LobbyTerminal/types.ts | 6 + .../modules/lobby_terminal/lobby_housing.dm | 179 +++++++++++- .../player_outposts/outpost_residents.dm | 6 +- wiki/content/new-player-guide.md | 8 +- wiki/content/ship-parts.md | 2 +- wiki/content/ship-upgrades.md | 21 +- 7 files changed, 464 insertions(+), 32 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx index 2c393ebbf79..da61db13552 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -1,28 +1,268 @@ /** - * HOUSING tab. STUB from the shell package: package P5 owns and replaces this file. - * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. - * data.outposts: see types.ts. + * HOUSING tab: player outposts taking residents (DM: lobby_housing.dm). + * Draws only with the term.tsx primitives, plus one input for the CODE field. */ +import { Fragment, type ReactNode, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Frame, Put, TOP, useGrid } from './term'; -import type { LobbyData } from './types'; +import { Blocks, Btn, type Cls, Frame, Put, Rule, TOP, useGrid } from './term'; +import type { LobbyData, Outpost, OutpostMode } from './types'; + +const MODES: Record = { + open: ['OPEN', ''], + password: ['CODE', 'amb'], + approved: ['INVITE', 'amb'], + closed: ['CLOSED', 'red'], +}; + +/** Cuts a name to n cells, ending a cut in '.'. */ +function cutName(name: string, n: number): string { + return name.length <= n ? name : `${name.slice(0, n - 1).trimEnd()}.`; +} + +/** Width of the code field between its brackets. */ +const FIELD = 10; +const MOVE_IN = '[ MOVE IN ]'; +/** Widest key group at the right of a row: CODE [field] [ MOVE IN ]. */ +const KEYS_W = 5 + FIELD + 3 + MOVE_IN.length; + +/** A text field on the grid, n cells wide, drawn in the terminal font. */ +function CodeField(props: { + x: number; + y: number; + n: number; + value: string; + disabled: boolean; + onChange: (value: string) => void; + onEnter: () => void; +}) { + const { x, y, n, value, disabled, onChange, onEnter } = props; + const { cw, ch } = useGrid(); + return ( + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onEnter(); + } + }} + style={{ + position: 'absolute', + left: x * cw, + top: y * ch, + width: n * cw, + height: ch, + padding: 0, + margin: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: disabled ? 'var(--lt-faint)' : 'var(--lt-amb)', + caretColor: 'var(--lt-amb)', + font: 'inherit', + lineHeight: 'inherit', + letterSpacing: 0, + zIndex: 6, + }} + /> + ); +} export function HousingTab() { - const { data } = useBackend(); - const { cols, rows } = useGrid(); + const { act, data } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const [codes, setCodes] = useState>({}); + const [page, setPage] = useState(0); const outposts = data.outposts || []; + + const h = rows - 1 - TOP; + const bottom = TOP + h - 1; + const hy = TOP + 1; + const right = cols - 4; + // The mockup's columns on a wide screen; tighter on a narrow one, with the keys on a second line. + const colA = narrow ? Math.min(22, Math.floor(cols * 0.42)) : 30; + const colR = narrow ? colA + 9 : 44; + const nameW = colA - 5; + const step = narrow ? 4 : 3; + const perPage = Math.max(1, Math.floor((bottom - 1 - (hy + 2)) / step)); + const pages = Math.max(1, Math.ceil(outposts.length / perPage)); + const shownPage = Math.min(page, pages - 1); + const shown = outposts.slice( + shownPage * perPage, + shownPage * perPage + perPage, + ); + const blockEnd = narrow ? cols - 3 : right - KEYS_W - 2; + const blockRoom = Math.max(0, Math.floor((blockEnd - (colR + 5)) / 2)); + + const moveIn = (o: Outpost) => { + act('home_move_in', { outpost: o.ref, code: codes[o.ref] || '' }); + setCodes({ ...codes, [o.ref]: '' }); + }; + + const codeKeys = (o: Outpost, y: number) => { + const locked = (o.code_wait || 0) > 0; + const code = codes[o.ref] || ''; + const fx = right - MOVE_IN.length - 2 - FIELD; + const cls = locked ? 'faint' : 'amb'; + return ( + <> + + CODE + + + {`[${' '.repeat(FIELD)}]`} + + setCodes({ ...codes, [o.ref]: value })} + onEnter={() => moveIn(o)} + /> + 0} + disabled={locked} + onClick={() => moveIn(o)} + /> + + ); + }; + + const keys = (o: Outpost, y: number) => { + if (o.mode === 'password' && !o.cleared && !o.denial) { + return codeKeys(o, y); + } + const ax = right - MOVE_IN.length - 2; + let ask: ReactNode = null; + if (o.can_ask) { + ask = o.asked ? ( + + ) : ( + act('home_request', { outpost: o.ref })} + /> + ); + } + return ( + <> + {ask} + {o.denial ? ( + // Faint, but a press still answers with the reason as a notice. + act('home_move_in', { outpost: o.ref })} + > + {MOVE_IN} + + ) : ( + moveIn(o)} + /> + )} + + ); + }; + return ( - - - NO ROOMS TO LET. - + + {outposts.length === 0 ? ( + + NO ROOMS TO LET. + + ) : ( + <> + + OUTPOST + + + ADMITS + + + {narrow ? 'RES' : 'RESIDENTS'} + + + )} + {shown.map((o, i) => { + const y = hy + 2 + i * step; + const [mode, modeCls] = MODES[o.mode] || MODES.closed; + const blocks = Math.max(0, Math.min(o.residents, blockRoom)); + return ( + + + {cutName(o.name.toUpperCase(), nameW)} + + + {mode} + + + {String(o.residents).padStart(3, ' ')} + + {Array.from({ length: blocks }, (_, k) => ( + + ))} + {keys(o, narrow ? y + 1 : y)} + {i < shown.length - 1 && ( + + )} + + ); + })} + {pages > 1 && ( + <> + setPage(shownPage - 1)} + /> + + {`${shownPage + 1}/${pages}`} + + = pages - 1} + onClick={() => setPage(shownPage + 1)} + /> + + )} ); } diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts index c7c785fe236..5e058274e40 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -210,7 +210,12 @@ export type Outpost = { cleared: BooleanLike; /** Short in-character reason MOVE IN is refused, or null. */ denial: string | null; + /** Seconds left on a wrong-code lockout. */ code_wait?: number; + /** INVITE outposts: the player may ask to move in. */ + can_ask?: BooleanLike; + /** A move-in request from this player is still fresh. */ + asked?: BooleanLike; }; export type HousingData = { @@ -266,4 +271,5 @@ export type LobbyActions = { yard_requisition: { leave_crew: 0 | 1 }; yard_load: { slot: number; leave_crew: 0 | 1 }; home_move_in: { outpost: string; code?: string }; + home_request: { outpost: string }; }; diff --git a/voidcrew/modules/lobby_terminal/lobby_housing.dm b/voidcrew/modules/lobby_terminal/lobby_housing.dm index 90945f0846d..638e3f57bba 100644 --- a/voidcrew/modules/lobby_terminal/lobby_housing.dm +++ b/voidcrew/modules/lobby_terminal/lobby_housing.dm @@ -1,2 +1,177 @@ -// HOUSING section (package P5). Override the section procs declared in _lobby_terminal.dm here: housing_data(), housing_act(). -// Placeholder from the shell package; the section owner replaces this file. +// HOUSING section of the dock terminal (lobby_terminal). Lists player outposts and moves the player in +// through /mob/dead/new_player/join_outpost() (voidcrew/modules/player_outposts/outpost_residents.dm), +// which stays the authority on admission. Codes go through the shared limiter in _lobby_terminal.dm. + +/// How long a ckey waits before asking the same outpost again. +#define LOBBY_HOUSING_REQUEST_COOLDOWN (3 MINUTES) +/// How long a ckey waits between asking any two outposts. +#define LOBBY_HOUSING_REQUEST_GAP (10 SECONDS) + +/// Move-in requests: "[ckey]|[outpost ref]" = world.time asked, and "[ckey]" = world.time of the last request anywhere. +GLOBAL_LIST_EMPTY(lobby_housing_requests) + +/// Outposts a lobby player can see: loaded and owned. +/datum/lobby_terminal/proc/housing_outposts() + . = list() + for(var/obj/structure/overmap/dynamic/player_outpost/home as anything in GLOB.player_outposts) + if(QDELETED(home) || !home.loaded || !home.founder_ckey) + continue + . += home + +/// Resolves a client ref only inside the live outpost list. +/datum/lobby_terminal/proc/housing_find(ref) + if(!istext(ref)) + return null + var/obj/structure/overmap/dynamic/player_outpost/home = locate(ref) in GLOB.player_outposts + if(QDELETED(home) || !home.loaded || !home.founder_ckey) + return null + return home + +/// Limiter key for an outpost's code. The access revision is part of it, so a new code clears old failures. +/datum/lobby_terminal/proc/housing_code_key(obj/structure/overmap/dynamic/player_outpost/home) + return "outpost:[REF(home)]:[home.resident_access_revision]" + +/** + * Short in-character reason this player can't move in, or null. Mirrors the order of + * resident_admission_error(). A password outpost the player isn't cleared for reads as + * null here: the code field is the gate, and join_outpost() checks the code itself. + */ +/datum/lobby_terminal/proc/housing_denial(obj/structure/overmap/dynamic/player_outpost/home, player_key) + if(!SSticker.IsRoundInProgress()) + return "Opens when the shift starts." + if(home.resident_mode == "closed") + return "Not taking residents." + if(player_key in home.blocked_residents) + return "You're barred from here." + var/cleared = home.has_resident_clearance(player_key) + if(home.dock_mode == OUTPOST_DOCK_MODE_LOCKDOWN && !cleared) + return "Locked down. Residents only." + if(home.resident_mode == "approved" && !cleared) + return "Residents by invitation." + if(home.resident_mode == "password" && !cleared && !length(home.resident_password)) + return "Not taking residents." + var/wait = cryo_rejoin_wait(player_key, home) + if(wait) + return "Still in cryo for [DisplayTimeText(wait)]." + if(!home.available_resident_pod()) + return "No free cryopods." + return null + +/// TRUE while this ckey's request to this outpost is still fresh. +/datum/lobby_terminal/proc/housing_asked(obj/structure/overmap/dynamic/player_outpost/home, player_key) + var/asked_at = GLOB.lobby_housing_requests["[player_key]|[REF(home)]"] + return asked_at && world.time < asked_at + LOBBY_HOUSING_REQUEST_COOLDOWN + +/datum/lobby_terminal/housing_data(mob/user) + var/player_key = user?.ckey + var/list/outposts = list() + for(var/obj/structure/overmap/dynamic/player_outpost/home as anything in housing_outposts()) + outposts += list(list( + "ref" = REF(home), + "name" = home.name, + "mode" = home.resident_mode, + "residents" = home.active_resident_count(), + "cleared" = !!home.has_resident_clearance(player_key), + "denial" = housing_denial(home, player_key), + "code_wait" = code_lock_wait(player_key, housing_code_key(home)), + "can_ask" = !!(home.resident_mode == "approved" && SSticker.IsRoundInProgress() && !home.has_resident_clearance(player_key) && !(player_key in home.blocked_residents)), + "asked" = !!housing_asked(home, player_key), + )) + return list("outposts" = outposts) + +/datum/lobby_terminal/housing_act(action, list/params, mob/user) + var/mob/dead/new_player/player = user + if(!istype(player) || !player.ckey) + return FALSE + var/obj/structure/overmap/dynamic/player_outpost/home = housing_find(params["outpost"]) + if(!home) + set_notice("That outpost is gone.", "warn") + return TRUE + switch(action) + if("home_move_in") + housing_move_in(player, home, params["code"]) + return TRUE + if("home_request") + housing_request(player, home) + return TRUE + return FALSE + +/// Checks the code against the limiter, then hands off to join_outpost() off the UI tick. +/datum/lobby_terminal/proc/housing_move_in(mob/dead/new_player/player, obj/structure/overmap/dynamic/player_outpost/home, code) + var/player_key = player.ckey + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + set_notice("No berths are being assigned right now.", "warn") + return + var/denial = housing_denial(home, player_key) + if(denial) + set_notice(denial, "warn") + return + var/attempt = "" + if(home.resident_mode == "password" && !home.has_resident_clearance(player_key)) + var/lock_key = housing_code_key(home) + var/wait = code_lock_wait(player_key, lock_key) + if(wait) + set_notice("Keypad locked. Try again in [wait] seconds.", "bad") + return + attempt = istext(code) ? trim(copytext(code, 1, 65)) : "" + if(!length(attempt)) + set_notice("Enter the code.", "warn") + return + // Exact match, as resident_admission_error() compares it. The code is never echoed. + if(attempt != home.resident_password) + if(note_code_failure(player_key, lock_key)) + set_notice("Keypad locked.", "bad") + else + set_notice("Wrong code.", "bad") + return + clear_code_failures(player_key, lock_key) + if(!try_join_lock(player)) + set_notice("Already on your way.", "warn") + return + set_notice("Moving in at [home.name].", "ok") + INVOKE_ASYNC(src, PROC_REF(housing_finish_move_in), player, home, attempt, player_key) + +/** + * join_outpost() sleeps (eligibility, appearance). The join lock spans all of it. + * attempt is never null here, so join_outpost() never opens its own password prompt. + */ +/datum/lobby_terminal/proc/housing_finish_move_in(mob/dead/new_player/player, obj/structure/overmap/dynamic/player_outpost/home, attempt, player_key) + var/joined = FALSE + if(!QDELETED(player) && !QDELETED(home)) + joined = player.join_outpost(home, attempt) + release_join_lock(player_key) + if(joined || QDELETED(src)) + return + var/denial = (!QDELETED(home) && player_key) ? housing_denial(home, player_key) : null + set_notice(denial || "Couldn't move in. Try again.", "warn") + +/// INVITE outposts: tells the residents on shift that this player wants to move in. +/datum/lobby_terminal/proc/housing_request(mob/dead/new_player/player, obj/structure/overmap/dynamic/player_outpost/home) + var/player_key = player.ckey + if(home.resident_mode != "approved" || !SSticker.IsRoundInProgress() || home.has_resident_clearance(player_key) || (player_key in home.blocked_residents)) + return + if(housing_asked(home, player_key)) + return + var/last = GLOB.lobby_housing_requests[player_key] + if(last && world.time < last + LOBBY_HOUSING_REQUEST_GAP) + set_notice("Wait a moment.", "warn") + return + var/list/recipients = list() + for(var/datum/mind/member as anything in home.residents) + if(QDELETED(member) || !member.current?.client || member.current.stat == DEAD) + continue + recipients |= member.current + if(!length(recipients)) + set_notice("Nobody's home at [home.name].", "warn") + return + GLOB.lobby_housing_requests[player_key] = world.time + GLOB.lobby_housing_requests["[player_key]|[REF(home)]"] = world.time + var/safe_name = html_encode(home.name) + var/safe_key = html_encode(player.key) + for(var/mob/resident as anything in recipients) + to_chat(resident, span_notice("[safe_name] housing office: [safe_key] is asking to move in.")) + log_game("[key_name(player)] asked to move in at outpost [home.name] ([length(recipients)] residents told)") + set_notice("Request sent to [home.name].", "ok") + +#undef LOBBY_HOUSING_REQUEST_COOLDOWN +#undef LOBBY_HOUSING_REQUEST_GAP diff --git a/voidcrew/modules/player_outposts/outpost_residents.dm b/voidcrew/modules/player_outposts/outpost_residents.dm index 045855736a1..a349b7b49f9 100644 --- a/voidcrew/modules/player_outposts/outpost_residents.dm +++ b/voidcrew/modules/player_outposts/outpost_residents.dm @@ -79,12 +79,12 @@ /// Through the existing lobby only: respawn's delay/configuration still controls /// reaching this mob, and character reuse/job/population gates are checked again. -/mob/dead/new_player/proc/join_outpost(obj/structure/overmap/dynamic/player_outpost/home) +/// attempt: the resident password, already entered (the dock terminal's code field). Null prompts for it. +/mob/dead/new_player/proc/join_outpost(obj/structure/overmap/dynamic/player_outpost/home, attempt = null) if(spawning || spawning_ship || !client || QDELETED(home)) return FALSE - var/attempt var/revision = home.resident_access_revision - if(home.resident_mode == "password" && !home.has_resident_clearance(ckey)) + if(isnull(attempt) && home.resident_mode == "password" && !home.has_resident_clearance(ckey)) attempt = tgui_input_text(src, "Enter the resident password for [home.name].", "Resident Arrival", max_length = 64, encode = FALSE, timeout = 60 SECONDS) if(QDELETED(home) || !client || spawning || spawning_ship) return FALSE diff --git a/wiki/content/new-player-guide.md b/wiki/content/new-player-guide.md index 64e5e8177cd..0d6b5561e3d 100644 --- a/wiki/content/new-player-guide.md +++ b/wiki/content/new-player-guide.md @@ -17,7 +17,13 @@ Each hull is rolled at random: a hull class, one of that class's themes, and a r Your job comes from the ship category preferences in your character setup, Command, Security, Engineering, Medical, Science, Cargo, Service and Assistant, each set to high, medium or low. Each hull picks its captain first, contested between the players who set Command highest, with ties broken at random; if nobody wants Command, someone is drafted and told so. Everyone else is matched to an open job in a category they asked for, working down from high to low, then to any open job. Job titles vary by hull and theme, so the captain might be called Captain, Team Leader or something stranger. -If you join after the round has started, you get a ship menu instead. You can join any existing crew that has an open slot and has left joining enabled, or launch a ship of your own from the shipyard. The two Pill-class hulls are free; everything else costs ship parts you have banked from previous rounds. +If you join after the round has started, press **JOIN** on the dock terminal. The terminal has three tabs: + +- **CREWS** lists every crew that is taking people. Pick one to see its ship, its memo and its open jobs, then press **SIGN ON** next to the job you want. A crew with a code needs the code typed into its **CODE** field first; if you don't have it, **APPLY** with a short note and the captain decides. A full crew can be pinged so its captain knows someone is waiting. +- **SHIPYARD** builds you a ship of your own. See [Buying & Upgrading Ships](ship-upgrades.md). The two Pill-class hulls are free; everything else costs ship parts you have banked from previous rounds. +- **HOUSING** lists [player outposts](player-outposts.md) taking residents. Press **MOVE IN** to wake up in one of the outpost's cryopods. An outpost marked **CODE** needs its code typed in first. One marked **INVITE** takes only people its management has invited; press **REQUEST** to let the residents on shift know you want in. + +The keys along the bottom of the terminal open your character setup, the game settings, the crew manifest, polls, the changelog and this wiki. **OBSERVE** lets you watch as a ghost instead. ## What is already on your ship diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md index 39f7a8c9270..5ddd41d3505 100644 --- a/wiki/content/ship-parts.md +++ b/wiki/content/ship-parts.md @@ -77,6 +77,6 @@ Give the item to the recipient so they can put it in their case and extract it. ## Spending banked parts -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. +Open the **SHIPYARD** tab on the lobby's dock terminal to see your balance and the prices of hulls, themes and modules. The bill for a ship adds up everything on it you don't own yet, and **COMMISSION** pays it from your banked parts in one go. 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. diff --git a/wiki/content/ship-upgrades.md b/wiki/content/ship-upgrades.md index 429f3643447..7a8b7fbb9b1 100644 --- a/wiki/content/ship-upgrades.md +++ b/wiki/content/ship-upgrades.md @@ -2,7 +2,7 @@ title: Buying & Upgrading Ships category: The Fleet order: 2 -blurb: Buying permanent unlocks in the shipyard and choosing a hull, theme and modules. +blurb: Building a ship in the shipyard: choosing a hull, theme and modules, and paying for permanent unlocks. --- The shipyard lets you choose a hull, theme and modules before launching. Purchases use [ship parts](ship-parts.md) banked to your account, and everything you buy stays unlocked for future rounds. For the hulls themselves, see the [Ship Catalog](ship-catalog.md). @@ -17,22 +17,27 @@ Use **Request Extraction Case** under the **IC** tab, put up to five parts insid ## The shipyard -From the lobby, the join menu has a **Start Your Own Ship** section with an **Open Shipyard** button. Everything about buying a ship happens in that one window, because it is the only place that shows you what you are buying. +Open the **SHIPYARD** tab on the dock terminal in the lobby. Everything about buying a ship happens there, because it is the only place that shows you what you are buying. -It has three tabs beside the map preview. **Hull** is the shelf: every ship with its part cost, crew capacity, slot count and theme count. **Theme** lists that hull's whole-ship variants, with the free default pre-selected. **Upgrades** gives one row per slot and the modules that fit it, with one free default in each. +1. **Pick a hull.** The shelf lists every ship with its part cost and crew. Selecting one shows its floor plan and description. +2. **Fit it out.** Choose a finish (the hull's theme) and a module for each slot. The floor plan redraws as you change them, so you can see what each room will look like. A hull's description tells you less than its floor plan does. +3. **Check the bill.** The bill lists everything on the ship you don't own yet, with the price of each and the total. Things you already own, and the free defaults, cost nothing. +4. **Commission.** One press pays the whole bill and launches the ship. If you are short, the bill shows which parts you are missing and the key stays dark. -The preview panel draws the actual ship map, and it is live: change the theme and the whole hull redraws, pick a different module and that room changes in place. Use it. A hull's description tells you less than its floor plan does. +Everything on the bill is unlocked for good once it is paid. The next time you build that hull, everything you bought is free to pick again. If the shipyard is too busy to launch your ship right after you paid, your unlocks are kept, so commissioning it again costs nothing. -Buying anything asks for confirmation and tells you the cost. Purchases are one-time and permanent: a theme or module you unlock is yours on every future ship of that class. Selecting is separate from buying, so you can own four modules for a slot and still fly the default. Once the hull is paid for, **LAUNCH SHIP** appears next to Cancel. +**REQUISITION** gives you a free hull when no crew has a seat open for you. + +If you are in someone else's crew, building a ship means leaving it to start your own. The bill says which crew you would leave, and **COMMISSION** asks you to confirm before anything is spent. !!! tip "Look before you spend" - Switching hulls in the shipyard costs nothing and the preview works on hulls you do not own. Read every floor plan first. + Browsing hulls and fittings costs nothing, and the floor plan works on hulls you do not own. Read every floor plan first. ## What happens when you launch -Your ship is built to order (hull, theme and the module in each slot) and placed in the safe inner ring of the overmap. You spawn aboard as its captain, so you set its course and manage its crew. It then appears in every other player's join menu with its job list, so people can pick a role and spawn straight in. If you want a crew, launch early and put something useful in the ship's memo. Then read [Piloting](piloting.md), [Ship Systems](ship-systems.md) and [Ship Combat](ship-combat.md). +Your ship is built to order (hull, theme and the module in each slot) and placed in the safe inner ring of the overmap. You spawn aboard as its captain, so you set its course and manage its crew. It then appears in every other player's CREWS tab with its job list, so people can pick a role and spawn straight in. If you want a crew, launch early and put something useful in the ship's memo. Then read [Piloting](piloting.md), [Ship Systems](ship-systems.md) and [Ship Combat](ship-combat.md). -A crew can run several ships, but only its leader can add one, by buying, requisitioning, commissioning or claiming it. The leader is whoever founded the crew. Anyone else who does it is asked whether they want to leave and start their own crew with that ship; saying no cancels it at no cost. A captain can still rebuild their own crew's ship from a checkpoint. If the leader leaves the crew, goes to cryo or respawns, the captain of the crew's oldest surviving ship takes over, or the earliest remaining member if it has no eligible captain. [Reputation](zone-reputation.md) stays with the crew across hull losses and is shared by its ships; accounts, research and mission slots remain per ship. +A crew can run several ships, but only its leader can add one, by buying, requisitioning, commissioning or claiming it. The leader is whoever founded the crew. Anyone else who does it is asked first, then leaves their crew and starts a new one with that ship. Saying no costs nothing. A captain can still rebuild their own crew's ship from a checkpoint. If the leader leaves the crew, goes to cryo or respawns, the captain of the crew's oldest surviving ship takes over, or the earliest remaining member if it has no eligible captain. [Reputation](zone-reputation.md) stays with the crew across hull losses and is shared by its ships; accounts, research and mission slots remain per ship. ## Modular hulls: slots and modules From 6eaed8f626c3dfff474b5c143c7cd793610d58ee Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:22:29 -0800 Subject: [PATCH 53/72] Add the CREWS tab to the dock terminal lobby One line per crew with the record beside it: memo, berths with SIGN ON, CODE and NOTE + APPLY for locked crews, PING CREW when every berth is taken. SIGN ON is a direct join with no popups. It checks the popcap queue and stops (the old path printed 'Server is full' and joined anyway), job bans and playtime off the UI tick, the cryo rejoin wait, admin-disabled late join and the shared join lock. Wrong codes go through the shared lockout, and a new code on the ship resets it. --- .../interfaces/LobbyTerminal/CrewRecord.tsx | 355 ++++++++++++++++++ .../interfaces/LobbyTerminal/CrewsTab.tsx | 220 ++++++++++- .../modules/lobby_terminal/lobby_crews.dm | 290 +++++++++++++- 3 files changed, 846 insertions(+), 19 deletions(-) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx new file mode 100644 index 00000000000..162d9ffc510 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx @@ -0,0 +1,355 @@ +/** + * The record pane of one crew: memo, berths with SIGN ON, CODE / NOTE + APPLY for locked + * crews, PING CREW when every berth is taken. Package P2. + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { + Blocks, + Btn, + Frame, + Put, + pad, + RPut, + Rule, + TOP, + useGrid, + wrap, +} from './term'; +import type { Crew, LobbyData } from './types'; + +/** SHIP_JOIN_PASSWORD_MAX_LEN and SHIP_APPLICATION_MESSAGE_MAX_LEN (voidcrew/_DEFINES/overmap.dm). */ +const CODE_MAX = 24; +const NOTE_MAX = 200; + +/** Seconds as m:ss. */ +export function clock(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s < 10 ? '0' : ''}${s}`; +} + +/** Cuts to n characters without padding; a cut ends in '.'. */ +export function cut(str: string, n: number): string { + return str.length > n ? `${str.slice(0, Math.max(0, n - 1))}.` : str; +} + +/** Locked and not yet cleared to board. */ +export const isLocked = (c: Crew) => !!c.locked && !c.cleared; + +/** A one-line text field on the grid, drawn in amber inside [ ]. Enter submits. */ +function Field(props: { + x: number; + y: number; + w: number; + value: string; + max: number; + disabled?: boolean; + onChange: (value: string) => void; + onEnter: () => void; +}) { + const { x, y, w, value, max, disabled, onChange, onEnter } = props; + const { cw, ch } = useGrid(); + return ( + <> + + {`[${' '.repeat(w)}]`} + + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onEnter(); + } + }} + style={{ + position: 'absolute', + left: Math.round((x + 1) * cw), + top: Math.round(y * ch), + width: Math.round(w * cw), + height: ch, + padding: 0, + margin: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: 'var(--lt-amb)', + caretColor: 'var(--lt-amb)', + font: 'inherit', + lineHeight: `${ch}px`, + textShadow: 'inherit', + userSelect: 'text', + zIndex: 6, + }} + /> + + ); +} + +type Props = { + crew: Crew; + x: number; + w: number; + h: number; + /** Narrow layout: the record has the whole screen and closes with BACK. */ + narrow: boolean; + onClose: () => void; +}; + +export function CrewRecord(props: Props) { + const { crew: c, x, w, h, narrow, onClose } = props; + const { act } = useBackend(); + const [code, setCode] = useState(''); + const [note, setNote] = useState(''); + const bottom = TOP + h - 1; + const out: ReactNode[] = []; + let y = TOP + 2; + + const closeLabel = narrow ? ' BACK ' : ' X '; + out.push( + + {closeLabel} + , + ); + + out.push( + + {pad(c.cls.toUpperCase(), Math.max(1, w - 18))} + , + + {`${c.crew} ABOARD`} + , + ); + y += 1; + if (c.doors) { + out.push( + + DOORS + , + + CREW ONLY + , + ); + } + y += 2; + + const locked = isLocked(c) && !c.applied; + // Rows the lower block needs: the code and note rows for a locked crew, one row otherwise. + const tail = locked ? 7 : c.applied ? 3 : 1; + + if (c.memo) { + const room = Math.max(1, bottom - y - tail - 6); + const lines = wrap(`"${c.memo}"`, w - 6).slice(0, Math.min(5, room)); + for (const line of lines) { + out.push( + + {line} + , + ); + y++; + } + y += 1; + } + out.push(); + y += 1; + + if (!c.jobs.length) { + out.push( + + NO BERTHS OPEN + , + ); + y += 3; + if (c.ping_wait > 0) { + out.push( + + [ PING CREW ] + , + + {clock(c.ping_wait)} + , + ); + } else { + out.push( + act('crew_ping', { ship: c.ref })} + />, + ); + } + return <>{out}; + } + + if (c.rejoin_wait > 0 && !locked) { + y += 1; + out.push( + + {`BACK ABOARD IN ${clock(c.rejoin_wait)}`} + , + ); + y += 2; + } + + const grey = locked || !!c.applied; + const sign = '[ SIGN ON ]'; + const maxBlocks = Math.max(1, Math.floor((w - 20 - sign.length - 4) / 2)); + // Two rows per berth as in the mockup, one when that would not fit. + const step = y + c.jobs.length * 2 + tail <= bottom ? 2 : 1; + const jobRoom = Math.max(1, Math.floor((bottom - y - tail) / step)); + for (const job of c.jobs.slice(0, jobRoom)) { + const usable = !grey && !!job.ok && c.rejoin_wait <= 0; + out.push( + + {pad(job.title.toUpperCase(), 14)} + , + ); + const shown = Math.min(job.open, maxBlocks); + for (let i = 0; i < shown; i++) { + out.push( + , + ); + } + out.push( + act('crew_sign_on', { ship: c.ref, job: job.ref })} + />, + ); + y += step; + } + + if (locked) { + out.push(); + y += 2; + const waiting = c.code_wait > 0; + const sendCode = () => { + if (!code.trim() || waiting) { + return; + } + act('crew_code', { ship: c.ref, code }); + setCode(''); + }; + out.push( + + CODE + , + , + , + ); + if (waiting) { + out.push( + + {clock(c.code_wait)} + , + ); + } + y += 2; + const sendNote = () => { + act('crew_apply', { ship: c.ref, note }); + setNote(''); + }; + out.push( + + NOTE + , + , + ); + y += 2; + out.push( + , + ); + } else if (c.applied) { + out.push(); + y += 2; + out.push( + + [ APPLIED ] + , + ); + } + + return <>{out}; +} + +/** The record pane with its frame. */ +export function CrewRecordPane(props: Props) { + const { crew, x, w, h } = props; + return ( + + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx index 81e6a2beacf..8f22e7be011 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx @@ -1,28 +1,214 @@ /** - * CREWS tab. STUB from the shell package: package P2 owns and replaces this file. - * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2 - * (rows - 1 is the shell's notice line). data.crews: see types.ts. + * CREWS tab: one line per crew, and the record of the selected one beside it (or in its + * place on a narrow screen). Package P2. Draws only with the term.tsx primitives; content + * lives between rows TOP and rows - 2 (rows - 1 is the shell's notice line). */ +import { type ReactNode, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Frame, Put, TOP, useGrid } from './term'; -import type { LobbyData } from './types'; +import { CrewRecordPane, isLocked } from './CrewRecord'; +import { Frame, Hit, lpad, Put, pad, TOP, useGrid } from './term'; +import type { Crew, LobbyData } from './types'; + +function openSlots(c: Crew): number { + return c.jobs.reduce((sum, job) => sum + job.open, 0); +} + +/** Status word and its colour, as in the mockup. Open crews show nothing. */ +function status(c: Crew): [string, string] | null { + if (!c.jobs.length) { + return ['FULL', 'red']; + } + if (c.applied) { + return ['APPLIED', 'amb']; + } + if (isLocked(c)) { + return ['LOCKED', 'amb']; + } + return null; +} export function CrewsTab() { const { data } = useBackend(); - const { cols, rows } = useGrid(); + const { cols, rows, narrow } = useGrid(); const crews = data.crews || []; + const [selRef, setSelRef] = useState(null); + const [page, setPage] = useState(1); + + const selected = crews.find((c) => c.ref === selRef) || null; + const h = rows - 1 - TOP; + + if (selected && narrow) { + return ( + setSelRef(null)} + /> + ); + } + + const split = !!selected; + // The mockup splits 64 | 46 at 110 columns. + const lw = split ? Math.round((cols * 64) / 110) : cols; + const showMemo = !split && !narrow; + // Fixed-width columns are placed from the right; the class column takes a share of the rest. + // With the memo column it starts at the mockup's share of the width (column 70 of 126). + const memo = Math.round((cols * 70) / 126); + const st = showMemo ? memo - 10 : lw - 11; + const open = st - (showMemo ? 7 : 6); + const crewCol = open - (showMemo ? 7 : 6); + const name = 3; + const cls = name + Math.min(26, Math.round((crewCol - name) * 0.58)); + + const hy = TOP + 1; + const fit = Math.max(1, h - 3); + const pages = Math.max(1, Math.ceil(crews.length / fit)); + const pg = Math.min(pages, Math.max(1, page)); + const shown = crews.slice((pg - 1) * fit, pg * fit); + + const out: ReactNode[] = []; + out.push( + + SHIP + , + + CLASS + , + + CREW + , + + OPEN + , + ); + if (showMemo) { + out.push( + + MEMO + , + ); + } + + if (!crews.length) { + out.push( + + {data.phase === 'playing' + ? 'NO CREWS ARE SIGNING ON.' + : 'CREWS SIGN ON ONCE THE PORT OPENS.'} + , + ); + } + + shown.forEach((c, i) => { + const y = hy + 1 + i; + const isSel = c.ref === selRef; + const k = isSel ? 'inv' : ''; + const slots = openSlots(c); + const stat = status(c); + if (isSel) { + out.push( + + {' '.repeat(Math.max(0, lw - 3))} + , + ); + } + out.push( + + {isSel ? '>' : ' '} + , + + {pad(c.name.toUpperCase(), cls - name - 1)} + , + + {pad(c.cls, crewCol - cls - 1)} + , + + {lpad(c.crew, 3)} + , + + {lpad(slots || '-', 3)} + , + ); + if (stat) { + out.push( + + {stat[0]} + , + ); + } + if (showMemo) { + out.push( + + {pad(c.memo, cols - memo - 2)} + , + ); + } + out.push( + setSelRef(isSel ? null : c.ref)} + />, + ); + }); + + if (pages > 1) { + const py = TOP + h - 1; + const label = ` ${pg}/${pages} `; + out.push( + 1 ? 'inv' : 'faint'} + bg={pg <= 1} + onClick={pg > 1 ? () => setPage(pg - 1) : undefined} + > + {' < '} + , + + {label} + , + = pages} + onClick={pg < pages ? () => setPage(pg + 1) : undefined} + > + {' > '} + , + ); + } + return ( - - - NO CREWS ARE SIGNING ON. - - + <> + + {out} + + {!!selected && ( + setSelRef(null)} + /> + )} + ); } diff --git a/voidcrew/modules/lobby_terminal/lobby_crews.dm b/voidcrew/modules/lobby_terminal/lobby_crews.dm index c128e26e3a4..bc1eca90b99 100644 --- a/voidcrew/modules/lobby_terminal/lobby_crews.dm +++ b/voidcrew/modules/lobby_terminal/lobby_crews.dm @@ -1,2 +1,288 @@ -// CREWS section (package P2). Override the section procs declared in _lobby_terminal.dm here: crews_data(), crews_act(). -// Placeholder from the shell package; the section owner replaces this file. +/** + * CREWS section of the dock terminal (package P2). + * + * Lists every joinable crew and runs the direct join path: SIGN ON, CODE, APPLY and PING. + * This replaces select_job_on_ship()'s popups while the terminal is up. The classic + * ShipJoinMenu stays as the fallback and is not touched. + * + * Every action re-validates on the server. Refs only resolve against get_joinable_ships() + * and that ship's job_slots, never a bare locate(). + */ + +/// Ping cooldown, the same as ship_join_menu.dm's SHIP_JOIN_PING_COOLDOWN (undefined at the end of that file). +#define LOBBY_CREW_PING_COOLDOWN (2 MINUTES) +/// One application per ckey per this long, across every ship. +#define LOBBY_CREW_APPLY_COOLDOWN (10 SECONDS) +/// How long a cached job eligibility answer (ban, playtime) is trusted. +#define LOBBY_CREW_JOB_CHECK_TTL (5 MINUTES) + +/datum/lobby_terminal + /// REF(job) = list(ok, world.time checked). Eligibility can query the ban DB and sleep, so it never runs inside ui_data. + var/list/crew_job_ok = list() + /// TRUE while an eligibility refresh is running. + var/crew_jobs_checking = FALSE + /// Ckey = world.time of the next allowed application. Static, so a reconnect does not reset it. + var/static/list/crew_apply_cooldowns = list() + /// REF(ship) = md5 of the join code last seen, so a code change wipes everyone's wrong-code count. + var/static/list/crew_code_seen = list() + /// Shared instance of the old join menu, only to reach its static ping cooldown list. + var/static/datum/ship_join_menu/crew_ping_menu + +/datum/lobby_terminal/crews_data(mob/user) + var/list/crews = list() + var/ckey = user?.ckey + var/list/to_check + for(var/obj/structure/overmap/ship/ship as anything in get_joinable_ships()) + var/ship_ref = REF(ship) + var/list/jobs = list() + for(var/datum/job/job as anything in ship.job_slots) + var/open = ship.job_slots[job] + if(open <= 0) + continue + var/job_ref = REF(job) + var/list/cached = crew_job_ok[job_ref] + if(!cached || world.time - cached[2] > LOBBY_CREW_JOB_CHECK_TTL) + LAZYOR(to_check, job) + jobs += list(list( + "ref" = job_ref, + "title" = job.title, + "open" = open, + "ok" = cached ? cached[1] : TRUE, + )) + crews += list(list( + "ref" = ship_ref, + "name" = ship.name, + "cls" = ship.source_template?.short_name || "Unknown", + "crew" = length(ship.manifest), + "memo" = ship.memo || "", + "locked" = !!ship.join_password, + "cleared" = ship.is_password_cleared(ckey), + "applied" = !isnull(ship.find_crew_application(ckey)), + "doors" = !!ship.crew_only_airlocks, + "ping_wait" = CEILING(crew_ping_wait(ckey, ship) / (1 SECONDS), 1), + "rejoin_wait" = CEILING(cryo_rejoin_wait(ckey, ship) / (1 SECONDS), 1), + "code_wait" = code_lock_wait(ckey, ship_ref), + "jobs" = jobs, + )) + if(to_check && !crew_jobs_checking && istype(user, /mob/dead/new_player)) + crew_jobs_checking = TRUE + INVOKE_ASYNC(src, PROC_REF(refresh_crew_jobs), user, to_check) + return list("crews" = crews) + +/// Runs the job eligibility checks (ban DB, playtime, account age) off the UI tick and caches the answers. +/datum/lobby_terminal/proc/refresh_crew_jobs(mob/dead/new_player/user, list/jobs) + for(var/datum/job/job as anything in jobs) + if(QDELETED(src) || QDELETED(user) || !user.client) + break + if(QDELETED(job)) + continue + var/ok = SSjob.check_job_eligibility(user, job, "Lobby terminal") == JOB_AVAILABLE + if(ok && user.client) + ok = job.special_check_latejoin(user.client) + crew_job_ok[REF(job)] = list(!!ok, world.time) + crew_jobs_checking = FALSE + +/// Deciseconds until this ckey may ping this ship. Both the sender and the ship must be off cooldown. +/datum/lobby_terminal/proc/crew_ping_wait(ckey, obj/structure/overmap/ship/ship) + var/list/cooldowns = crew_ping_cooldowns() + return max(0, (cooldowns[ckey] || 0) - world.time, COOLDOWN_TIMELEFT(ship, join_ping_cooldown)) + +/// The old join menu's static ping cooldown list, shared so the two menus can't be used to double a ping. +/datum/lobby_terminal/proc/crew_ping_cooldowns() + if(!crew_ping_menu) + crew_ping_menu = new /datum/ship_join_menu(null) + return crew_ping_menu.ping_cooldowns + +/// Finds a joinable ship by ref. Never a bare locate(). +/datum/lobby_terminal/proc/crew_find_ship(ship_ref) + if(!istext(ship_ref)) + return null + var/obj/structure/overmap/ship/ship = locate(ship_ref) + if(!istype(ship) || QDELETED(ship) || !(ship in get_joinable_ships())) + return null + return ship + +/// Finds the job datum for a ref among this ship's berths. +/datum/lobby_terminal/proc/crew_find_job(obj/structure/overmap/ship/ship, job_ref) + if(!istext(job_ref)) + return null + for(var/datum/job/job as anything in ship.job_slots) + if(REF(job) == job_ref) + return job + return null + +/datum/lobby_terminal/crews_act(action, list/params, mob/user) + var/mob/dead/new_player/player = user + if(!istype(player) || !player.ckey) + return FALSE + var/obj/structure/overmap/ship/ship = crew_find_ship(params["ship"]) + if(!ship) + set_notice("That ship has left.", "warn") + return TRUE + switch(action) + if("crew_sign_on") + return crew_sign_on(player, ship, params["job"]) + if("crew_code") + return crew_enter_code(player, ship, params["code"]) + if("crew_apply") + return crew_apply(player, ship, params["note"]) + if("crew_ping") + return crew_ping(player, ship) + return FALSE + +/** + * Port is full: the classic Join button's rule plus the old late join's "first in line" + * rule, and unlike select_job_on_ship() it actually stops the join. Admins skip it. + * Puts the player in line and returns TRUE when they have to wait. + */ +/datum/lobby_terminal/proc/crew_port_full(mob/dead/new_player/player) + if(ckey(player.key) in GLOB.admin_datums) + return FALSE + var/relevant_cap + var/hard_popcap = CONFIG_GET(number/hard_popcap) + var/extreme_popcap = CONFIG_GET(number/extreme_popcap) + if(hard_popcap && extreme_popcap) + relevant_cap = min(hard_popcap, extreme_popcap) + else + relevant_cap = max(hard_popcap, extreme_popcap) + var/over_cap = relevant_cap && living_player_count() >= relevant_cap + var/behind = length(SSticker.queued_players) && SSticker.queued_players[1] != player + if(!over_cap && !behind) + return FALSE + var/queue_position = SSticker.queued_players.Find(player) + if(!queue_position) + SSticker.queued_players += player + queue_position = length(SSticker.queued_players) + set_notice("Port is full. You are number [queue_position] in line.", "warn") + return TRUE + +/// The gates SIGN ON checks before and after its sleep. Returns a short denial line, or null. +/datum/lobby_terminal/proc/crew_sign_on_denial(mob/dead/new_player/player, obj/structure/overmap/ship/ship, datum/job/job) + if(!SSticker?.IsRoundInProgress()) + return "The port is not open yet." + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + return "No berths are being assigned right now." + if(QDELETED(ship) || !(ship in get_joinable_ships())) + return "That ship has left." + if(!job || !(job in ship.job_slots) || ship.job_slots[job] <= 0) + return "That berth was just taken." + if(!ship.is_password_cleared(player.ckey)) + return "Their doors are locked." + if(cryo_rejoin_wait(player.ckey, ship)) + return "You left that crew too recently." + return null + +/datum/lobby_terminal/proc/crew_sign_on(mob/dead/new_player/player, obj/structure/overmap/ship/ship, job_ref) + var/datum/job/job = crew_find_job(ship, job_ref) + var/denial = crew_sign_on_denial(player, ship, job) + if(denial) + set_notice(denial, "warn") + return TRUE + if(crew_port_full(player)) + return TRUE + var/list/cached = crew_job_ok[REF(job)] + if(cached && !cached[1]) + set_notice("You aren't cleared for that job.", "warn") + return TRUE + if(!try_join_lock(player)) + return FALSE + INVOKE_ASYNC(src, PROC_REF(crew_finish_sign_on), player, ship, job) + return TRUE + +/// The sleeping half of SIGN ON: eligibility (may hit the ban DB), every gate again, then the spawn. Holds the join lock throughout. +/datum/lobby_terminal/proc/crew_finish_sign_on(mob/dead/new_player/player, obj/structure/overmap/ship/ship, datum/job/job) + var/player_ckey = player.ckey + var/eligible = SSjob.check_job_eligibility(player, job, "Lobby terminal sign on") == JOB_AVAILABLE + if(QDELETED(player) || !player.client || QDELETED(src)) + release_join_lock(player_ckey) + return + if(eligible) + eligible = job.special_check_latejoin(player.client) + if(!QDELETED(job)) + crew_job_ok[REF(job)] = list(!!eligible, world.time) + if(!eligible) + release_join_lock(player_ckey) + set_notice("You aren't cleared for that job.", "warn") + return + var/denial = crew_sign_on_denial(player, ship, job) + if(denial) + release_join_lock(player_ckey) + set_notice(denial, "warn") + return + if(crew_port_full(player)) + release_join_lock(player_ckey) + return + player.auto_deadmin_on_ready_or_latejoin() + set_notice("Waking aboard [ship.name] as [job.title].", "ok") + var/joined = player.AttemptSpawnOnShip(job, ship) + release_join_lock(player_ckey) + if(QDELETED(src)) + return + if(joined) + // Don't greet them with this if they come back to the lobby later. + notice = null + else if(!QDELETED(player) && player.client) + set_notice("Could not sign on. Try another berth.", "bad") + +/datum/lobby_terminal/proc/crew_enter_code(mob/dead/new_player/player, obj/structure/overmap/ship/ship, code) + if(!ship.join_password || ship.is_password_cleared(player.ckey)) + return TRUE + var/ship_ref = REF(ship) + // A new code on the ship wipes everyone's wrong-code count for it. + var/code_hash = md5(lowertext(ship.join_password)) + if(crew_code_seen[ship_ref] != code_hash) + crew_code_seen[ship_ref] = code_hash + clear_code_failures(null, ship_ref) + if(code_lock_wait(player.ckey, ship_ref)) + set_notice("Too many wrong codes. Wait a minute.", "bad") + return TRUE + // Raw on purpose: captains set the code through raw TGUI params, so the attempt stays raw + // too (as select_job_on_ship() does). It is only compared, never shown or logged. + var/attempt = trim("[code || ""]", SHIP_JOIN_PASSWORD_MAX_LEN + 1) + if(!length(attempt)) + return FALSE + if(!ship.check_join_password(attempt)) + if(note_code_failure(player.ckey, ship_ref)) + set_notice("Too many wrong codes. Wait a minute.", "bad") + else + set_notice("Wrong code.", "bad") + return TRUE + ship.password_cleared_ckeys[player.ckey] = TRUE + clear_code_failures(player.ckey, ship_ref) + log_game("[key_name(player)] entered the join code of ship [ship.name] at the lobby terminal") + set_notice(null) + return TRUE + +/datum/lobby_terminal/proc/crew_apply(mob/dead/new_player/player, obj/structure/overmap/ship/ship, note) + if(!ship.join_password || ship.is_password_cleared(player.ckey) || ship.find_crew_application(player.ckey)) + return TRUE + if(world.time < (crew_apply_cooldowns[player.ckey] || 0)) + set_notice("Wait a moment before applying again.", "warn") + return TRUE + // Raw on purpose, as the old menu does (encode = FALSE): the captain's TGUI escapes it, + // and it is html_encode()d at the one place it reaches chat. + note = trim("[note || ""]", SHIP_APPLICATION_MESSAGE_MAX_LEN + 1) + if(!length(note)) + set_notice("Write a note for the captain.", "warn") + return TRUE + crew_apply_cooldowns[player.ckey] = world.time + LOBBY_CREW_APPLY_COOLDOWN + ship.file_crew_application(player, note) + return TRUE + +/// PING CREW: the old join menu's ping, with the same shared cooldowns. +/datum/lobby_terminal/proc/crew_ping(mob/dead/new_player/player, obj/structure/overmap/ship/ship) + if(ship_has_open_slots(ship)) + return TRUE + if(crew_ping_wait(player.ckey, ship) > 0) + return TRUE + var/list/cooldowns = crew_ping_cooldowns() + cooldowns[player.ckey] = world.time + LOBBY_CREW_PING_COOLDOWN + COOLDOWN_START(ship, join_ping_cooldown, LOBBY_CREW_PING_COOLDOWN) + ship.ship_notify("Someone wants to join [html_encode(ship.name)]. Please open a job slot at the cryogenic oversight console.", "CREW REQUEST", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 25) + to_chat(player, span_notice("Ping sent to [html_encode(ship.name)]'s crew.")) + log_game("[key_name(player)] pinged ship [ship.name] to request an open job slot.") + return TRUE + +#undef LOBBY_CREW_PING_COOLDOWN +#undef LOBBY_CREW_APPLY_COOLDOWN +#undef LOBBY_CREW_JOB_CHECK_TTL From 7366ab69e2fe87cd60e4ff698b97340547183ac0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:22:46 -0800 Subject: [PATCH 54/72] Add the dock terminal shipyard backend with a single commission bill The shipyard section sends the hull catalog after the player opens it, sends each hull's preview images on demand, and caches parts and ownership off the UI tick. COMMISSION rebuilds the bill on the server, spends it once and records every unlock, then launches with the crew answer the terminal collected. spend_parts now refunds the classes it already took when a later class fails. --- voidcrew/edits/mobs/new_player.dm | 12 +- voidcrew/modules/lobby_terminal/lobby_yard.dm | 612 +++++++++++++++++- .../ship_purchase/ship_economy_database.dm | 47 +- .../modules/unit_tests/lobby_terminal_bill.dm | 201 +++++- 4 files changed, 851 insertions(+), 21 deletions(-) diff --git a/voidcrew/edits/mobs/new_player.dm b/voidcrew/edits/mobs/new_player.dm index 06a93b2919d..deb96839ade 100644 --- a/voidcrew/edits/mobs/new_player.dm +++ b/voidcrew/edits/mobs/new_player.dm @@ -118,6 +118,8 @@ /// Flag to prevent double-clicking ship spawn /mob/dead/new_player/var/spawning_ship = FALSE +/// Crew-leave answer the dock terminal already collected for requisition_free_hull() (TRUE founds a new crew). Null asks with a popup. +/mob/dead/new_player/var/lobby_form_new = null /** * Callback when player confirms their hull, theme and upgrade selections. @@ -130,12 +132,13 @@ spawn_ship_with_upgrades(template, upgrade_selections, selected_theme) /** - * Actually spawn the ship with the given upgrade selections and theme + * Actually spawn the ship with the given upgrade selections and theme. + * preset_form_new: the crew-leave answer the dock terminal already collected (lobby_terminal/lobby_yard.dm); null asks with a popup. */ -/mob/dead/new_player/proc/spawn_ship_with_upgrades(datum/map_template/shuttle/voidcrew/template, list/upgrade_selections, datum/ship_theme/selected_theme) +/mob/dead/new_player/proc/spawn_ship_with_upgrades(datum/map_template/shuttle/voidcrew/template, list/upgrade_selections, datum/ship_theme/selected_theme, preset_form_new = null) if(!template) return select_ship() - var/form_new = choose_new_reputation_crew(src) + var/form_new = isnull(preset_form_new) ? choose_new_reputation_crew(src) : preset_form_new if(isnull(form_new)) return var/datum/voidcrew_crew_record/registration_crew = mind?.recover_reputation_crew() @@ -193,7 +196,8 @@ if(!can_requisition_hull(src)) to_chat(src, span_warning("A berth has opened aboard the fleet.")) return select_ship() - var/form_new = choose_new_reputation_crew(src) + var/form_new = isnull(lobby_form_new) ? choose_new_reputation_crew(src) : lobby_form_new + lobby_form_new = null if(isnull(form_new)) return var/datum/voidcrew_crew_record/registration_crew = mind?.recover_reputation_crew() diff --git a/voidcrew/modules/lobby_terminal/lobby_yard.dm b/voidcrew/modules/lobby_terminal/lobby_yard.dm index 502b37382cc..5a2c7989305 100644 --- a/voidcrew/modules/lobby_terminal/lobby_yard.dm +++ b/voidcrew/modules/lobby_terminal/lobby_yard.dm @@ -1,2 +1,610 @@ -// SHIPYARD section (package P3). Override the section procs declared in _lobby_terminal.dm here: yard_data(), yard_static(), yard_act(). -// Placeholder from the shell package; the section owner replaces this file. +/** + * # Dock terminal: SHIPYARD (backend) + * + * Overrides the shipyard section procs declared in _lobby_terminal.dm. + * + * - yard_static(): the hull catalog, sent only after the player opens SHIPYARD. + * - yard_data(): parts, ownership and requisition state. DB-backed values are cached on the + * terminal and refreshed off the UI tick (yard_refresh()), never inside ui_data(). + * - yard_act(): yard_open, yard_preview (per-hull lazy preview images), yard_commission + * (one bill for the hull, finish and modules not owned yet, then launch), yard_requisition + * and yard_load (the ship-saves hook). + * + * The client sends a configuration, never a price. Every bill is rebuilt here from the shelf. + */ + +/// Cached DB values older than this are fetched again on the next shipyard action. +#define LOBBY_YARD_REFRESH_INTERVAL (60 SECONDS) +/// A join lock older than this, with no spawn in flight, is left over from a runtime and is dropped. +#define LOBBY_YARD_STALE_LOCK (3 MINUTES) + +/// The shelf as catalog entries for the terminal. Same for everyone; rebuilt when FREE_SHIPS changes. +GLOBAL_LIST(lobby_yard_catalog) +/// FREE_SHIPS value the catalog was built with. +GLOBAL_VAR(lobby_yard_catalog_free) +/// Hull type string -> preview image names for that hull, filled with the catalog. +GLOBAL_LIST_EMPTY(lobby_yard_preview_files) + +/datum/lobby_terminal + /// TRUE once this player opened SHIPYARD; the catalog is only sent after that. + var/yard_opened = FALSE + /// Cached parts account: class -> count, or null when there is no ledger (no DB, guest). + var/list/yard_account + /// Cached ownership: list("hulls" = list(), "finishes" = list(hull = ids), "modules" = list(hull = ids)). + var/list/yard_owned + /// world.time of the last finished refresh. + var/yard_refreshed_at = 0 + /// TRUE while a refresh is waiting on the DB. + var/yard_refreshing = FALSE + /// Hull type strings whose preview images were already sent to this client. + var/list/yard_previews_sent = list() + /// TRUE once the preview URL mappings went to the browser. + var/yard_mappings_sent = FALSE + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +/datum/lobby_terminal/yard_static(mob/user) + if(!yard_opened) + return null + return list("catalog" = lobby_yard_catalog()) + +/datum/lobby_terminal/yard_data(mob/user) + if(!yard_opened) + return null + return list( + "account" = yard_account, + "owned" = yard_owned, + "free_ships" = !!CONFIG_GET(flag/free_ships), + "can_requisition" = !!(SSticker?.IsRoundInProgress() && can_requisition_hull(user)), + "crew_change" = crew_change_name(user), + ) + +/// Fetches parts and ownership from the DB (sleeps) and caches them on the terminal. +/datum/lobby_terminal/proc/yard_refresh() + set waitfor = FALSE + if(yard_refreshing || !owner?.ckey) + return + yard_refreshing = TRUE + var/player_ckey = owner.ckey + var/datum/ship_economy_db/economy = GLOB.ship_economy_db + var/list/parts = economy?.get_parts(player_ckey) + var/list/hulls = get_effective_unlocked_ships(player_ckey) + var/list/theme_rows = economy?.get_unlocked_themes(player_ckey) || list() + var/list/module_rows = economy?.get_unlocked_upgrades(player_ckey) || list() + if(QDELETED(src)) + return + yard_refreshing = FALSE + yard_refreshed_at = world.time + yard_account = parts + var/list/owned_modules = lobby_yard_split_unlocks(module_rows) + // Builder Bay was merged into Engineering Bay; honour old purchases as the selector does. + var/list/pill_modules = owned_modules["[/datum/map_template/shuttle/voidcrew/pill]"] + if(pill_modules && ("pill_extra_builder" in pill_modules)) + pill_modules |= "pill_extra_engineering" + yard_owned = list( + "hulls" = hulls, + "finishes" = lobby_yard_split_unlocks(theme_rows), + "modules" = owned_modules, + ) + if(ui && !QDELETED(ui)) + SStgui.update_uis(src) + +/// "type|id" unlock rows -> list(type = list(ids)). +/proc/lobby_yard_split_unlocks(list/rows) + var/list/by_hull = list() + for(var/row in rows) + var/list/row_parts = splittext(row, "|") + if(length(row_parts) < 2) + continue + var/list/ids = by_hull[row_parts[1]] + if(!ids) + ids = list() + by_hull[row_parts[1]] = ids + ids |= row_parts[2] + return by_hull + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +/// The shelf as sent to the terminal (static data). Built once and cached globally. +/proc/lobby_yard_catalog() + var/free = !!CONFIG_GET(flag/free_ships) + if(GLOB.lobby_yard_catalog && GLOB.lobby_yard_catalog_free == free) + return GLOB.lobby_yard_catalog + ensure_ship_upgrades_initialized() + var/list/catalog = list() + for(var/datum/map_template/shuttle/voidcrew/hull as anything in get_purchasable_ship_templates()) + catalog += list(lobby_yard_hull_entry(hull)) + GLOB.lobby_yard_catalog = catalog + GLOB.lobby_yard_catalog_free = free + return catalog + +/// Part cost list with only the classes that cost something. +/proc/lobby_yard_cost(list/cost) + var/list/out = list() + for(var/part_class in cost) + var/amount = cost[part_class] + if(isnum(amount) && amount > 0) + out[part_class] = amount + return out + +/// Job definitions (theme/module job_slots format) -> list(list(title, slots)). +/proc/lobby_yard_crew_lines(list/definitions) + var/list/lines = list() + for(var/list/definition in definitions) + if(!definition["name"]) + continue + lines += list(list("[definition["name"]]", definition["slots"] || 0)) + return lines + +/// A slot key as players read it: the hull prefix dropped, words spaced, upper case. +/proc/lobby_yard_slot_label(datum/map_template/shuttle/voidcrew/hull, slot_key) + var/label = lowertext("[slot_key]") + var/list/path_parts = splittext("[hull.type]", "/") + var/leaf = path_parts[length(path_parts)] + var/underscore = findtext(leaf, "_") + var/prefix = "[underscore ? copytext(leaf, 1, underscore) : leaf]_" + if(findtext(label, prefix) == 1) + label = copytext(label, length(prefix) + 1) + label = replacetext(label, "_s_", "'s ") + label = replacetext(label, "_", " ") + return uppertext(label) + +/proc/lobby_yard_hull_entry(datum/map_template/shuttle/voidcrew/hull) + var/hull_id = "[hull.type]" + var/list/themes = get_themes_for_ship(hull.type) + var/list/modules = get_modules_for_ship(hull.type) + + var/list/slot_keys = list() + for(var/slot_key in hull.upgrade_slot_ids) + slot_keys |= slot_key + + var/list/finishes = list() + for(var/theme_id in themes) + var/datum/ship_theme/theme = themes[theme_id] + var/list/theme_slots = get_upgrade_slot_ids_for_theme(hull, theme) + for(var/slot_key in theme_slots) + slot_keys |= slot_key + finishes += list(list( + "id" = theme.id, + "name" = theme.name, + "desc" = theme.desc, + "cost" = lobby_yard_cost(theme.part_cost), + "def" = !!theme.is_default, + "slots" = theme_slots.Copy(), + "jobs" = lobby_yard_crew_lines(theme.job_slots), + )) + + var/list/slots = list() + for(var/slot_key in slot_keys) + slots += list(list("key" = slot_key, "label" = lobby_yard_slot_label(hull, slot_key))) + + var/list/module_entries = list() + for(var/module_id in modules) + var/datum/ship_upgrade_module/module = modules[module_id] + var/list/module_themes = null + if(islist(module.for_theme)) + var/list/theme_list = module.for_theme + module_themes = theme_list.Copy() + else if(module.for_theme) + module_themes = list("[module.for_theme]") + var/list/jobs = list() + jobs[""] = lobby_yard_crew_lines(module.job_slots_add) + for(var/theme_key in module.job_slots_add_by_theme) + jobs["[theme_key]"] = lobby_yard_crew_lines(module.job_slots_add_by_theme[theme_key]) + module_entries += list(list( + "id" = module.id, + "name" = module.name, + "desc" = module.desc, + "slot" = module.slot, + "cost" = lobby_yard_cost(module.part_cost), + "def" = !!module.is_default, + "themes" = module_themes, + "jobs" = jobs, + )) + + var/list/preview = build_ship_preview_data(hull) + GLOB.lobby_yard_preview_files[hull_id] = lobby_yard_preview_names(preview) + + var/list/entry = list( + "id" = hull_id, + "name" = hull.name, + "short" = hull.short_name || hull.name, + "desc" = hull.catalog_desc || generate_ship_description(hull), + "cost" = lobby_yard_cost(hull.part_requirements), + "free" = !!is_ship_free(hull), + "finishes" = finishes, + "slots" = slots, + "modules" = module_entries, + "preview" = preview, + ) + // Optional key (not in the shared types): a hull without finishes takes its crew from the template. + if(!length(themes)) + entry["jobs"] = lobby_yard_crew_lines(hull.job_slots) + return entry + +/// Every image name a hull's preview block refers to. +/proc/lobby_yard_preview_names(list/preview) + var/list/names = list() + if(!islist(preview)) + return names + var/list/hulls = preview["hulls"] + for(var/theme_id in hulls) + var/list/hull_entry = hulls[theme_id] + if(islist(hull_entry) && hull_entry["png"]) + names |= hull_entry["png"] + var/list/modules = preview["modules"] + for(var/module_id in modules) + var/list/module_entry = modules[module_id] + if(!islist(module_entry)) + continue + if(module_entry["png"]) + names |= module_entry["png"] + var/list/variants = module_entry["themes"] + for(var/theme_id in variants) + var/list/variant = variants[theme_id] + if(islist(variant) && variant["png"]) + names |= variant["png"] + return names + +/// A hull type string from the client, resolved only against the shelf. +/proc/lobby_yard_find_hull(hull_id) + if(!istext(hull_id) || !hull_id) + return null + for(var/datum/map_template/shuttle/voidcrew/hull as anything in get_purchasable_ship_templates()) + if("[hull.type]" == hull_id) + return hull + return null + +/** + * Turns a client fit into a theme and slot selections, the way the selector validates + * them. Unnamed slots take their default module. Returns list(theme, selections), or a + * string saying why the fit is refused. + */ +/proc/lobby_yard_resolve_fit(datum/map_template/shuttle/voidcrew/hull, finish_id, list/module_ids) + var/list/themes = get_themes_for_ship(hull.type) + var/datum/ship_theme/theme = null + if(length(themes)) + if(finish_id) + theme = themes["[finish_id]"] + if(!theme) + return "That finish isn't offered on this hull." + else + theme = get_default_theme_for_ship(hull.type) + var/list/modules = get_modules_for_ship(hull.type) + var/list/selections = list() + for(var/slot_key in get_upgrade_slot_ids_for_theme(hull, theme)) + var/module_id = islist(module_ids) ? module_ids[slot_key] : null + if(module_id) + var/datum/ship_upgrade_module/module = modules["[module_id]"] + if(!module || module.slot != slot_key || !is_module_available_for_theme(module, theme?.id)) + return "That fit isn't offered on this hull." + selections[slot_key] = module + continue + var/datum/ship_upgrade_module/default_module = get_default_module_for_ship_slot(hull.type, slot_key) + if(default_module && is_module_available_for_theme(default_module, theme?.id)) + selections[slot_key] = default_module + return list(theme, selections) + +// --------------------------------------------------------------------------- +// The bill +// --------------------------------------------------------------------------- + +/** + * Lines of a commission bill: every item in the fit the player doesn't own yet and that + * costs parts. Each line is list("kind" = "hull"/"finish"/"module", "id", "name", "cost"). + * Pure: the caller supplies ownership, so the math can be tested without a database. + */ +/proc/lobby_yard_bill_lines(datum/map_template/shuttle/voidcrew/hull, datum/ship_theme/theme, list/selections, hull_owned, list/owned_theme_ids, list/owned_module_ids) + var/list/lines = list() + if(!hull_owned && !is_ship_free(hull)) + var/list/hull_cost = lobby_yard_cost(hull.part_requirements) + if(length(hull_cost)) + lines += list(list("kind" = "hull", "id" = "[hull.type]", "name" = hull.name, "cost" = hull_cost)) + if(theme && !theme.is_default && !(theme.id in owned_theme_ids)) + var/list/theme_cost = lobby_yard_cost(theme.part_cost) + if(length(theme_cost)) + lines += list(list("kind" = "finish", "id" = theme.id, "name" = theme.name, "cost" = theme_cost)) + for(var/slot_key in selections) + var/datum/ship_upgrade_module/module = selections[slot_key] + if(!istype(module) || module.is_default || (module.id in owned_module_ids)) + continue + var/list/module_cost = lobby_yard_cost(module.part_cost) + if(length(module_cost)) + lines += list(list("kind" = "module", "id" = module.id, "name" = module.name, "cost" = module_cost)) + return lines + +/// Sum of a bill's lines: class -> parts. +/proc/lobby_yard_bill_total(list/lines) + var/list/total = list() + for(var/list/line in lines) + var/list/cost = line["cost"] + for(var/part_class in cost) + total[part_class] = (total[part_class] || 0) + cost[part_class] + return total + +/** + * Pays a commission bill: reads ownership and parts (sleeps on the DB), spends the total + * once, then records every unlock. Returns TRUE when paid (or nothing was owed), or a + * short line saying why not. An unlock write that fails after paying is logged and + * reported to admins; the commission still goes ahead, since the player has paid. + */ +/datum/lobby_terminal/proc/yard_settle(mob/user, player_ckey, datum/map_template/shuttle/voidcrew/hull, datum/ship_theme/theme, list/selections, datum/ship_economy_db/economy = GLOB.ship_economy_db) + if(CONFIG_GET(flag/free_ships)) + return TRUE + var/hull_id = "[hull.type]" + var/hull_owned = is_ship_free(hull) + if(!hull_owned) + if(!economy) + return "The shipyard ledger is offline." + hull_owned = economy.is_ship_unlocked(player_ckey, hull_id) + var/list/owned_themes = economy?.get_unlocked_themes_for_ship(player_ckey, hull_id) || list() + var/list/owned_modules = economy?.get_unlocked_upgrades_for_ship(player_ckey, hull_id) || list() + if(hull.type == /datum/map_template/shuttle/voidcrew/pill && ("pill_extra_builder" in owned_modules)) + owned_modules |= "pill_extra_engineering" + + var/list/lines = lobby_yard_bill_lines(hull, theme, selections, hull_owned, owned_themes, owned_modules) + if(!length(lines)) + return TRUE + if(!economy) + return "The shipyard ledger is offline." + var/list/total = lobby_yard_bill_total(lines) + var/list/parts = economy.get_parts(player_ckey) + if(!parts) + return "The shipyard ledger is offline." + for(var/part_class in total) + if((parts[part_class] || 0) < total[part_class]) + return "Not enough parts for this bill." + if(!economy.spend_parts(player_ckey, total)) + return "The payment didn't go through. Nothing was charged." + + var/list/total_text = list() + for(var/part_class in total) + total_text += "[total[part_class]] [part_class]" + log_game("SHIP_CATALOG: [player_ckey] paid a lobby bill of [total_text.Join(", ")] for [hull_id] ([length(lines)] item\s)") + + for(var/list/line in lines) + var/written = FALSE + switch(line["kind"]) + if("hull") + written = economy.unlock_ship(player_ckey, hull_id) + if(written) + log_game("SHIP_CATALOG: [player_ckey] unlocked ship [hull_id] ([hull.name])") + ship_metric_hull_unlocked(user, hull) + if("finish") + written = economy.unlock_theme(player_ckey, hull_id, line["id"]) + if(written) + log_game("SHIP_UPGRADE: [player_ckey] purchased theme '[line["id"]]' for [hull_id]") + ship_metric_unlocked("ship_theme_unlocked", user, hull.type, line["id"], line["cost"], FALSE) + if("module") + written = economy.unlock_upgrade(player_ckey, hull_id, line["id"]) + if(written) + log_game("SHIP_UPGRADE: [player_ckey] purchased upgrade '[line["id"]]' for [hull_id]") + ship_metric_unlocked("ship_module_unlocked", user, hull.type, line["id"], line["cost"], FALSE) + if(!written) + log_game("SHIP_CATALOG ERROR: [player_ckey] paid for [line["kind"]] [line["id"]] on [hull_id] but the unlock was not recorded") + message_admins("Ship unlock failed for [player_ckey]: paid for [line["kind"]] [line["id"]] on [hull_id], but it was not recorded. Check the game log.") + return TRUE + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +/datum/lobby_terminal/yard_act(action, list/params, mob/user) + var/mob/dead/new_player/player = user + if(!istype(player)) + return FALSE + switch(action) + if("yard_open") + if(!yard_opened) + yard_opened = TRUE + if(ui && !QDELETED(ui)) + update_static_data(player, ui) + yard_refresh() + return TRUE + if("yard_preview") + yard_send_preview(params["hull"]) + return FALSE + if("yard_commission") + return yard_commission(player, params) + if("yard_requisition") + return yard_requisition(player, params) + if("yard_load") + return yard_load(player, params) + return FALSE + +/// Sends one hull's preview images to this client, once. +/datum/lobby_terminal/proc/yard_send_preview(hull_id) + if(!yard_opened || !owner || !istext(hull_id) || (hull_id in yard_previews_sent)) + return + lobby_yard_catalog() // fills the preview file index + var/list/names = GLOB.lobby_yard_preview_files[hull_id] + if(isnull(names)) // not on the shelf + return + yard_previews_sent += hull_id + if(!length(names)) + return + var/datum/asset/previews = get_asset_datum(/datum/asset/simple/ship_previews) + SSassets.transport.send_assets(owner, names) + if(!yard_mappings_sent && window) + yard_mappings_sent = TRUE + window.send_raw_message(previews.get_serialized_url_mappings()) + +/** + * Why a join route can't start right now, or null. Same gates as the Join key: round + * running, admin late-join switch, popcap queue. + */ +/datum/lobby_terminal/proc/yard_route_denial(mob/dead/new_player/player) + if(!SSticker?.IsRoundInProgress()) + return "The port is not open yet." + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + return "No berths are being assigned right now." + if(ckey(player.key) in GLOB.admin_datums) + return null + var/relevant_cap + var/hard_popcap = CONFIG_GET(number/hard_popcap) + var/extreme_popcap = CONFIG_GET(number/extreme_popcap) + if(hard_popcap && extreme_popcap) + relevant_cap = min(hard_popcap, extreme_popcap) + else + relevant_cap = max(hard_popcap, extreme_popcap) + var/full = relevant_cap && living_player_count() >= relevant_cap + if(SSticker.queued_players.len) + if(full || player != SSticker.queued_players[1]) + return "Port is full. Wait for your turn in line." + else if(full) + return "Port is full. Press JOIN to get in line." + return null + +/** + * Takes the shared join lock, dropping one left over from a runtime first. FALSE when + * another route is running for this player. + */ +/datum/lobby_terminal/proc/yard_take_lock(mob/dead/new_player/player) + var/locked_at = GLOB.lobby_join_locks[player.ckey] + if(locked_at && world.time - locked_at > LOBBY_YARD_STALE_LOCK && !player.spawning && !player.spawning_ship) + release_join_lock(player.ckey) + return try_join_lock(player) + +/** + * The crew-leave answer for a founding route. Returns TRUE (found a new crew and leave the + * old one), FALSE (nothing to leave), or null after asking the client to confirm. + */ +/datum/lobby_terminal/proc/yard_form_new(mob/dead/new_player/player, list/params) + var/crew_name = crew_change_name(player) + if(!crew_name) + return FALSE + if(text2num("[params["leave_crew"]]") == 1) + return TRUE + set_notice("This leaves [crew_name].", "warn") + return null + +/datum/lobby_terminal/proc/yard_commission(mob/dead/new_player/player, list/params) + var/denial = yard_route_denial(player) + if(denial) + set_notice(denial, "warn") + return TRUE + var/datum/map_template/shuttle/voidcrew/hull = lobby_yard_find_hull(params["hull"]) + if(!hull) + set_notice("That hull isn't on the shelf.", "bad") + return TRUE + var/fit = lobby_yard_resolve_fit(hull, params["finish"], params["modules"]) + if(!islist(fit)) + set_notice(fit, "bad") + return TRUE + var/list/fit_list = fit + var/form_new = yard_form_new(player, params) + if(isnull(form_new)) + return TRUE + if(!yard_take_lock(player)) + set_notice("Your ship is already being prepared.", "warn") + return TRUE + set_notice("Preparing your [hull.name].", "ok") + INVOKE_ASYNC(src, PROC_REF(run_commission), player, player.ckey, hull, fit_list[1], fit_list[2], form_new) + return TRUE + +/** + * Pays the bill and launches. The caller holds the join lock; this releases it on every + * path. Sleeps on the DB and on the ship load. + */ +/datum/lobby_terminal/proc/run_commission(mob/dead/new_player/player, player_ckey, datum/map_template/shuttle/voidcrew/hull, datum/ship_theme/theme, list/selections, form_new, datum/ship_economy_db/economy = GLOB.ship_economy_db) + var/paid = yard_settle(player, player_ckey, hull, theme, selections, economy) + if(QDELETED(src)) + release_join_lock(player_ckey) + return + yard_refresh_after_purchase() + if(paid != TRUE) + release_join_lock(player_ckey) + set_notice(paid, "bad") + return + // The player may have left or spawned while the DB answered. Their unlocks are kept. + if(QDELETED(player) || !player.client || player.client != owner) + release_join_lock(player_ckey) + return + // The crew they agreed to leave may have changed while the DB answered. + var/crew_name = crew_change_name(player) + if(!crew_name) + form_new = FALSE + else if(!form_new) + release_join_lock(player_ckey) + set_notice("This leaves [crew_name].", "warn") + return + log_game("[key_name(player)] commissioned [hull.name] ([hull.type]) from the dock terminal[theme ? " in [theme.id]" : ""]") + player.spawn_ship_with_upgrades(hull, selections, theme, form_new) + release_join_lock(player_ckey) + yard_report_failed_launch(player) + +/// Re-reads ownership after something may have been bought. +/datum/lobby_terminal/proc/yard_refresh_after_purchase() + if(yard_opened) + yard_refresh() + +/// After a launch call returns: if the player is still in the lobby, say why. +/datum/lobby_terminal/proc/yard_report_failed_launch(mob/dead/new_player/player) + if(QDELETED(src) || QDELETED(player) || !player.client || !istype(player.client.mob, /mob/dead/new_player)) + return + goto_tab("shipyard") + if(player.spawning_ship) + set_notice("Your ship is ready, but no berth is free aboard.", "bad") + else if(SSmapping.at_z_level_ceiling()) + set_notice("The shipyard is congested. What you paid for stays yours; try again shortly.", "bad") + else + set_notice("The shipyard could not prepare your ship. What you paid for stays yours.", "bad") + +/datum/lobby_terminal/proc/yard_requisition(mob/dead/new_player/player, list/params) + var/denial = yard_route_denial(player) + if(denial) + set_notice(denial, "warn") + return TRUE + if(!can_requisition_hull(player)) + set_notice("A berth has opened aboard the fleet.", "warn") + return TRUE + var/form_new = yard_form_new(player, params) + if(isnull(form_new)) + return TRUE + if(!yard_take_lock(player)) + set_notice("Your ship is already being prepared.", "warn") + return TRUE + set_notice("Requisitioning a hull.", "ok") + INVOKE_ASYNC(src, PROC_REF(run_requisition), player, player.ckey, form_new) + return TRUE + +/// requisition_free_hull() with the terminal's crew answer. Releases the join lock. +/datum/lobby_terminal/proc/run_requisition(mob/dead/new_player/player, player_ckey, form_new) + if(QDELETED(player)) + release_join_lock(player_ckey) + return + player.lobby_form_new = form_new + player.requisition_free_hull() + if(!QDELETED(player)) + player.lobby_form_new = null + release_join_lock(player_ckey) + yard_report_failed_launch(player) + +/datum/lobby_terminal/proc/yard_load(mob/dead/new_player/player, list/params) + var/denial = yard_route_denial(player) + if(denial) + set_notice(denial, "warn") + return TRUE + var/slot = text2num("[params["slot"]]") + if(!isnum(slot) || slot < 1 || slot != round(slot)) + return FALSE + var/form_new = yard_form_new(player, params) + if(isnull(form_new)) + return TRUE + if(!yard_take_lock(player)) + set_notice("Your ship is already being prepared.", "warn") + return TRUE + INVOKE_ASYNC(src, PROC_REF(run_load), player.ckey, slot, form_new) + return TRUE + +/// The ship-saves hook under the join lock. Releases it. +/datum/lobby_terminal/proc/run_load(player_ckey, slot, form_new) + load_saved_ship(slot, form_new) + release_join_lock(player_ckey) + +#undef LOBBY_YARD_REFRESH_INTERVAL +#undef LOBBY_YARD_STALE_LOCK diff --git a/voidcrew/modules/ship_purchase/ship_economy_database.dm b/voidcrew/modules/ship_purchase/ship_economy_database.dm index 8db629a1524..7bf896d6ce9 100644 --- a/voidcrew/modules/ship_purchase/ship_economy_database.dm +++ b/voidcrew/modules/ship_purchase/ship_economy_database.dm @@ -242,7 +242,7 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new) ckey = ckey(ckey) // Normalize ckey - if(!SSdbcore.IsConnected()) + if(!is_available()) return FALSE // First, check if player has sufficient parts @@ -258,23 +258,18 @@ 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. Each class is its own row, so this is not one transaction: when a + // later class fails, the classes already taken are put back (a single lobby bill + // often spans several classes). + var/list/deducted = list() for(var/part_class in requirements) var/amount = requirements[part_class] - - var/datum/db_query/query = SSdbcore.NewQuery( - "UPDATE [format_table_name("player_ship_parts")] \ - SET quantity = quantity - :amount \ - WHERE ckey = :ckey AND part_class = :part_class AND quantity >= :amount", - list("ckey" = ckey, "part_class" = part_class, "amount" = amount) - ) - - if(!query.Execute() || query.affected < 1) - qdel(query) + if(!deduct_part_class(ckey, part_class, amount)) 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 +282,30 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new) return TRUE +/// Whether the database can take writes. A proc so tests can stand in for it. +/datum/ship_economy_db/proc/is_available() + return SSdbcore.IsConnected() + +/// Takes amount parts of one class, only if the player has them. TRUE on success. +/datum/ship_economy_db/proc/deduct_part_class(ckey, part_class, amount) + var/datum/db_query/query = SSdbcore.NewQuery( + "UPDATE [format_table_name("player_ship_parts")] SET quantity = quantity - :amount WHERE ckey = :ckey AND part_class = :part_class AND quantity >= :amount", + list("ckey" = ckey, "part_class" = part_class, "amount" = amount) + ) + var/success = query.Execute() && query.affected >= 1 + qdel(query) + return success + +/// Puts back parts a failed spend_parts() already took: class -> amount. +/datum/ship_economy_db/proc/refund_parts(ckey, list/taken) + for(var/part_class in taken) + var/amount = taken[part_class] + if(add_part(ckey, part_class, amount, "refund")) + log_game("SHIP_ECONOMY: [ckey] refunded [amount] [part_class] parts after a failed spend") + continue + log_game("SHIP_ECONOMY ERROR: [ckey] lost [amount] [part_class] parts: the refund after a failed spend did not go through") + message_admins("Ship parts refund failed for [ckey]: [amount] [part_class] parts were taken but not returned. Check the game log.") + /** * Check if a ship template is unlocked for a player * diff --git a/voidcrew/modules/unit_tests/lobby_terminal_bill.dm b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm index 66b68877287..0059d9b1a89 100644 --- a/voidcrew/modules/unit_tests/lobby_terminal_bill.dm +++ b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm @@ -1 +1,200 @@ -// Lobby terminal bill test (package P3). Placeholder from the shell package; the owner replaces this file. +/** + * Dock terminal shipyard bill (voidcrew/modules/lobby_terminal/lobby_yard.dm). + * + * Covers the single commission bill: which lines it charges, the total, the refund when a + * multi-class spend fails halfway, the join lock being released on every path, and + * FREE_SHIPS charging nothing. A stand-in ledger replaces the database. + */ + +/// Ledger stand-in: balances in memory, one class can be made to fail. +/datum/ship_economy_db/lobby_bill_test + var/list/balance = list() + var/fail_class + var/list/refunded = list() + var/list/spent_calls = list() + var/list/unlocked = list() + +/datum/ship_economy_db/lobby_bill_test/is_available() + return TRUE + +/datum/ship_economy_db/lobby_bill_test/get_parts(ckey) + var/list/parts = list() + for(var/part_class in GLOB.ship_part_classes) + parts[part_class] = balance[part_class] || 0 + return parts + +/datum/ship_economy_db/lobby_bill_test/deduct_part_class(ckey, part_class, amount) + if(part_class == fail_class || (balance[part_class] || 0) < amount) + return FALSE + balance[part_class] -= amount + return TRUE + +/datum/ship_economy_db/lobby_bill_test/add_part(ckey, part_class, quantity = 1, method = "unspecified") + balance[part_class] = (balance[part_class] || 0) + quantity + if(method == "refund") + refunded[part_class] = (refunded[part_class] || 0) + quantity + return TRUE + +/datum/ship_economy_db/lobby_bill_test/spend_parts(ckey, list/requirements) + spent_calls += list(requirements.Copy()) + return ..() + +/datum/ship_economy_db/lobby_bill_test/is_ship_unlocked(ckey, ship_template) + return ("hull|[ship_template]" in unlocked) + +/datum/ship_economy_db/lobby_bill_test/unlock_ship(ckey, ship_template) + unlocked += "hull|[ship_template]" + return TRUE + +/datum/ship_economy_db/lobby_bill_test/get_unlocked_themes_for_ship(ckey, ship_template) + var/list/ids = list() + for(var/entry in unlocked) + var/list/entry_parts = splittext(entry, "|") + if(entry_parts[1] == "finish" && entry_parts[2] == "[ship_template]") + ids += entry_parts[3] + return ids + +/datum/ship_economy_db/lobby_bill_test/unlock_theme(ckey, ship_template, theme_id) + unlocked += "finish|[ship_template]|[theme_id]" + return TRUE + +/datum/ship_economy_db/lobby_bill_test/get_unlocked_upgrades_for_ship(ckey, ship_template) + var/list/ids = list() + for(var/entry in unlocked) + var/list/entry_parts = splittext(entry, "|") + if(entry_parts[1] == "module" && entry_parts[2] == "[ship_template]") + ids += entry_parts[3] + return ids + +/datum/ship_economy_db/lobby_bill_test/unlock_upgrade(ckey, ship_template, upgrade_id) + unlocked += "module|[ship_template]|[upgrade_id]" + return TRUE + +/// A terminal with no client or browser window, for calling the bill procs directly. +/datum/lobby_terminal/bill_test + +/datum/lobby_terminal/bill_test/New() + return // No client: skip the window and signal setup. + +/datum/unit_test/lobby_terminal_bill + var/datum/map_template/shuttle/voidcrew/hull + var/datum/ship_theme/finish + var/datum/ship_upgrade_module/paid_module + var/datum/ship_upgrade_module/stock_module + var/datum/ship_upgrade_module/owned_module + var/list/selections + /// FREE_SHIPS before the test, restored in Destroy(). + var/was_free_ships + +/datum/unit_test/lobby_terminal_bill/Destroy() + CONFIG_SET(flag/free_ships, was_free_ships) + return ..() + +/datum/unit_test/lobby_terminal_bill/Run() + was_free_ships = CONFIG_GET(flag/free_ships) + CONFIG_SET(flag/free_ships, FALSE) + for(var/datum/map_template/shuttle/voidcrew/candidate as anything in get_purchasable_ship_templates()) + if(length(lobby_yard_cost(candidate.part_requirements))) + hull = candidate + break + TEST_ASSERT(hull, "No hull on the shelf costs parts") + + finish = new + finish.id = "lobby_bill_finish" + finish.name = "Test Finish" + finish.part_cost = list("science" = 2) + + paid_module = new + paid_module.id = "lobby_bill_paid" + paid_module.slot = "slot_a" + paid_module.part_cost = list("combat" = 3, "science" = 1) + + stock_module = new + stock_module.id = "lobby_bill_stock" + stock_module.slot = "slot_b" + stock_module.is_default = TRUE + stock_module.part_cost = list("combat" = 50) + + owned_module = new + owned_module.id = "lobby_bill_owned" + owned_module.slot = "slot_c" + owned_module.part_cost = list("trade" = 4) + + selections = list("slot_a" = paid_module, "slot_b" = stock_module, "slot_c" = owned_module) + + check_bill_lines() + check_partial_spend_refund() + check_commission_paths() + check_free_ships() + +/datum/unit_test/lobby_terminal_bill/proc/check_bill_lines() + var/list/lines = lobby_yard_bill_lines(hull, finish, selections, FALSE, list(), list("lobby_bill_owned")) + TEST_ASSERT_EQUAL(length(lines), 3, "An unowned hull, finish and paid module should make three lines; stock and owned items are free") + var/list/expected = lobby_yard_cost(hull.part_requirements) + expected["science"] = (expected["science"] || 0) + 3 + expected["combat"] = (expected["combat"] || 0) + 3 + var/list/total = lobby_yard_bill_total(lines) + for(var/part_class in GLOB.ship_part_classes) + TEST_ASSERT_EQUAL(total[part_class] || 0, expected[part_class] || 0, "Bill total for [part_class] is wrong") + + lines = lobby_yard_bill_lines(hull, finish, selections, TRUE, list("lobby_bill_finish"), list("lobby_bill_owned")) + TEST_ASSERT_EQUAL(length(lines), 1, "Owned hull and finish still billed") + TEST_ASSERT_EQUAL(lines[1]["id"], "lobby_bill_paid", "The remaining line should be the paid module") + + finish.is_default = TRUE + lines = lobby_yard_bill_lines(hull, finish, list(), TRUE, list(), list()) + TEST_ASSERT_EQUAL(length(lines), 0, "A stock finish was billed") + finish.is_default = FALSE + +/datum/unit_test/lobby_terminal_bill/proc/check_partial_spend_refund() + var/datum/ship_economy_db/lobby_bill_test/ledger = new + ledger.balance = list("combat" = 10, "science" = 10) + ledger.fail_class = "science" + TEST_ASSERT(!ledger.spend_parts("lobbybilltest", list("combat" = 3, "science" = 2)), "A spend with a failing class went through") + TEST_ASSERT_EQUAL(ledger.refunded["combat"], 3, "The class taken before the failure was not refunded") + TEST_ASSERT_EQUAL(ledger.balance["combat"], 10, "Combat parts were lost on a failed spend") + TEST_ASSERT_EQUAL(ledger.balance["science"], 10, "Science parts changed on a failed spend") + +/datum/unit_test/lobby_terminal_bill/proc/check_commission_paths() + var/test_ckey = "lobbybilltest" + var/datum/lobby_terminal/bill_test/terminal = new + var/datum/ship_economy_db/lobby_bill_test/ledger = new + var/list/total = lobby_yard_bill_total(lobby_yard_bill_lines(hull, finish, selections, FALSE, list(), list())) + + // Short on parts: nothing spent, lock released, a notice. + GLOB.lobby_join_locks[test_ckey] = world.time + terminal.run_commission(null, test_ckey, hull, finish, selections, FALSE, ledger) + TEST_ASSERT(!GLOB.lobby_join_locks[test_ckey], "A refused bill kept the join lock") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 0, "A bill the player can't afford was charged") + TEST_ASSERT(terminal.notice, "A refused bill gave no notice") + + // Enough parts: one spend for the whole bill, every line unlocked, lock released. + for(var/part_class in total) + ledger.balance[part_class] = total[part_class] + 1 + GLOB.lobby_join_locks[test_ckey] = world.time + terminal.run_commission(null, test_ckey, hull, finish, selections, FALSE, ledger) + TEST_ASSERT(!GLOB.lobby_join_locks[test_ckey], "A paid bill kept the join lock") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 1, "The bill was not charged once") + for(var/part_class in total) + TEST_ASSERT_EQUAL(ledger.balance[part_class], 1, "Wrong [part_class] charge") + TEST_ASSERT(("hull|[hull.type]" in ledger.unlocked), "The hull was not unlocked") + TEST_ASSERT(("finish|[hull.type]|lobby_bill_finish" in ledger.unlocked), "The finish was not unlocked") + TEST_ASSERT(("module|[hull.type]|lobby_bill_paid" in ledger.unlocked), "The module was not unlocked") + TEST_ASSERT(!("module|[hull.type]|lobby_bill_stock" in ledger.unlocked), "A stock module was recorded as bought") + + // Everything owned now: a second commission costs nothing. + GLOB.lobby_join_locks[test_ckey] = world.time + terminal.run_commission(null, test_ckey, hull, finish, selections, FALSE, ledger) + TEST_ASSERT(!GLOB.lobby_join_locks[test_ckey], "A free relaunch kept the join lock") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 1, "Owned items were charged again") + qdel(terminal) + +/datum/unit_test/lobby_terminal_bill/proc/check_free_ships() + var/datum/lobby_terminal/bill_test/terminal = new + var/datum/ship_economy_db/lobby_bill_test/ledger = new + CONFIG_SET(flag/free_ships, TRUE) + var/result = terminal.yard_settle(null, "lobbybilltest", hull, finish, selections, ledger) + CONFIG_SET(flag/free_ships, FALSE) + TEST_ASSERT_EQUAL(result, TRUE, "FREE_SHIPS refused a commission") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 0, "FREE_SHIPS charged parts") + qdel(terminal) From 32d2b87b3bb0287f9dc6a79762a365c5664f3953 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:24:28 -0800 Subject: [PATCH 55/72] Tell a requisitioning player when a berth opened instead of a load failure --- voidcrew/modules/lobby_terminal/lobby_yard.dm | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/voidcrew/modules/lobby_terminal/lobby_yard.dm b/voidcrew/modules/lobby_terminal/lobby_yard.dm index 5a2c7989305..c3865e3d3af 100644 --- a/voidcrew/modules/lobby_terminal/lobby_yard.dm +++ b/voidcrew/modules/lobby_terminal/lobby_yard.dm @@ -582,6 +582,11 @@ GLOBAL_LIST_EMPTY(lobby_yard_preview_files) if(!QDELETED(player)) player.lobby_form_new = null release_join_lock(player_ckey) + // The fleet opened a berth while the request ran: requisition_free_hull() refused it. + if(!QDELETED(src) && !QDELETED(player) && player.client && istype(player.client.mob, /mob/dead/new_player) && !player.spawning_ship && !can_requisition_hull(player)) + goto_tab("crews") + set_notice("A berth has opened aboard the fleet.", "warn") + return yard_report_failed_launch(player) /datum/lobby_terminal/proc/yard_load(mob/dead/new_player/player, list/params) From acc8c6ef76445e438bed398fa2cbc7f865b15100 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:31:13 -0800 Subject: [PATCH 56/72] Draw the lobby terminal's small buttons from lobby sprite PNGs The batched spritesheet failed for the whole round when the first player reached the lobby before the asset subsystem had run. The five buttons now go out as PNGs cut from bottom_buttons.dmi in the terminal's own asset. --- .../interfaces/LobbyTerminal/Bezel.tsx | 21 +++++++++++++++---- .../modules/lobby_terminal/lobby_assets.dm | 19 +++++++---------- voidcrew/modules/lobby_terminal/lobby_tgui.dm | 5 +---- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx index 76795c08df6..e531afb55ce 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx @@ -8,6 +8,7 @@ * Without the art (static.bezel null) the same tree is drawn by PLACEHOLDER_CSS. */ import { type ReactNode, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; import { useBackend } from 'tgui/backend'; import type { LobbyData } from './types'; @@ -65,10 +66,22 @@ function SmallButton(props: { onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} > - - {!!mark && ( - - )} + + {mark ? ( + + ) : null} ); } diff --git a/voidcrew/modules/lobby_terminal/lobby_assets.dm b/voidcrew/modules/lobby_terminal/lobby_assets.dm index fc30d5acdfe..64fb6338b0a 100644 --- a/voidcrew/modules/lobby_terminal/lobby_assets.dm +++ b/voidcrew/modules/lobby_terminal/lobby_assets.dm @@ -2,8 +2,8 @@ * Terminal art and font. Runtime string paths, not 'compiled' resources, so the art stays * out of the compiled RSC and the external RSC zip and arrives with the page. * - * Only files that exist are registered: the bezel art (html/, drawn separately) can be - * missing, and then the client draws its placeholder bezel. + * Only art files that exist are registered: the bezel art (html/) can be missing, and then + * the client draws its placeholder bezel. */ /datum/asset/simple/namespaced/lobby_terminal @@ -19,17 +19,12 @@ for(var/name in art) if(fexists("[art_dir][name]")) assets[name] = "[art_dir][name]" + // The bezel's small buttons, cut from the real lobby sprites so a sprite change reaches the terminal too. + for(var/button in list("wiki", "poll", "crew_manifest", "settings", "changelog")) + for(var/suffix in list("", "_highlighted", "_disabled")) + assets["lt_[button][suffix].png"] = icon('icons/hud/lobby/bottom_buttons.dmi', "[button][suffix]", frame = 1) + assets["lt_new_poll.png"] = icon('icons/hud/lobby/poll_overlay.dmi', "new_poll") parents = list() if(fexists("[art_dir]lobby_terminal.css")) parents["lobby_terminal.css"] = "[art_dir]lobby_terminal.css" return ..() - -/// The bezel's small buttons, from the real lobby sprites so a sprite change reaches the terminal too. -/datum/asset/spritesheet_batched/lobby_terminal_buttons - name = "lobbyterminal" - -/datum/asset/spritesheet_batched/lobby_terminal_buttons/create_spritesheets() - for(var/button in list("wiki", "poll", "crew_manifest", "settings", "changelog")) - for(var/suffix in list("", "_highlighted", "_disabled")) - insert_icon("lt-[button][suffix]", uni_icon('icons/hud/lobby/bottom_buttons.dmi', "[button][suffix]")) - insert_icon("lt-new_poll", uni_icon('icons/hud/lobby/poll_overlay.dmi', "new_poll")) diff --git a/voidcrew/modules/lobby_terminal/lobby_tgui.dm b/voidcrew/modules/lobby_terminal/lobby_tgui.dm index b735b8be203..f41718c57c9 100644 --- a/voidcrew/modules/lobby_terminal/lobby_tgui.dm +++ b/voidcrew/modules/lobby_terminal/lobby_tgui.dm @@ -72,10 +72,7 @@ GLOBAL_DATUM_INIT(lobby_terminal_state, /datum/ui_state/lobby_terminal, new) hide_control() /datum/lobby_terminal/ui_assets(mob/user) - return list( - get_asset_datum(/datum/asset/simple/namespaced/lobby_terminal), - get_asset_datum(/datum/asset/spritesheet_batched/lobby_terminal_buttons), - ) + return list(get_asset_datum(/datum/asset/simple/namespaced/lobby_terminal)) /datum/lobby_terminal/ui_static_data(mob/user) var/list/data = list() From ba5e2a26daee640e677489c092b895bdb7d3d03d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:31:43 -0800 Subject: [PATCH 57/72] Build the dock terminal shipyard screens SHIPYARD home with new ship, requisition and saved ship cards drawn from their floor plans; the hull registry and record; FIT with the live composited yard preview on a viewscreen and the slot being edited outlined; and the single BILL with commission. Leaving a crew shows a LEAVES line and turns the key into CONFIRM. Narrow windows get one panel at a time. Hull.jobs carries crew for hulls without finishes. --- .../interfaces/LobbyTerminal/Bill.tsx | 227 +++++++ .../interfaces/LobbyTerminal/Fit.tsx | 400 +++++++++++ .../interfaces/LobbyTerminal/HullRecord.tsx | 127 ++++ .../interfaces/LobbyTerminal/Hulls.tsx | 150 +++++ .../interfaces/LobbyTerminal/PlanPic.tsx | 153 +++++ .../interfaces/LobbyTerminal/SaveSlots.tsx | 337 ++++++++++ .../interfaces/LobbyTerminal/ShipPic.tsx | 152 +++++ .../interfaces/LobbyTerminal/YardTab.tsx | 633 +++++++++++++++++- .../interfaces/LobbyTerminal/types.ts | 2 + 9 files changed, 2169 insertions(+), 12 deletions(-) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx new file mode 100644 index 00000000000..1bbfd0ef365 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx @@ -0,0 +1,227 @@ +/** + * BILL: the fitted ship and its crew on the left, the one bill on the right (each line, + * total, parts on account, any shortfall) and [ COMMISSION ]. When commissioning leaves a + * crew, one amber LEAVES line shows and the key reads CONFIRM after the first press. + * Narrow: the bill alone. + * + * The client sends the configuration, never prices; the server works out the bill again. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { Frame, lpad, Put, pad, RPut, Rule, TOP, useGrid } from './term'; +import type { Hull, LobbyActions } from './types'; +import { + billOf, + costOf, + crewOf, + fitOf, + Key, + PART_ORDER, + partsOf, + usePreview, + type YardCtx, +} from './YardTab'; + +export function Bill(props: { + ctx: YardCtx; + hull: Hull; + onCommission: (params: LobbyActions['yard_commission']) => void; +}) { + const { ctx, hull, onCommission } = props; + const { view, set, own } = ctx; + const { cols, rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = fitOf(hull, view.finish, view.picks); + const bill = billOf(own, fit, ctx.account); + const crew = crewOf(fit); + const crewN = crew.reduce((a, l) => a + l[1], 0); + const out: ReactNode[] = []; + + const lw = narrow ? 0 : Math.floor(cols * 0.5); + if (!narrow) { + out.push( + , + ); + const ph = Math.floor(h * 0.55); + out.push( + , + ); + let y = TOP + ph + 1; + out.push(); + y++; + out.push( + + CREW + , + + {String(crewN)} + , + ); + y += 2; + const colW = Math.floor((lw - 6) / 2); + const per = Math.max(1, Math.ceil(crew.length / 2)); + crew.forEach(([job, n], i) => { + const cx = 3 + Math.floor(i / per) * colW; + const cy = y + (i % per); + if (cy >= TOP + h - 1) { + return; + } + out.push( + + {pad(job, colW - 5)} + , + + {lpad(n, 2)} + , + ); + }); + } + + const x = narrow ? 0 : lw + 1; + const w = cols - x; + out.push(); + let y = TOP + 2; + const by = TOP + h - 3; + const labelW = Math.min(14, Math.max(8, Math.floor(w * 0.25))); + const nameX = x + 3 + labelW + 1; + // Leave the bottom of the box to the total, account, warnings and keys. + const lineEnd = by - 7; + bill.lines.forEach(([label, name, cost], i) => { + if (y >= lineEnd) { + return; + } + const costText = cost ? costOf(cost) || '-' : '-'; + const nameRoom = x + w - 3 - nameX; + // A long price drops to its own row under the name. + const own = nameRoom - (costText.length + 2) < 10; + out.push( + + {pad(label, labelW)} + , + + {pad( + name, + own ? nameRoom : nameRoom - Math.max(9, costText.length + 2), + )} + , + ); + if (own) { + y++; + } + out.push( + + {costText.slice(-(w - 6))} + , + ); + y++; + }); + y++; + out.push(); + y += 2; + const short = Object.keys(bill.short).length > 0; + const due = partsOf(bill.total); + out.push( + + TOTAL + , + + {costOf(bill.total) || '-'} + , + ); + y++; + const accountText = PART_ORDER.map( + (k) => + `${ctx.account?.[k] || 0} ${narrow ? k.slice(0, 3).toUpperCase() : k.toUpperCase()}`, + ).join(' '); + out.push( + + {narrow ? 'HELD' : 'ON ACCOUNT'} + , + + {accountText} + , + ); + + const confirming = view.confirm === 'commission'; + const blocked = short || !ctx.open; + if (short) { + out.push( + + SHORT + , + + {costOf(bill.short)} + , + ); + } else if (!ctx.open) { + out.push( + + THE YARD OPENS AT DEPARTURE + , + ); + } else if (ctx.crewChange) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, w - 6)} + , + ); + } + const commission = () => { + if (ctx.crewChange && !confirming) { + set({ confirm: 'commission' }); + return; + } + const modules: Record = {}; + for (const s of fit.slots) { + if (s.pick) { + modules[s.key] = s.pick.id; + } + } + onCommission({ + hull: hull.id, + finish: fit.finish?.id || '', + modules, + leave_crew: ctx.crewChange ? 1 : 0, + }); + }; + out.push( + , + set({ screen: 'fit' })} + />, + ); + + return <>{out}; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx new file mode 100644 index 00000000000..9ab6f781c14 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx @@ -0,0 +1,400 @@ +/** + * FIT: the finish, then each module slot. Wide: the step list with the running total and + * crew on the left, the viewscreen and this step's options on the right. Narrow: the + * viewscreen above the options, PREV/NEXT between steps. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { + Frame, + Hit, + lpad, + Put, + pad, + RPut, + Rule, + TOP, + useGrid, + wrap, +} from './term'; +import type { CrewLine, Finish, Hull, Module } from './types'; +import { + billOf, + costOf, + crewOf, + finishOwned, + fitOf, + Key, + moduleOwned, + partsOf, + type ShipFit, + usePreview, + type YardCtx, +} from './YardTab'; + +type Step = { + label: string; + /** Finish step, or the slot key. */ + slot: string | null; + opts: (Finish | Module)[]; + pick: number; +}; + +function jobsText(jobs: CrewLine[]): string { + return jobs.map(([t, n]) => (n > 1 ? `${t} x${n}` : t)).join(', '); +} + +function stepsOf(fit: ShipFit): Step[] { + const steps: Step[] = []; + const { hull, finish } = fit; + if (hull.finishes.length) { + steps.push({ + label: 'FINISH', + slot: null, + opts: hull.finishes, + pick: Math.max( + 0, + hull.finishes.findIndex((f) => f.id === finish?.id), + ), + }); + } + for (const s of fit.slots) { + steps.push({ + label: s.label, + slot: s.key, + opts: s.options, + pick: Math.max( + 0, + s.options.findIndex((m) => m.id === s.pick?.id), + ), + }); + } + return steps; +} + +export function Fit(props: { ctx: YardCtx; hull: Hull }) { + const { ctx, hull } = props; + const { view, set, own } = ctx; + const { cols, rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = fitOf(hull, view.finish, view.picks); + const steps = stepsOf(fit); + if (!steps.length) { + return null; + } + const st = Math.min(steps.length - 1, Math.max(0, view.step)); + const S = steps[st]; + const bill = billOf(own, fit, ctx.account); + const crew = crewOf(fit); + const crewN = crew.reduce((a, l) => a + l[1], 0); + const out: ReactNode[] = []; + + const ownedOpt = (o: Finish | Module) => + S.slot === null + ? finishOwned(own, hull, o as Finish) + : moduleOwned(own, hull, o as Module); + const choose = (j: number) => { + const o = S.opts[j]; + if (S.slot === null) { + set({ finish: o.id }); + } else { + set({ picks: { ...view.picks, [S.slot]: o.id } }); + } + }; + const last = st === steps.length - 1; + const next = () => (last ? set({ screen: 'bill' }) : set({ step: st + 1 })); + + const x = narrow + ? 0 + : Math.min(48, Math.max(34, Math.floor(cols * 0.36))) + 1; + const w = cols - x; + const lw = x - 1; + + // The viewscreen takes what the busiest step's options leave, so it doesn't jump. + const descRows = (o: Finish | Module) => + Math.min(2, wrap(o.desc, w - 6).length); + const need = + Math.max( + ...steps.map((s) => { + let n = s.opts.length + Math.max(0, ...s.opts.map(descRows)); + if (s.slot === null) { + n += Math.max( + 0, + ...s.opts.map( + (o) => + Math.min(2, wrap(jobsText((o as Finish).jobs), w - 12).length) + + 1, + ), + ); + } + return n; + }), + ) + (narrow ? 7 : 6); + const ph = Math.max(narrow ? 6 : 9, Math.min(Math.floor(h * 0.62), h - need)); + + if (!narrow) { + // Step list, total and crew. + out.push( + , + ); + // Two rows a step when there is room, one when not. + const pitch = TOP + 2 + steps.length * 2 + 8 <= TOP + h ? 2 : 1; + let y = TOP + 2; + steps.forEach((s, i) => { + const o = s.opts[s.pick]; + const cur = i === st; + const paid = + !!o && + !(s.slot === null + ? finishOwned(own, hull, o as Finish) + : moduleOwned(own, hull, o as Module)); + if (cur) { + out.push( + + {' '.repeat(lw - 3)} + , + ); + } + out.push( + + {cur ? '>' : ' '} + , + + {pad(s.label, 14)} + , + + {pad(o ? o.name : '-', lw - 21)} + , + set({ step: i })} + />, + ); + y += pitch; + }); + if (pitch === 1) { + y++; + } + out.push(); + const tot = costOf(bill.total) || 'NOTHING DUE'; + const totLines = wrap(tot, lw - 13); + const short = Object.keys(bill.short).length > 0; + out.push( + + TOTAL + , + ); + totLines.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + let cy = y + 3 + Math.max(0, totLines.length - 1); + const stop = TOP + h - 4; + if (cy + 2 < stop) { + out.push( + + CREW + , + + {String(crewN)} + , + ); + cy += 2; + if (cy + crew.length <= stop) { + crew.forEach(([job, n], i) => { + out.push( + + {pad(job, lw - 10)} + , + + {lpad(n, 2)} + , + ); + }); + } + } + out.push( + set({ screen: 'bill' })} + />, + ); + } + + // Viewscreen. + out.push( + , + , + ); + + // This step's options. + const oy = TOP + ph; + const oh = h - ph; + const title = narrow ? `${S.label} ${st + 1}/${steps.length}` : S.label; + out.push(); + let y = oy + 2; + const tagW = 18; + S.opts.forEach((o, j) => { + const sel = j === S.pick; + const owned = ownedOpt(o); + if (sel) { + out.push( + + {' '.repeat(w - 3)} + , + ); + } + const tag = o.def ? 'STOCK' : owned ? 'OWNED' : costOf(o.cost); + out.push( + + {sel ? '>' : ' '} + , + + {pad(o.name, w - 6 - Math.min(tagW, tag.length + 2))} + , + + {tag} + , + choose(j)} + />, + ); + y++; + }); + y++; + const o = S.opts[S.pick]; + const by = oy + oh - 2; + if (o) { + const jl = + S.slot === null ? wrap(jobsText((o as Finish).jobs), w - 12) : []; + const rowsFree = + by - (narrow ? 2 : 1) - y - (jl.length ? jl.length + 1 : 0); + wrap(o.desc, w - 6) + .slice(0, Math.max(1, rowsFree)) + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + if (jl.length) { + y++; + out.push( + + CREW + , + ); + jl.slice(0, Math.max(1, by - (narrow ? 2 : 1) - y)).forEach((l, i) => { + out.push( + + {l} + , + ); + }); + } + } + if (narrow) { + const tot = costOf(bill.total); + const short = Object.keys(bill.short).length > 0; + out.push( + + {`CREW ${crewN}`} + , + ); + if (tot) { + out.push( + + {tot.slice(0, w - 15)} + , + ); + } + if (!last) { + out.push( + set({ screen: 'bill' })} + />, + ); + } + } + if (st > 0) { + out.push( + set({ step: st - 1 })} + />, + ); + } + out.push( + , + ); + + return <>{out}; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx new file mode 100644 index 00000000000..a75d3236651 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx @@ -0,0 +1,127 @@ +/** + * One hull's record: the stock fit on the viewscreen, model name, description, crew, + * rooms, finishes, price and [ FIT OUT ]. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { Frame, Put, TOP, useGrid, wrap } from './term'; +import type { Hull } from './types'; +import { + costOf, + crewCount, + hullOwned, + Key, + stockFit, + usePreview, + type YardCtx, +} from './YardTab'; + +export function HullRecord(props: { + ctx: YardCtx; + hull: Hull; + x: number; + w: number; +}) { + const { ctx, hull, x, w } = props; + const { rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = stockFit(hull); + const picH = Math.max(6, Math.floor(h * 0.42)); + const out: ReactNode[] = []; + + out.push( + , + ); + let y = TOP + 2 + picH; + out.push( + + {hull.name.toUpperCase().slice(0, w - 6)} + , + ); + y++; + const by = TOP + h - 3; + const owned = hullOwned(ctx.own, hull); + // Leave room for the stats line and the price above the key. + const descRows = Math.max(1, by - (owned ? 1 : 3) - 2 - y); + wrap(hull.desc, w - 6) + .slice(0, descRows) + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + y++; + if (y < by - (owned ? 0 : 2)) { + out.push( + + CREW + , + + {String(crewCount(fit))} + , + + ROOMS + , + + {String(hull.slots.length)} + , + ); + if (hull.finishes.length && w >= 44) { + out.push( + + FINISHES + , + + {String(hull.finishes.length)} + , + ); + } + } + if (!owned) { + out.push( + + PRICE + , + + {costOf(hull.cost)} + , + ); + } + out.push( + + ctx.set({ screen: 'fit', finish: null, picks: {}, step: 0 }) + } + />, + ); + if (!narrow) { + out.push( + ctx.set({ hull: null })} + > + {' X '} + , + ); + } + + return ( + + {out} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx new file mode 100644 index 00000000000..2edb1089ccf --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx @@ -0,0 +1,150 @@ +/** + * HULLS registry. Wide: the list, and the selected hull's record beside it. Narrow: the + * list, or the record on its own screen ([ BACK ] returns to the list). + */ +import { type ReactNode, useMemo } from 'react'; + +import { HullRecord } from './HullRecord'; +import { Frame, Hit, lpad, Put, pad, TOP, useGrid } from './term'; +import { + crewCount, + hullOwned, + partsOf, + stockFit, + type YardCtx, +} from './YardTab'; + +export function Hulls(props: { ctx: YardCtx }) { + const { ctx } = props; + const { catalog, hull, own } = ctx; + const { cols, rows, narrow } = useGrid(); + const h = rows - 1 - TOP; + const crews = useMemo( + () => catalog.map((hl) => crewCount(stockFit(hl))), + [catalog], + ); + + if (narrow && hull) { + return ; + } + + const split = !!hull && !narrow; + const lw = split ? Math.max(40, Math.floor(cols * 0.41)) : cols; + // Columns, right to left from the price. + const price = lw - 14; + const rooms = price - 8; + const crew = rooms - 6; + const fin = split ? 0 : price - 9; + const showModel = !split && crew - 28 >= 20; + const showFin = !split && !narrow; + const roomsAt = showFin ? fin - 8 : rooms; + const crewAt = showFin ? roomsAt - 7 : crew; + const nameEnd = showModel ? 27 : crewAt - 1; + + const out: ReactNode[] = []; + const hy = TOP + 1; + out.push( + + HULL + , + + CREW + , + + ROOMS + , + + PRICE + , + ); + if (showModel) { + out.push( + + MODEL + , + ); + } + if (showFin) { + out.push( + + FINISH + , + ); + } + const maxRows = Math.max(0, TOP + h - 2 - hy); + catalog.slice(0, maxRows).forEach((hl, i) => { + const y = hy + 1 + i; + const sel = hull?.id === hl.id; + const k = sel ? 'inv' : ''; + const owned = hullOwned(own, hl); + const cost = partsOf(hl.cost); + const priceText = owned + ? cost && !hl.free && !own.free + ? 'OWNED' + : 'FREE' + : `${cost} PARTS`; + if (sel) { + out.push( + + {' '.repeat(lw - 3)} + , + ); + } + out.push( + + {sel ? '>' : ' '} + , + + {pad(hl.short.toUpperCase(), nameEnd - 3)} + , + + {lpad(crews[i], 3)} + , + + {lpad(hl.slots.length, 3)} + , + + {priceText} + , + ); + if (showModel) { + out.push( + + {pad(hl.name, crewAt - 29)} + , + ); + } + if (showFin) { + out.push( + + {lpad(hl.finishes.length || '-', 3)} + , + ); + } + out.push( + ctx.set({ hull: sel ? null : hl.id })} + />, + ); + }); + + return ( + <> + + {out} + {split && !!hull && ( + + )} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx new file mode 100644 index 00000000000..6906b480afe --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx @@ -0,0 +1,153 @@ +/** + * A saved ship as a line drawing from its survey plan string: rows split by '/', each a + * run-length list of cells (`12o` = twelve floor). '#' wall, 'g' window, 'o' floor, + * 'e' engine, 'w' weapon, 's' sensor, 't' cargo, '.' nothing. Walls are joined centre to + * centre. A tall hull lies on its side when the space is wide. + */ +import { useMemo } from 'react'; + +import { useGrid } from './term'; + +function parsePlan(plan: string): string[][] { + return plan.split('/').map((row) => { + const out: string[] = []; + for (const m of row.matchAll(/(\d*)(\D)/g)) { + const n = m[1] ? Number(m[1]) : 1; + for (let i = 0; i < n; i++) { + out.push(m[2]); + } + } + return out; + }); +} + +export function PlanPic(props: { + x: number; + y: number; + w: number; + h: number; + plan: string; + /** Drawn dark (the ship can't be loaded). */ + faded?: boolean; +}) { + const { x, y, w, h, plan, faded } = props; + const { cw, ch } = useGrid(); + const aw = w * cw; + const ah = h * ch; + + const drawing = useMemo(() => { + let rows = parsePlan(plan); + let W = Math.max(0, ...rows.map((r) => r.length)); + let H = rows.length; + if (!W || !H) { + return null; + } + if (H > W * 1.15 && aw > ah) { + const rot: string[][] = []; + for (let cx = 0; cx < W; cx++) { + rot.push([]); + for (let cy = H - 1; cy >= 0; cy--) { + rot[cx].push(rows[cy][cx] || '.'); + } + } + rows = rot; + [W, H] = [H, W]; + } + const k = Math.min(aw / W, ah / H); + const at = (cx: number, cy: number) => rows[cy]?.[cx] || '.'; + const wall = (c: string) => c === '#' || c === 'g'; + let floor = ''; + let lines = ''; + let glass = ''; + let eng = ''; + const dots: [number, number, string][] = []; + for (let cy = 0; cy < H; cy++) { + for (let cx = 0; cx < W; cx++) { + const c = at(cx, cy); + if (c === '.') { + continue; + } + const px = (cx + 0.5) * k; + const py = (cy + 0.5) * k; + floor += `M${cx * k} ${cy * k}h${k}v${k}h-${k}z`; + if (wall(c)) { + let lone = true; + for (const [dx, dy] of [ + [1, 0], + [0, 1], + ]) { + const n = at(cx + dx, cy + dy); + if (wall(n)) { + const seg = `M${px} ${py}L${px + dx * k} ${py + dy * k}`; + if (c === 'g' || n === 'g') { + glass += seg; + } else { + lines += seg; + } + lone = false; + } + } + if (wall(at(cx - 1, cy)) || wall(at(cx, cy - 1))) { + lone = false; + } + if (lone) { + lines += `M${px - k * 0.3} ${py}h${k * 0.6}`; + } + } else if (c === 'e') { + eng += `M${cx * k + k * 0.15} ${cy * k + k * 0.15}h${k * 0.7}v${k * 0.7}h-${k * 0.7}z`; + } else if (c === 'w' || c === 's' || c === 't') { + dots.push([px, py, c]); + } + } + } + return { W, H, k, floor, lines, glass, eng, dots }; + }, [plan, aw, ah]); + + if (!drawing) { + return null; + } + const { W, H, k, floor, lines, glass, eng, dots } = drawing; + const ph = faded ? '#2b6450' : '#7effbf'; + const dim = faded ? '#1d4636' : '#3f9a76'; + const amb = faded ? '#5a4a15' : '#ffc41f'; + const red = faded ? '#5a2a30' : '#ff5f6f'; + const sw = Math.max(1.2, k * 0.18); + return ( +
+ + + + + + {dots.map(([cx, cy, c], i) => ( + + ))} + +
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx new file mode 100644 index 00000000000..80246c0e3b8 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx @@ -0,0 +1,337 @@ +/** + * SAVED SHIPS box on the shipyard home: one card per slot with the ship's floor plan, + * name, class, what it carries and LOAD, a denial line, or UNDER WAY. Too narrow for three + * cards, it becomes a one-line-per-slot list. + */ +import type { ReactNode } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { PlanPic } from './PlanPic'; +import { Blocks, Frame, Put, pad, RPut, wrap } from './term'; +import type { LobbyData, SaveCard } from './types'; +import { Key, thousands, type YardCtx } from './YardTab'; + +const MIN_CARD = 26; + +type Props = { + saves: (SaveCard | null)[]; + x: number; + y: number; + w: number; + h: number; + ctx: YardCtx; +}; + +/** LOAD, or CONFIRM after the first press when loading leaves a crew. */ +function useLoad(ctx: YardCtx) { + const { act } = useBackend(); + return (sv: SaveCard) => { + const key = `load${sv.slot}`; + if (ctx.crewChange && ctx.view.confirm !== key) { + ctx.set({ confirm: key }); + return; + } + ctx.set({ confirm: null }); + act('yard_load', { slot: sv.slot, leave_crew: ctx.crewChange ? 1 : 0 }); + }; +} + +export function SaveSlots(props: Props) { + const { saves, x, y, w, h, ctx } = props; + const gap = 2; + const n = Math.max(1, saves.length); + const cardW = Math.floor((w - 4 - gap * (n - 1)) / n); + if (cardW < MIN_CARD || h < 14) { + return ; + } + return ( + + {saves.map((sv, i) => ( + + ))} + + ); +} + +function Card(props: { + sv: SaveCard | null; + n: number; + x: number; + y: number; + w: number; + h: number; + ctx: YardCtx; +}) { + const { sv, n, x, y, w, h, ctx } = props; + const load = useLoad(ctx); + const out: ReactNode[] = []; + if (!sv) { + return ( + + + EMPTY + + + ); + } + const ix = x + 2; + const iw = w - 4; + const denied = !!sv.denial; + const fit = sv.fit || []; + + const lines: [string, number | null, string][] = fit.map((f) => [ + f[0], + f[1], + f[2] || 'red', + ]); + if (sv.lab) { + lines.push(['Lab machines', sv.lab, '']); + } + if (sv.cargo) { + lines.push(['Cargo machines', sv.cargo, 'amb']); + } + if (sv.research) { + lines.push([`Research, ${sv.research} nodes`, null, '']); + } + if (sv.funds) { + lines.push([`Holds ${thousands(sv.funds)} cr`, null, 'amb']); + } + + const nameRows = Math.min(2, wrap(sv.name.toUpperCase(), iw).length); + const hasStats = sv.mass !== undefined || sv.crew !== undefined; + // Rows below the lines: the key, or a two-line denial, or LEAVES + the key. + const foot = denied ? 2 : 1 + (ctx.crewChange ? 1 : 0); + // The plan shrinks (to 3 rows) before the first few fit lines are cut. + // Short cards drop the blank row under the stats. + const statRows = hasStats ? (h < 15 ? 1 : 2) : 1; + const fixed = 4 + nameRows + 1 + statRows + foot; + const prH = Math.max(2, Math.min(11, h - fixed - Math.min(4, lines.length))); + if (sv.plan) { + out.push( + , + ); + } + let yy = y + 1 + prH + 1; + const nameLines = wrap(sv.name.toUpperCase(), iw); + const nm = nameLines.slice(0, 2); + if (nameLines.length > 2) { + nm[1] = `${pad(nm[1], iw - 1)}.`; + } + nm.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + out.push( + + {pad(sv.class, iw)} + , + ); + if (hasStats) { + if (sv.mass !== undefined) { + out.push( + + MASS + , + + {`${thousands(sv.mass)} t`} + , + ); + } + if (sv.crew !== undefined) { + const cx = sv.mass !== undefined ? ix + 16 : ix; + out.push( + + CREW + , + + {String(sv.crew)} + , + ); + } + yy += statRows; + } else { + yy += 1; + } + + const room = Math.max(0, y + h - 2 - foot - yy); + const show = + lines.length > room ? lines.slice(0, Math.max(0, room - 1)) : lines; + show.forEach(([label, count, cls], i) => { + out.push( + , + + {pad(label, iw - 6)} + , + ); + if (count) { + out.push( + + {String(count)} + , + ); + } + yy++; + }); + if (room > 0 && show.length < lines.length) { + out.push( + + {`+${lines.length - show.length} more`} + , + ); + } + + const by = y + h - 2; + if (sv.denial) { + wrap(sv.denial, iw) + .slice(-2) + .reverse() + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + } else if (sv.locked) { + out.push( + + UNDER WAY + , + ); + } else { + const confirming = ctx.view.confirm === `load${sv.slot}`; + if (ctx.crewChange && confirming) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, iw)} + , + ); + } + out.push( + load(sv)} + />, + ); + } + + return ( + + {out} + + ); +} + +/** Narrow windows: one line per slot. */ +function SaveList(props: Props) { + const { saves, x, y, w, h, ctx } = props; + const load = useLoad(ctx); + const out: ReactNode[] = []; + let yy = y + 2; + const keyW = 12; + saves.forEach((sv, i) => { + if (yy > y + h - 2) { + return; + } + const n = sv?.slot || i + 1; + out.push( + + {String(n)} + , + ); + if (!sv) { + out.push( + + EMPTY + , + ); + yy += 2; + return; + } + const nameW = Math.max(4, w - 6 - keyW - 4); + out.push( + + {pad(sv.name.toUpperCase(), nameW)} + , + ); + if (sv.denial) { + out.push( + + {pad(sv.denial, w - 9)} + , + ); + } else if (sv.locked) { + out.push( + + UNDER WAY + , + ); + } else { + const confirming = ctx.view.confirm === `load${sv.slot}`; + out.push( + + {pad(sv.class, w - 9)} + , + load(sv)} + />, + ); + } + yy += 3; + }); + if (ctx.crewChange && ctx.view.confirm?.startsWith('load')) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, w - 6)} + , + ); + } + return ( + + {out} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx new file mode 100644 index 00000000000..20888487436 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx @@ -0,0 +1,152 @@ +/** + * The yard's real full-colour preview on a recessed viewscreen: the finish's hull picture + * with each picked module laid on its slot, the same compositing as ShipPreview in + * ShipUpgradeSelector.tsx (module connector tile onto the slot marker tile). + * + * Scale: whole screen pixels per tile. 32 px tiles or more snap to a multiple of 32 and + * draw pixelated; anything smaller is a smooth downscale at a whole number of px per tile, + * so tiles never land between pixels. + */ +import type { CSSProperties, ReactNode } from 'react'; +import { resolveAsset } from 'tgui/assets'; + +import { useGrid } from './term'; +import type { ShipFit } from './YardTab'; + +type Geom = { + png: string; + width: number; + height: number; + connector?: [number, number]; +}; + +type PreviewData = { + tile_px?: number; + hulls: Record }>; + modules: Record }>; +}; + +const PAD = 10; + +export function ShipPic(props: { + /** Cells; fractions allowed. */ + x: number; + y: number; + w: number; + h: number; + fit: ShipFit; + /** Slot key to outline in amber, or null. */ + hiSlot?: string | null; +}) { + const { x, y, w, h, fit, hiSlot } = props; + const { cw, ch } = useGrid(); + const sx = Math.round(x * cw); + const sy = Math.round(y * ch); + const sw = Math.round(w * cw); + const sh = Math.round(h * ch); + const frame: CSSProperties = { + left: sx, + top: sy, + width: sw, + height: sh, + }; + + const pv = fit.hull.preview as PreviewData | null; + const themeKey = fit.finish?.id || ''; + const hull = pv?.hulls?.[themeKey] || pv?.hulls?.['']; + if (!pv || !hull || sw <= PAD * 2 || sh <= PAD * 2) { + return ( +
+
+
+ ); + } + + const tile = pv.tile_px || 32; + const aw = sw - PAD * 2; + const ah = sh - PAD * 2; + let d = Math.floor(Math.min(aw / hull.width, ah / hull.height)); + const crisp = d >= tile; + if (crisp) { + d = Math.floor(d / tile) * tile; + } + d = Math.max(1, d); + const iw = hull.width * d; + const ih = hull.height * d; + const rendering = crisp ? 'pixelated' : 'auto'; + + const layers: ReactNode[] = [ + , + ]; + for (const slot of fit.slots) { + const marker = hull.slots?.[slot.key]; + const mod = slot.pick ? pv.modules?.[slot.pick.id] : undefined; + if (!marker || !mod) { + continue; + } + const g = (themeKey && mod.themes?.[themeKey]) || mod; + const [cx, cy] = g.connector || [1, 1]; + const l = (marker[0] - cx) * d; + const t = (hull.height - (marker[1] - cy) - g.height) * d; + layers.push( + , + ); + if (slot.key === hiSlot) { + layers.push( +
, + ); + } + } + + return ( +
+
+ {layers} +
+
+
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx index b4021a975be..957ed99f3c4 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -1,25 +1,634 @@ /** - * SHIPYARD tab. STUB from the shell package: package P4 owns and replaces this file. - * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. - * The catalog arrives in static data only after `yard_open`. data/static keys: see types.ts. + * SHIPYARD tab: home (new ship, requisition, saved ships), then HULLS -> hull record -> + * FIT -> BILL. Where the player is and what they picked stays in React (a module-level + * store, so it survives a tab switch); only yard_* actions reach DM. + * + * The catalog arrives in static data only after `yard_open`. Keys: see types.ts. */ -import { useEffect } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Frame, Put, TOP, useGrid } from './term'; -import type { LobbyData } from './types'; +import { Bill } from './Bill'; +import { Fit } from './Fit'; +import { Hulls } from './Hulls'; +import { SaveSlots } from './SaveSlots'; +import { Frame, Put, RPut, TOP, useGrid } from './term'; +import type { + CrewLine, + Finish, + Hull, + LobbyData, + Module, + Owned, + PartClass, + Parts, +} from './types'; -export function YardTab() { +// --------------------------------------------------------------------------- +// Catalog helpers (shared by Hulls, HullRecord, Fit and Bill). +// --------------------------------------------------------------------------- + +export const PART_ORDER: PartClass[] = ['combat', 'science', 'trade', 'misc']; + +/** "4 COMBAT 2 TRADE", or '' when nothing is due. */ +export function costOf(cost?: Parts | null): string { + return PART_ORDER.filter((k) => (cost?.[k] || 0) > 0) + .map((k) => `${cost?.[k]} ${k.toUpperCase()}`) + .join(' '); +} + +export function partsOf(cost?: Parts | null): number { + return PART_ORDER.reduce((a, k) => a + (cost?.[k] || 0), 0); +} + +/** 1,850 */ +export function thousands(n: number): string { + return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +export type Ownership = { + owned: Owned | null | undefined; + /** FREE_SHIPS config: everything is free. */ + free: boolean; +}; + +export function hullOwned(o: Ownership, hull: Hull): boolean { + return ( + o.free || + !!hull.free || + !partsOf(hull.cost) || + !!o.owned?.hulls?.includes(hull.id) + ); +} + +export function finishOwned(o: Ownership, hull: Hull, f: Finish): boolean { + return ( + o.free || + !!f.def || + !partsOf(f.cost) || + !!o.owned?.finishes?.[hull.id]?.includes(f.id) + ); +} + +export function moduleOwned(o: Ownership, hull: Hull, m: Module): boolean { + return ( + o.free || + !!m.def || + !partsOf(m.cost) || + !!o.owned?.modules?.[hull.id]?.includes(m.id) + ); +} + +/** Upper-case label for a slot key: from the catalog, else the key without its hull prefix. */ +export function slotLabel(hull: Hull, key: string): string { + const known = hull.slots.find((s) => s.key === key); + let s = (known?.label || key).toLowerCase(); + const pre = `${key.split('_')[0]}_`; + if (!known && s.startsWith(pre)) { + s = s.slice(pre.length); + } + return s.replace(/_s_/g, "'s ").replace(/_/g, ' ').toUpperCase(); +} + +/** The default finish, or the first, or null for a hull without finishes. */ +export function defaultFinish(hull: Hull): Finish | null { + return hull.finishes.find((f) => !!f.def) || hull.finishes[0] || null; +} + +/** Modules for a slot under a finish, the stock one first. */ +export function slotOptions( + hull: Hull, + key: string, + finish: Finish | null, +): Module[] { + const opts = hull.modules.filter( + (m) => + m.slot === key && (!m.themes || !finish || m.themes.includes(finish.id)), + ); + return [...opts.filter((m) => !!m.def), ...opts.filter((m) => !m.def)]; +} + +export type FitSlot = { + key: string; + label: string; + options: Module[]; + pick: Module | null; +}; + +export type ShipFit = { + hull: Hull; + finish: Finish | null; + slots: FitSlot[]; +}; + +/** + * The configuration as it would be built. A pick the finish doesn't offer falls back to + * that finish's stock module, as the server resets picks on a finish change. + */ +export function fitOf( + hull: Hull, + finishId: string | null, + picks: Record, +): ShipFit { + const finish = + hull.finishes.find((f) => f.id === finishId) || defaultFinish(hull); + const keys: string[] = finish ? finish.slots : hull.slots.map((s) => s.key); + const slots = keys.map((key) => { + const options = slotOptions(hull, key, finish); + const pick = options.find((m) => m.id === picks[key]) || options[0] || null; + return { key, label: slotLabel(hull, key), options, pick }; + }); + return { hull, finish, slots }; +} + +/** The stock fit of a hull. */ +export function stockFit(hull: Hull): ShipFit { + return fitOf(hull, null, {}); +} + +/** Crew manifest of a fit, same titles summed. */ +export function crewOf(fit: ShipFit): CrewLine[] { + const out: CrewLine[] = []; + const add = (lines?: CrewLine[]) => { + for (const [title, n] of lines || []) { + const have = out.find((l) => l[0] === title); + if (have) { + have[1] += n; + } else { + out.push([title, n]); + } + } + }; + add(fit.finish ? fit.finish.jobs : fit.hull.jobs); + for (const s of fit.slots) { + if (s.pick) { + const jobs = s.pick.jobs || {}; + add((fit.finish && jobs[fit.finish.id]) || jobs[''] || []); + } + } + return out; +} + +export function crewCount(fit: ShipFit): number { + return crewOf(fit).reduce((a, l) => a + l[1], 0); +} + +/** [label, name, cost or null when owned] */ +export type BillLine = [string, string, Parts | null]; + +export type ShipBill = { + lines: BillLine[]; + total: Parts; + short: Parts; +}; + +export function billOf( + o: Ownership, + fit: ShipFit, + account: Parts | null | undefined, +): ShipBill { + const { hull, finish } = fit; + const lines: BillLine[] = [ + ['HULL', hull.short, hullOwned(o, hull) ? null : hull.cost], + ]; + if (finish) { + lines.push([ + 'FINISH', + finish.name, + finishOwned(o, hull, finish) ? null : finish.cost, + ]); + } + for (const s of fit.slots) { + if (s.pick) { + lines.push([ + s.label, + s.pick.name, + moduleOwned(o, hull, s.pick) ? null : s.pick.cost, + ]); + } + } + const total: Parts = {}; + for (const l of lines) { + for (const k of PART_ORDER) { + const n = l[2]?.[k] || 0; + if (n) { + total[k] = (total[k] || 0) + n; + } + } + } + const short: Parts = {}; + for (const k of PART_ORDER) { + const due = total[k] || 0; + const have = account?.[k] || 0; + if (due > have) { + short[k] = due - have; + } + } + return { lines, total, short }; +} + +// --------------------------------------------------------------------------- +// UI-local state. +// --------------------------------------------------------------------------- + +export type YardScreen = 'home' | 'hulls' | 'fit' | 'bill'; + +export type YardView = { + screen: YardScreen; + /** Selected hull id (HULLS record, FIT, BILL). */ + hull: string | null; + finish: string | null; + /** slot key -> module id */ + picks: Record; + step: number; + /** The key that has been pressed once and now reads CONFIRM. */ + confirm: string | null; +}; + +const HOME: YardView = { + screen: 'home', + hull: null, + finish: null, + picks: {}, + step: 0, + confirm: null, +}; + +/** Survives the tab unmounting (tab switch, the CONFIRMED box). */ +const store: { view: YardView } = { view: HOME }; +/** Hulls whose preview pictures have been asked for this session. */ +const previewed = new Set(); + +export type SetView = (patch: Partial) => void; + +function useYardView(): [YardView, SetView] { + const [view, setView] = useState(store.view); + const set: SetView = (patch) => + setView((prev) => { + const next = { ...prev, confirm: null, ...patch }; + store.view = next; + return next; + }); + return [view, set]; +} + +/** Ask once per hull for its preview pictures. */ +export function usePreview(hullId: string | null) { const { act } = useBackend(); - const { cols, rows } = useGrid(); useEffect(() => { - act('yard_open'); - }, []); + if (hullId && !previewed.has(hullId)) { + previewed.add(hullId); + act('yard_preview', { hull: hullId }); + } + }, [hullId]); +} + +/** Everything the screens need, worked out once per render. */ +export type YardCtx = { + view: YardView; + set: SetView; + catalog: Hull[]; + hull: Hull | null; + own: Ownership; + account: Parts | null | undefined; + /** Commissioning, requisition and loading are open (round running). */ + open: boolean; + crewChange: string | null; +}; + +// --------------------------------------------------------------------------- +// Shared bits of drawing. +// --------------------------------------------------------------------------- + +/** A `[ LABEL ]` key in any colour class; faint and dead when disabled. */ +export function Key(props: { + x: number; + y: number; + label: string; + cls?: string; + right?: boolean; + disabled?: boolean; + bg?: boolean; + onClick?: () => void; +}) { + const { x, y, label, cls = '', right, disabled, bg, onClick } = props; + const text = `[ ${label} ]`; + const at = right ? x - text.length : x; + if (disabled) { + return ( + + {text} + + ); + } + const amber = cls === 'amb' || cls === 'inva'; + return ( + + {text} + + ); +} + +/** Breadcrumb and [ BACK ] on the tab row, right-aligned. */ +function Crumbs(props: { ctx: YardCtx; screen: YardScreen }) { + const { ctx, screen } = props; + const { view, set, hull } = ctx; + const { cols } = useGrid(); + const seg: [string, (() => void) | null][] = [ + ['SHIPYARD', () => set({ screen: 'home', hull: null })], + [ + 'HULLS', + () => + set({ + screen: 'hulls', + hull: screen === 'hulls' ? null : view.hull, + }), + ], + ]; + if (hull && screen !== 'hulls') { + seg.push([hull.short.toUpperCase(), () => set({ screen: 'hulls' })]); + } + if (screen === 'fit' || screen === 'bill') { + seg.push(['FIT', () => set({ screen: 'fit' })]); + } + if (screen === 'bill') { + seg.push(['BILL', null]); + } + // The tab names end near column 41; drop leading crumbs until the rest fits. + const room = cols - 1 - 42 - 12; + const width = () => seg.map((s) => s[0]).join(' / ').length; + while (seg.length > 1 && width() > room) { + seg.shift(); + } + const showCrumbs = width() <= room; + const back: Record> = { + home: {}, + hulls: view.hull ? { hull: null } : { screen: 'home', hull: null }, + fit: { screen: 'hulls' }, + bill: { screen: 'fit' }, + }; + const out: ReactNode[] = []; + let x = cols - 1 - (showCrumbs ? width() : 0); + out.push( + set(back[screen])} + />, + ); + if (showCrumbs) { + seg.forEach(([label, go], i) => { + const last = i === seg.length - 1; + out.push( + + {label} + , + ); + x += label.length; + if (!last) { + out.push( + + {' / '} + , + ); + x += 3; + } + }); + } + return <>{out}; +} + +// --------------------------------------------------------------------------- +// Home: NEW SHIP, REQUISITION, SAVED SHIPS. +// --------------------------------------------------------------------------- + +function Home(props: { ctx: YardCtx }) { + const { ctx } = props; + const { data, act } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const bottom = rows - 1; + const req = !!data.can_requisition; + const account = data.account || {}; + const confirmReq = ctx.view.confirm === 'req'; + const reqLine = ctx.crewChange + ? `LEAVES ${ctx.crewChange.toUpperCase()}` + : 'SURPLUS HULL'; + + const requisition = () => { + if (ctx.crewChange && !confirmReq) { + ctx.set({ confirm: 'req' }); + return; + } + ctx.set({ confirm: null }); + act('yard_requisition', { leave_crew: ctx.crewChange ? 1 : 0 }); + }; + const newShip = () => ctx.set({ screen: 'hulls', hull: null }); + + const out: ReactNode[] = []; + let th = 5; + const y = TOP + 2; + if (narrow) { + // One box: NEW SHIP and the parts on account, the surplus hull below. + th = req ? 6 : 5; + out.push( + , + , + ); + const short = PART_ORDER.map( + (k) => `${k.slice(0, 3).toUpperCase()} ${account[k] || 0}`, + ).join(' '); + if (cols - 20 >= short.length) { + out.push( + + {short} + , + ); + } + if (req) { + out.push( + + {reqLine.slice(0, cols - 24)} + , + , + ); + } + } else { + const lw = req ? Math.floor(cols * 0.62) : cols; + out.push( + , + , + ); + const segs = PART_ORDER.map((k) => [ + k.toUpperCase(), + String(account[k] || 0), + ]); + const partsW = + 7 + + segs.reduce((a, [k, n]) => a + k.length + 1 + n.length, 0) + + 3 * (segs.length - 1); + let x = lw - 3 - partsW; + out.push( + + PARTS + , + ); + x += 7; + for (const [k, n] of segs) { + out.push( + + {k} + , + + {n} + , + ); + x += k.length + 1 + n.length + 3; + } + if (req) { + const rx = lw + 1; + const rw = cols - lw - 1; + out.push( + , + + {reqLine.slice(0, Math.max(0, rw - 23))} + , + , + ); + } + } + + if (data.saves) { + out.push( + , + ); + } + return <>{out}; +} + +// --------------------------------------------------------------------------- + +function Waiting() { + const { cols, rows } = useGrid(); return ( - + - THE YARD IS CLOSED. + CONTACTING THE YARD... ); } + +export function YardTab() { + const { data, act } = useBackend(); + const [view, set] = useYardView(); + useEffect(() => { + act('yard_open'); + }, []); + + const catalog = useMemo(() => data.catalog || [], [data.catalog]); + const hull = useMemo( + () => catalog.find((h) => h.id === view.hull) || null, + [catalog, view.hull], + ); + const ctx: YardCtx = { + view, + set, + catalog, + hull, + own: { owned: data.owned, free: !!data.free_ships }, + account: data.account, + open: data.phase === 'playing', + crewChange: data.crew_change || null, + }; + + let screen = view.screen; + if ((screen === 'fit' || screen === 'bill') && !hull && !!data.catalog) { + screen = 'hulls'; + } + let body: ReactNode; + if (screen === 'home') { + body = ; + } else if (!data.catalog) { + body = ; + } else if (screen === 'hulls') { + body = ; + } else if (screen === 'fit' && hull) { + body = ; + } else if (hull) { + body = ( + { + // The client forgets the fit once it is sent. + set(HOME); + act('yard_commission', params); + }} + /> + ); + } + + return ( + <> + {screen !== 'home' && } + {body} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts index c7c785fe236..7ec303f21d1 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -148,6 +148,8 @@ export type Hull = { finishes: Finish[]; slots: HullSlot[]; modules: Module[]; + /** Crew of a hull with no finishes (template job_slots). Hulls with finishes use Finish.jobs. */ + jobs?: CrewLine[]; /** build_ship_preview_data(hull), unchanged. */ preview: unknown; }; From f1d4bb376b648b2c17c7d004710a242e0b422b8b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:33:52 -0800 Subject: [PATCH 58/72] Add a Do an execution step to the Cyberware tutorial Credited when a Gorilla Arms, Mantis Blades or Widowline finisher lands. --- tgstation.dme | 1 + voidcrew/_DEFINES/tutorials.dm | 1 + .../tutorials/hooks_chrome_finisher.dm | 23 ++++++++++++++ .../modules/tutorials/tutorial_catalog.dm | 6 ++++ .../modules/unit_tests/voidcrew_tutorials.dm | 31 +++++++++++++++++++ 5 files changed, 62 insertions(+) create mode 100644 voidcrew/modules/tutorials/hooks_chrome_finisher.dm diff --git a/tgstation.dme b/tgstation.dme index c13dac5ef33..0704db165ed 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7841,6 +7841,7 @@ #include "voidcrew\modules\trade\favor_uniques\outfitter.dm" #include "voidcrew\modules\trade\favor_uniques\pike.dm" #include "voidcrew\modules\trade\favor_uniques\vex.dm" +#include "voidcrew\modules\tutorials\hooks_chrome_finisher.dm" #include "voidcrew\modules\tutorials\hooks_contract.dm" #include "voidcrew\modules\tutorials\hooks_medical.dm" #include "voidcrew\modules\tutorials\hooks_power.dm" diff --git a/voidcrew/_DEFINES/tutorials.dm b/voidcrew/_DEFINES/tutorials.dm index 51d14d9f855..8e8ff5ff0f9 100644 --- a/voidcrew/_DEFINES/tutorials.dm +++ b/voidcrew/_DEFINES/tutorials.dm @@ -55,6 +55,7 @@ // Cyberware #define TUTORIAL_STEP_CHROME_BUY "vc/chrome/buy" #define TUTORIAL_STEP_CHROME_INSTALL "vc/chrome/install" +#define TUTORIAL_STEP_CHROME_EXECUTE "vc/chrome/execute" // First surgery #define TUTORIAL_STEP_SURGERY_START "vc/surgery/start" diff --git a/voidcrew/modules/tutorials/hooks_chrome_finisher.dm b/voidcrew/modules/tutorials/hooks_chrome_finisher.dm new file mode 100644 index 00000000000..20ffcbba930 --- /dev/null +++ b/voidcrew/modules/tutorials/hooks_chrome_finisher.dm @@ -0,0 +1,23 @@ +// Tutorial checklist hook: a finished chrome execution (Cyberware item). +// The three cyberware finishers are separate engine types: the Gorilla Arms moves (/datum/hv_finisher/chrome), +// the Mantis Blades scissor and the Widowline garrote. Boss finishers and gun executions don't count. +// Hooked on the shared stop() so each line is covered; returns the parent's value unchanged. + +/// The finishers that count as a cyberware execution. +GLOBAL_LIST_INIT(tutorial_chrome_finisher_types, typecacheof(list( + /datum/hv_finisher/chrome, + /datum/hv_finisher/garrote, + /datum/hv_finisher/mantis_scissor, +))) + +/datum/hv_finisher/stop(reason) + // Captured first: the end callback can delete the finisher inside the parent. + var/was_running = running + var/mob/living/doer = user + var/killed = completed + . = ..() + if(!was_running || !(killed || reason == HV_FINISHER_DONE)) + return + if(!doer?.ckey || !GLOB.tutorial_chrome_finisher_types[type]) + return + tutorial_credit(doer, TUTORIAL_STEP_CHROME_EXECUTE) diff --git a/voidcrew/modules/tutorials/tutorial_catalog.dm b/voidcrew/modules/tutorials/tutorial_catalog.dm index 4f02dfcd14d..bc306077f09 100644 --- a/voidcrew/modules/tutorials/tutorial_catalog.dm +++ b/voidcrew/modules/tutorials/tutorial_catalog.dm @@ -321,6 +321,7 @@ steps = list( /datum/vc_tutorial_step/chrome_buy, /datum/vc_tutorial_step/chrome_install, + /datum/vc_tutorial_step/chrome_execute, ) /datum/vc_tutorial_step/chrome_buy @@ -333,6 +334,11 @@ name = "Get it installed" text = "Lie in the Chop Shop's Chrome Cradle, or have it put in by surgery." +/datum/vc_tutorial_step/chrome_execute + key = TUTORIAL_STEP_CHROME_EXECUTE + name = "Do an execution" + text = "Get someone down, then finish them with Gorilla Arms, Mantis Blades or a Widowline." + // --------------------------------------------------------------------------- // First surgery // --------------------------------------------------------------------------- diff --git a/voidcrew/modules/unit_tests/voidcrew_tutorials.dm b/voidcrew/modules/unit_tests/voidcrew_tutorials.dm index d9807f3840e..785293994aa 100644 --- a/voidcrew/modules/unit_tests/voidcrew_tutorials.dm +++ b/voidcrew/modules/unit_tests/voidcrew_tutorials.dm @@ -222,6 +222,37 @@ TEST_ASSERT(implant.owner == player, "The install failed") TEST_ASSERT(done("vc/chrome/install"), "Installing an implant was not credited") +/// A chrome finisher that lands counts; one called off, or a boss-style finisher, doesn't. +/datum/unit_test/voidcrew_tutorials/chrome_execute/Run() + var/mob/living/carbon/human/player = make_player() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(run_loc_floor_bottom_left, EAST)) + + var/datum/hv_finisher/chrome/called_off = new(player, victim) + called_off.running = TRUE + called_off.stop(HV_FINISHER_LOST_GRIP) + TEST_ASSERT(!done("vc/chrome/execute"), "A called-off chrome finisher was credited") + + var/datum/hv_finisher/boss_style = new(player, victim) + boss_style.running = TRUE + boss_style.completed = TRUE + boss_style.stop(HV_FINISHER_DONE) + qdel(boss_style) + TEST_ASSERT(!done("vc/chrome/execute"), "A non-chrome finisher was credited") + + var/datum/hv_finisher/chrome/finished = new(player, victim) + finished.running = TRUE + finished.completed = TRUE + finished.stop(HV_FINISHER_DONE) + TEST_ASSERT(done("vc/chrome/execute"), "A finished chrome execution was not credited") + + reset_progress() + var/datum/hv_finisher/mantis_scissor/scissor = new(player, victim) + scissor.running = TRUE + scissor.completed = TRUE + scissor.stop(HV_FINISHER_DONE) + qdel(scissor) + TEST_ASSERT(done("vc/chrome/execute"), "A finished Mantis scissor was not credited") + /// Redeeming research notes at an R&D console. /datum/unit_test/voidcrew_tutorials/research_notes/Run() var/mob/living/carbon/human/player = make_player() From 232509d629121ab4f5b8c4d3a8c27c6a724725aa Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:42:31 -0800 Subject: [PATCH 59/72] Integrate the dock terminal lobby packages Share one text field between CREWS and HOUSING, route the old join menu's outpost and ship password popups through the lobby code limiter, clear spawning_ship when a new hull can't seat its buyer, keep a refused commission's fit, return SHIPYARD to its front page when pressed again, limit the yard's DB refresh and the client metrics log, and tidy the bezel CSS comments. --- .../interfaces/LobbyTerminal/CrewRecord.tsx | 59 +-------------- .../interfaces/LobbyTerminal/HousingTab.tsx | 73 +++++-------------- .../interfaces/LobbyTerminal/SaveSlots.tsx | 2 +- .../interfaces/LobbyTerminal/YardTab.tsx | 32 ++++++-- .../interfaces/LobbyTerminal/index.tsx | 7 +- .../interfaces/LobbyTerminal/term.tsx | 67 +++++++++++++++++ .../interfaces/LobbyTerminal/types.ts | 2 + voidcrew/edits/mobs/new_player.dm | 28 +++++-- voidcrew/edits/mobs/ship_join_menu.dm | 6 +- .../lobby_terminal/html/lobby_terminal.css | 6 +- .../modules/lobby_terminal/lobby_bezel.dm | 7 ++ .../modules/lobby_terminal/lobby_crews.dm | 49 ++++++++----- .../modules/lobby_terminal/lobby_housing.dm | 37 ++++++++++ voidcrew/modules/lobby_terminal/lobby_yard.dm | 28 +++++-- 14 files changed, 246 insertions(+), 157 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx index 162d9ffc510..595c8584e4b 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx @@ -8,13 +8,13 @@ import { useBackend } from 'tgui/backend'; import { Blocks, Btn, + Field, Frame, Put, pad, RPut, Rule, TOP, - useGrid, wrap, } from './term'; import type { Crew, LobbyData } from './types'; @@ -38,62 +38,6 @@ export function cut(str: string, n: number): string { /** Locked and not yet cleared to board. */ export const isLocked = (c: Crew) => !!c.locked && !c.cleared; -/** A one-line text field on the grid, drawn in amber inside [ ]. Enter submits. */ -function Field(props: { - x: number; - y: number; - w: number; - value: string; - max: number; - disabled?: boolean; - onChange: (value: string) => void; - onEnter: () => void; -}) { - const { x, y, w, value, max, disabled, onChange, onEnter } = props; - const { cw, ch } = useGrid(); - return ( - <> - - {`[${' '.repeat(w)}]`} - - onChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - onEnter(); - } - }} - style={{ - position: 'absolute', - left: Math.round((x + 1) * cw), - top: Math.round(y * ch), - width: Math.round(w * cw), - height: ch, - padding: 0, - margin: 0, - border: 'none', - outline: 'none', - background: 'transparent', - color: 'var(--lt-amb)', - caretColor: 'var(--lt-amb)', - font: 'inherit', - lineHeight: `${ch}px`, - textShadow: 'inherit', - userSelect: 'text', - zIndex: 6, - }} - /> - - ); -} - type Props = { crew: Crew; x: number; @@ -273,6 +217,7 @@ export function CrewRecord(props: Props) { value={code} max={CODE_MAX} disabled={waiting} + secret onChange={setCode} onEnter={sendCode} />, diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx index da61db13552..105172e10c2 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -1,11 +1,21 @@ /** * HOUSING tab: player outposts taking residents (DM: lobby_housing.dm). - * Draws only with the term.tsx primitives, plus one input for the CODE field. + * Draws only with the term.tsx primitives. */ import { Fragment, type ReactNode, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Blocks, Btn, type Cls, Frame, Put, Rule, TOP, useGrid } from './term'; +import { + Blocks, + Btn, + type Cls, + Field, + Frame, + Put, + Rule, + TOP, + useGrid, +} from './term'; import type { LobbyData, Outpost, OutpostMode } from './types'; const MODES: Record = { @@ -26,54 +36,6 @@ const MOVE_IN = '[ MOVE IN ]'; /** Widest key group at the right of a row: CODE [field] [ MOVE IN ]. */ const KEYS_W = 5 + FIELD + 3 + MOVE_IN.length; -/** A text field on the grid, n cells wide, drawn in the terminal font. */ -function CodeField(props: { - x: number; - y: number; - n: number; - value: string; - disabled: boolean; - onChange: (value: string) => void; - onEnter: () => void; -}) { - const { x, y, n, value, disabled, onChange, onEnter } = props; - const { cw, ch } = useGrid(); - return ( - onChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onEnter(); - } - }} - style={{ - position: 'absolute', - left: x * cw, - top: y * ch, - width: n * cw, - height: ch, - padding: 0, - margin: 0, - border: 'none', - outline: 'none', - background: 'transparent', - color: disabled ? 'var(--lt-faint)' : 'var(--lt-amb)', - caretColor: 'var(--lt-amb)', - font: 'inherit', - lineHeight: 'inherit', - letterSpacing: 0, - zIndex: 6, - }} - /> - ); -} - export function HousingTab() { const { act, data } = useBackend(); const { cols, rows, narrow } = useGrid(); @@ -115,15 +77,14 @@ export function HousingTab() { CODE - - {`[${' '.repeat(FIELD)}]`} - - setCodes({ ...codes, [o.ref]: value })} onEnter={() => moveIn(o)} /> diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx index 80246c0e3b8..a676ec9959a 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx @@ -95,7 +95,7 @@ function Card(props: { const lines: [string, number | null, string][] = fit.map((f) => [ f[0], f[1], - f[2] || 'red', + f[2] ?? 'red', ]); if (sv.lab) { lines.push(['Lab machines', sv.lab, '']); diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx index 957ed99f3c4..c0001d8223a 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -255,7 +255,10 @@ const HOME: YardView = { }; /** Survives the tab unmounting (tab switch, the CONFIRMED box). */ -const store: { view: YardView } = { view: HOME }; +const store: { view: YardView; fitSerial: number | null } = { + view: HOME, + fitSerial: null, +}; /** Hulls whose preview pictures have been asked for this session. */ const previewed = new Set(); @@ -575,12 +578,31 @@ function Waiting() { ); } -export function YardTab() { +export function YardTab(props: { home: number }) { const { data, act } = useBackend(); const [view, set] = useYardView(); useEffect(() => { act('yard_open'); }, []); + // SHIPYARD pressed again while on it: back to the yard's front page. + const [home, setHome] = useState(props.home); + if (home !== props.home) { + setHome(props.home); + if (view.screen !== 'home') { + set(HOME); + } + } + // A commission put the player aboard: the fit was built, so forget it. A refusal keeps it. + const fitSerial = data.fit_serial; + useEffect(() => { + if (fitSerial === undefined) { + return; + } + if (store.fitSerial !== null && store.fitSerial !== fitSerial) { + set(HOME); + } + store.fitSerial = fitSerial; + }, [fitSerial]); const catalog = useMemo(() => data.catalog || [], [data.catalog]); const hull = useMemo( @@ -616,11 +638,7 @@ export function YardTab() { { - // The client forgets the fit once it is sent. - set(HOME); - act('yard_commission', params); - }} + onCommission={(params) => act('yard_commission', params)} /> ); } diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx index 0bfbbfe45b3..9f1cddb583d 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx @@ -121,6 +121,8 @@ export function LobbyTerminal() { ); const [tab, setTab] = useState('crews'); + /** Bumped when SHIPYARD is pressed while already on it. */ + const [yardHome, setYardHome] = useState(0); const request = data.tab_request; useEffect(() => { if (request) { @@ -156,7 +158,7 @@ export function LobbyTerminal() { } else if (tab === 'crews') { body = ; } else if (tab === 'shipyard') { - body = ; + body = ; } else { body = ; } @@ -189,6 +191,9 @@ export function LobbyTerminal() {
{ + if (next === tab && next === 'shipyard') { + setYardHome(yardHome + 1); + } setTab(next); if (confirmed) { act('dismiss'); diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx index 8cb04e6070c..754a4d9788d 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx @@ -139,6 +139,73 @@ export function Btn(props: BtnProps) { ); } +type FieldProps = { + /** Column of the opening bracket; the text starts one cell to the right. */ + x: number; + y: number; + /** Cells between the brackets. */ + w: number; + value: string; + max: number; + disabled?: boolean; + /** Mask the text (codes). */ + secret?: boolean; + onChange: (value: string) => void; + onEnter: () => void; +}; + +/** + * A one-line text field on the grid: `[ ]` in amber with an input over it. + * Enter submits. tgui-core's key passthrough ignores keys typed into an input, so typing + * here never reaches the player's BYOND hotkeys. + */ +export function Field(props: FieldProps) { + const { x, y, w, value, max, disabled, secret, onChange, onEnter } = props; + const { cw, ch } = useGrid(); + return ( + <> + + {`[${' '.repeat(w)}]`} + + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onEnter(); + } + }} + style={{ + position: 'absolute', + left: Math.round((x + 1) * cw), + top: Math.round(y * ch), + width: Math.round(w * cw), + height: ch, + padding: 0, + margin: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: disabled ? 'var(--lt-faint)' : 'var(--lt-amb)', + caretColor: 'var(--lt-amb)', + font: 'inherit', + lineHeight: `${ch}px`, + letterSpacing: 0, + textShadow: 'inherit', + userSelect: 'text', + zIndex: 6, + }} + /> + + ); +} + type FrameProps = { x: number; y: number; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts index 1a9e277dfb0..caffe82edf5 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -189,6 +189,8 @@ export type YardData = { can_requisition?: BooleanLike; /** Name of the crew the player would leave by founding a new one, or null. */ crew_change?: string | null; + /** Bumped when a commission put the player aboard: drop the fit. */ + fit_serial?: number; /** Saved ships, null entries for empty slots. Null hides the SAVED SHIPS box. Always sent by the shell. */ saves: (SaveCard | null)[] | null; }; diff --git a/voidcrew/edits/mobs/new_player.dm b/voidcrew/edits/mobs/new_player.dm index deb96839ade..01599264307 100644 --- a/voidcrew/edits/mobs/new_player.dm +++ b/voidcrew/edits/mobs/new_player.dm @@ -74,14 +74,24 @@ // Password gate. Saved clearance lasts until a password change or join access reset. // encode = FALSE: captains set the password through raw TGUI params, so the attempt // must stay raw too or any password with an HTML-special character never matches. + // Wrong guesses go through the dock terminal's code limiter (lobby_terminal/lobby_crews.dm). if(!ship.is_password_cleared(ckey)) + var/datum/lobby_terminal/terminal = client?.lobby_terminal + if(!terminal) + return + if(terminal.code_lock_wait(ckey, terminal.crew_code_key(ship))) + to_chat(src, span_warning("Too many wrong passwords for [ship.name]. Wait a minute.")) + return select_ship() var/attempt = tgui_input_text(src, "This ship is password-locked by its crew. Enter the join password.", "[ship.name] - Join Password", max_length = SHIP_JOIN_PASSWORD_MAX_LEN, encode = FALSE, timeout = 60 SECONDS) - if(isnull(attempt) || QDELETED(ship)) + if(isnull(attempt) || QDELETED(ship) || QDELETED(terminal)) return select_ship() // Cancelled, timed out, or the ship died mid-prompt - if(!ship.check_join_password(attempt)) - to_chat(src, span_warning("Incorrect join password for [ship.name].")) - return select_ship() - ship.password_cleared_ckeys[ckey] = TRUE + switch(terminal.crew_check_code(src, ship, attempt)) + if("locked") + to_chat(src, span_warning("Too many wrong passwords for [ship.name]. Wait a minute.")) + return select_ship() + if("wrong") + to_chat(src, span_warning("Incorrect join password for [ship.name].")) + return select_ship() // Build job choices var/list/job_choices = list() @@ -134,6 +144,7 @@ /** * Actually spawn the ship with the given upgrade selections and theme. * preset_form_new: the crew-leave answer the dock terminal already collected (lobby_terminal/lobby_yard.dm); null asks with a popup. + * Returns the ship when it was built but the player couldn't be seated on it. */ /mob/dead/new_player/proc/spawn_ship_with_upgrades(datum/map_template/shuttle/voidcrew/template, list/upgrade_selections, datum/ship_theme/selected_theme, preset_form_new = null) if(!template) @@ -173,7 +184,10 @@ to_chat(src, span_notice("Your ship is ready.")) if(!AttemptSpawnOnShip(target.job_slots[1], target)) + // The hull exists and is theirs; let them try again from the crew list. + spawning_ship = FALSE to_chat(src, span_danger("Your berth is unavailable.")) + return target /** * Spawns a free hull for a player the fleet has no room for, and seats them on it as @@ -187,6 +201,7 @@ * * The gate is re-checked here rather than trusted from ui_act, since the fleet can * open up in the time it takes someone to read the menu. + * Returns the ship when it was built but the player couldn't be seated on it. */ /mob/dead/new_player/proc/requisition_free_hull() if(!SSticker?.IsRoundInProgress()) @@ -224,7 +239,10 @@ ship_metric_purchased(target, src, requisition = TRUE) if(!AttemptSpawnOnShip(target.job_slots[1], target)) + // The hull exists and is theirs; let them try again from the crew list. + spawning_ship = FALSE to_chat(src, span_danger("Your berth is unavailable.")) + return target /** * Join as the given job diff --git a/voidcrew/edits/mobs/ship_join_menu.dm b/voidcrew/edits/mobs/ship_join_menu.dm index cfd1b6f135f..d2d15da29e4 100644 --- a/voidcrew/edits/mobs/ship_join_menu.dm +++ b/voidcrew/edits/mobs/ship_join_menu.dm @@ -194,8 +194,10 @@ if("join_outpost") var/obj/structure/overmap/dynamic/player_outpost/home = locate(params["ref"]) in GLOB.player_outposts - if(home && user == ui.user) - INVOKE_ASYNC(user, TYPE_PROC_REF(/mob/dead/new_player, join_outpost), home) + var/datum/lobby_terminal/terminal = user?.client?.lobby_terminal + // Through the dock terminal's code limiter, so this popup can't be used to guess codes. + if(home && terminal && user == ui.user) + INVOKE_ASYNC(terminal, TYPE_PROC_REF(/datum/lobby_terminal, housing_prompt_move_in), user, home) return TRUE if("open_wiki") var/wiki_url = CONFIG_GET(string/wikiurl) diff --git a/voidcrew/modules/lobby_terminal/html/lobby_terminal.css b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css index bde1355098d..f1a83bc16b2 100644 --- a/voidcrew/modules/lobby_terminal/html/lobby_terminal.css +++ b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css @@ -13,7 +13,7 @@ * .lt-deck-row * .lt-keys > .lt-key.lt-key--join (or --ready/--notready + .lt-lamp), --observe, --character * .lt-grille-slot > .lt-grille - * .lt-tray > .lt-sbtn x5 (spritesheet icon inside; .lt-sbtn-mark for the new-poll lamp) + * .lt-tray > .lt-sbtn x5 (an lt_ @@ -54,17 +59,19 @@ function SmallButton(props: { onClick: () => void; }) { const { id, title, disabled, mark, onClick } = props; - const [hover, setHover] = useState(false); - const state = disabled ? '_disabled' : hover ? '_highlighted' : ''; + const focus = useFocusable(!disabled, { + activate: onClick, + }); + const state = disabled ? '_disabled' : focus.focused ? '_highlighted' : ''; return (