From d33201164d5e00018ef10a8973ae6035300f1faf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:07:05 -0700 Subject: [PATCH 1/3] Rebalance the Event Horizon rifle and anomaly core supply The Event Horizon rifle kit is no longer researchable. The rifle is now a research-cache unique with three shots, no recharge, a minute between shots, on-screen range and a reality tear that no longer gibs mobs. Anomaly cores are scarce again. Cargo no longer sells raw cores. Boffin sells a limited weighted rotation of raw cores and anomaly charts, priced by rarity. A chart points the buying ship at a planet or derelict, and the anomaly only appears when that ship docks there. Planets get all nine anomaly types on a per-planet chance by zone; they move and keep their stock effects but stay on their site. The planetary pyroclastic anomaly shoots flame jets instead of venting plasma. --- .../projectiles/guns/energy/beam_rifle.dm | 10 +- code/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_anomaly_trade.dm | 292 +++++++++++++ .../unit_tests/voidcrew_shop_catalog.dm | 3 + tgstation.dme | 3 + voidcrew/_DEFINES/overmap_zones.dm | 19 +- voidcrew/datums/mapgen/PlanetGenerator.dm | 8 +- voidcrew/datums/mapgen/planet_anomalies.dm | 299 +++++++++++-- .../objects/items/event_horizon_rifle.dm | 48 +++ voidcrew/modules/cargo/anomaly_packs.dm | 32 ++ .../dynamic_events/events/anomalies.dm | 2 +- voidcrew/modules/loot/themes/research.dm | 4 +- .../code/modules/overmap/space_ruin.dm | 5 +- voidcrew/modules/shuttle/helm/_helm.dm | 9 +- voidcrew/modules/trade/anomaly_charts.dm | 408 ++++++++++++++++++ voidcrew/modules/trade/rumor_charts.dm | 18 +- voidcrew/modules/trade/shop.dm | 111 ++++- .../trade/shop_catalog_outfitter_vendors.dm | 86 ++++ wiki/content/research.md | 2 +- wiki/content/trader-outposts.md | 14 +- 20 files changed, 1292 insertions(+), 82 deletions(-) create mode 100644 code/modules/unit_tests/voidcrew_anomaly_trade.dm create mode 100644 voidcrew/edits/objects/items/event_horizon_rifle.dm create mode 100644 voidcrew/modules/cargo/anomaly_packs.dm create mode 100644 voidcrew/modules/trade/anomaly_charts.dm diff --git a/code/modules/projectiles/guns/energy/beam_rifle.dm b/code/modules/projectiles/guns/energy/beam_rifle.dm index e5e36337ae2..198e5bfa9df 100644 --- a/code/modules/projectiles/guns/energy/beam_rifle.dm +++ b/code/modules/projectiles/guns/energy/beam_rifle.dm @@ -21,16 +21,10 @@ selfcharge = TRUE self_charge_amount = STANDARD_ENERGY_GUN_SELF_CHARGE_RATE * 10 -/obj/item/gun/energy/event_horizon/Initialize(mapload) - . = ..() - AddComponent(/datum/component/scope, range_modifier = 4) +// VOIDCREW EDIT REMOVAL: scope component and "must be scoped" check. It fires on screen +// only; see voidcrew/edits/objects/items/event_horizon_rifle.dm. /obj/item/gun/energy/event_horizon/process_fire(atom/target, mob/living/user, message, params, zone_override, bonus_spread) - - if(!HAS_TRAIT(user, TRAIT_USER_SCOPED)) - balloon_alert(user, "must be scoped!") - return - . = ..() message_admins("[ADMIN_LOOKUPFLW(user)] has fired an anti-existential beam at [ADMIN_VERBOSEJMP(user)].") diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index 865ec5c355a..71599d2fcb3 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -333,6 +333,7 @@ #include "verify_config_tags.dm" #include "verify_emoji_names.dm" #include "voidcrew_ammo_box_materials.dm" +#include "voidcrew_anomaly_trade.dm" #include "voidcrew_assault_pod.dm" #include "voidcrew_autopilot_course.dm" #include "voidcrew_autotranslate_morph.dm" diff --git a/code/modules/unit_tests/voidcrew_anomaly_trade.dm b/code/modules/unit_tests/voidcrew_anomaly_trade.dm new file mode 100644 index 00000000000..9e1b8883aa8 --- /dev/null +++ b/code/modules/unit_tests/voidcrew_anomaly_trade.dm @@ -0,0 +1,292 @@ +// Fork defines are included after upstream unit tests, so the signal and zone values +// below are the literals behind them: COMSIG_VOIDCREW_SHIP_DOCKED is +// "voidcrew_ship_docked" (voidcrew/_DEFINES/ship_defines.dm). + +/// The nine raw anomaly crates are gone from every cargo console. +/datum/unit_test/voidcrew_anomaly_cargo_hidden/Run() + var/list/raw_packs = list( + /datum/supply_pack/science/raw_flux_anomaly, + /datum/supply_pack/science/raw_hallucination_anomaly, + /datum/supply_pack/science/raw_grav_anomaly, + /datum/supply_pack/science/raw_vortex_anomaly, + /datum/supply_pack/science/raw_ectoplasm_anomaly, + /datum/supply_pack/science/raw_bluespace_anomaly, + /datum/supply_pack/science/raw_pyro_anomaly, + /datum/supply_pack/science/raw_bioscrambler_anomaly, + /datum/supply_pack/science/raw_dimensional_anomaly, + ) + for(var/pack_type in raw_packs) + var/datum/supply_pack/pack = allocate(pack_type) + TEST_ASSERT_NULL(pack.contains, "[pack_type] still has contents, so SSshuttle registers it") + for(var/pack_id in SSshuttle.supply_packs) + var/datum/supply_pack/pack = SSshuttle.supply_packs[pack_id] + for(var/item in pack.contains) + if(ispath(item, /obj/item/raw_anomaly_core)) + TEST_FAIL("[pack.type] sells [item] over cargo") + +/// Boffin's core and chart rotations: every type, the right price band, one unit, +/// no discounts, and sold slots turn over instead of refilling. +/datum/unit_test/voidcrew_anomaly_boffin_shelves/Run() + var/list/core_prices = list( + /obj/item/raw_anomaly_core/flux = 25000, + /obj/item/raw_anomaly_core/grav = 25000, + /obj/item/raw_anomaly_core/hallucination = 25000, + /obj/item/raw_anomaly_core/pyro = 25000, + /obj/item/raw_anomaly_core/bioscrambler = 30000, + /obj/item/raw_anomaly_core/ectoplasm = 30000, + /obj/item/raw_anomaly_core/dimensional = 30000, + /obj/item/raw_anomaly_core/bluespace = 35000, + /obj/item/raw_anomaly_core/vortex = 35000, + ) + var/list/chart_prices = list( + /obj/effect/anomaly/flux = 12500, + /obj/effect/anomaly/grav = 12500, + /obj/effect/anomaly/hallucination = 12500, + /obj/effect/anomaly/pyro = 12500, + /obj/effect/anomaly/bioscrambler = 15000, + /obj/effect/anomaly/ectoplasm = 15000, + /obj/effect/anomaly/dimensional = 15000, + /obj/effect/anomaly/bluespace = 17500, + /obj/effect/anomaly/bhole = 17500, + ) + + var/datum/outpost_shop/shop = allocate(/datum/outpost_shop/vendor/skunkworks, null) + var/datum/shop_rotation/cores + var/datum/shop_rotation/charts + for(var/datum/shop_rotation/rotation as anything in shop.live_rotations) + if(istype(rotation, /datum/shop_rotation/skunk_cores)) + cores = rotation + else if(istype(rotation, /datum/shop_rotation/skunk_anomaly_charts)) + charts = rotation + TEST_ASSERT_NOTNULL(cores, "Boffin has no raw core rotation") + TEST_ASSERT_NOTNULL(charts, "Boffin has no anomaly chart rotation") + + var/list/cores_seen = list() + for(var/datum/shop_sku/sku_type as anything in cores.pool) + var/item = initial(sku_type.item_path) + TEST_ASSERT(item in core_prices, "[sku_type] sells [item], which is not a raw anomaly core") + TEST_ASSERT_EQUAL(initial(sku_type.price_credits), core_prices[item], "[sku_type] is in the wrong price band") + TEST_ASSERT(!initial(sku_type.fixed_price), "[sku_type] ignores favor and specials") + TEST_ASSERT(!initial(sku_type.contract_reward), "[sku_type] can be paid out by contracts") + TEST_ASSERT_EQUAL(initial(sku_type.stock_max), 1, "[sku_type] stocks more than one unit") + cores_seen[item] = cores.pool[sku_type] + TEST_ASSERT_EQUAL(length(cores_seen), 9, "Boffin's core rotation does not cover all nine types") + TEST_ASSERT(cores_seen[/obj/item/raw_anomaly_core/flux] > cores_seen[/obj/item/raw_anomaly_core/bioscrambler], "Uncommon cores are not rarer than common ones") + TEST_ASSERT(cores_seen[/obj/item/raw_anomaly_core/bioscrambler] > cores_seen[/obj/item/raw_anomaly_core/vortex], "Rare cores are not rarer than uncommon ones") + + var/list/charts_seen = list() + for(var/datum/shop_sku/anomaly_chart/sku_type as anything in charts.pool) + var/anomaly = initial(sku_type.anomaly_type) + var/base_type + for(var/candidate in chart_prices) + if(ispath(anomaly, candidate)) + base_type = candidate + break + TEST_ASSERT_NOTNULL(base_type, "[sku_type] points at [anomaly], which is not one of the nine anomaly types") + TEST_ASSERT_EQUAL(initial(sku_type.price_credits), chart_prices[base_type], "[sku_type] is in the wrong price band") + TEST_ASSERT(!initial(sku_type.fixed_price), "[sku_type] ignores favor and specials") + var/obj/effect/anomaly/anomaly_path = anomaly + TEST_ASSERT(initial(anomaly_path.immortal) && initial(anomaly_path.site_bound), "[sku_type] surfaces an anomaly that times out or can leave its site") + charts_seen[base_type] = TRUE + TEST_ASSERT_EQUAL(length(charts_seen), 9, "Boffin's chart rotation does not cover all nine types") + + for(var/datum/shop_rotation/rotation as anything in list(cores, charts)) + var/list/on_shelf = list() + for(var/datum/shop_sku/sku as anything in shop.skus) + if(sku.rotation == rotation) + on_shelf += sku + TEST_ASSERT(length(on_shelf) >= 1 && length(on_shelf) <= rotation.slots, "[rotation.type] put [length(on_shelf)] SKUs on the shelf") + var/datum/shop_sku/sold = on_shelf[1] + TEST_ASSERT_EQUAL(sold.stock, 1, "[sold.type] stocked [sold.stock] units") + sold.discount_pct = 30 + TEST_ASSERT_EQUAL(sold.get_credit_price(), round(sold.price_credits * 0.7, 5), "A special did not discount [sold.type]") + var/sold_type = sold.type + sold.stock = 0 + shop.convoy_restock() + TEST_ASSERT(!(sold in shop.skus), "The convoy refilled a sold [sold_type] in place") + var/list/after = list() + for(var/datum/shop_sku/sku as anything in shop.skus) + if(sku.rotation == rotation) + after += sku.type + TEST_ASSERT_EQUAL(length(after), length(on_shelf), "The convoy did not replace the sold slot") + TEST_ASSERT(!(sold_type in after), "The sold slot came back as the same [sold_type]") + TEST_ASSERT_EQUAL(length(unique_list(after)), length(after), "The rotation shelved the same SKU twice") + +/// Raw cores take the normal favor discount, and the better of favor or special wins. +/datum/unit_test/voidcrew_anomaly_core_favor/Run() + var/datum/outpost_shop/shop = allocate(/datum/outpost_shop) + shop.add_sku(/datum/shop_sku/skunk/anomaly_core/vortex, "rotating") + var/datum/shop_sku/skunk/anomaly_core/sku = shop.skus[1] + var/obj/structure/overmap/ship/ship = allocate(/obj/structure/overmap/ship) + var/mob/living/carbon/human/buyer = allocate(/mob/living/carbon/human/consistent) + buyer.mind_initialize() + allocated += buyer.mind + var/datum/team/voidcrew/team = allocate(/datum/team/voidcrew) + team.ship = ship + buyer.mind.ship_teams = list(team) + sku.discount_pct = 0 + TEST_ASSERT_EQUAL(sku.get_credit_price(buyer), 35000, "A stranger got a discount") + ship.trader_favor = list() + ship.trader_favor[shop.favor_key()] = 6 // FAVOR_TIER_TRUSTED, 15% off + TEST_ASSERT_EQUAL(sku.get_credit_price(buyer), 29750, "A Trusted crew did not get the normal favor discount") + sku.discount_pct = 30 + TEST_ASSERT_EQUAL(sku.get_credit_price(buyer), 24500, "The better special did not win over favor") + sku.discount_pct = 10 + TEST_ASSERT_EQUAL(sku.get_credit_price(buyer), 29750, "Favor and special stacked, or the better one lost") + buyer.mind.ship_teams = null + team.ship = null + +/// Buying a chart takes the money and puts a sealed anomaly rumor on the ship. +/datum/unit_test/voidcrew_anomaly_chart_purchase/Run() + var/datum/outpost_shop/shop = allocate(/datum/outpost_shop) + shop.add_sku(/datum/shop_sku/anomaly_chart/grav, "rotating") + var/datum/shop_sku/anomaly_chart/sku = shop.skus[1] + var/obj/structure/overmap/ship/ship = allocate(/obj/structure/overmap/ship) + var/mob/living/carbon/human/buyer = allocate(/mob/living/carbon/human/consistent) + buyer.mind_initialize() + allocated += buyer.mind + var/datum/team/voidcrew/team = allocate(/datum/team/voidcrew) + team.ship = ship + buyer.mind.ship_teams = list(team) + var/datum/bank_account/account = allocate(/datum/bank_account, "Anomaly chart test", null, 1, FALSE) + account.account_balance = 20000 + var/obj/item/card/id/advanced/id_card = allocate(/obj/item/card/id/advanced) + qdel(id_card.registered_account) + id_card.registered_account = account + TEST_ASSERT(buyer.put_in_hands(id_card), "Buyer could not carry their paying ID") + + TEST_ASSERT(sku.try_purchase(buyer, null), "Chart purchase failed") + TEST_ASSERT_EQUAL(account.account_balance, 7500, "The chart charged the wrong amount") + TEST_ASSERT_EQUAL(length(ship.pending_rumors), 1, "Purchase did not upload a sealed chart") + var/datum/rumor_chart/anomaly/chart = ship.pending_rumors[1] + TEST_ASSERT(istype(chart), "The sealed chart is not an anomaly chart") + TEST_ASSERT_EQUAL(chart.anomaly_type, /obj/effect/anomaly/grav/planetary, "The chart names the wrong anomaly") + TEST_ASSERT(!chart.revealed && !chart.surfaced, "A fresh chart is already revealed") + TEST_ASSERT(!sku.try_purchase(buyer, null), "A one-unit chart sold twice") + TEST_ASSERT_EQUAL(account.account_balance, 7500, "A refused purchase took money") + + qdel(ship) + TEST_ASSERT(QDELETED(chart), "Hull loss left its sealed chart alive") + buyer.mind.ship_teams = null + team.ship = null + +/// Test chart that skips site generation and spawn-point search +/datum/rumor_chart/anomaly/unit_test + var/obj/structure/overmap/test_site + var/turf/test_turf + +/datum/rumor_chart/anomaly/unit_test/pick_site(obj/structure/overmap/ship/ship) + return test_site + +/datum/rumor_chart/anomaly/unit_test/find_spawn_turf(obj/structure/overmap/site) + return test_turf + +/// Nothing on reveal, nothing for other ships or other sites, one anomaly when the +/// buyer docks at the charted site, and a lost site re-seals the chart. +/datum/unit_test/voidcrew_anomaly_chart_arrival + var/list/obj/effect/anomaly/spawned = list() + +/datum/unit_test/voidcrew_anomaly_chart_arrival/Destroy() + QDEL_LIST(spawned) + return ..() + +/datum/unit_test/voidcrew_anomaly_chart_arrival/Run() + var/obj/structure/overmap/ship/buyer = allocate(/obj/structure/overmap/ship) + var/obj/structure/overmap/ship/stranger = allocate(/obj/structure/overmap/ship) + var/obj/structure/overmap/space_ruin/site = allocate(/obj/structure/overmap/space_ruin) + var/obj/structure/overmap/space_ruin/elsewhere = allocate(/obj/structure/overmap/space_ruin) + var/turf/spot = run_loc_floor_top_right + + var/datum/rumor_chart/anomaly/unit_test/chart = new + chart.anomaly_type = /obj/effect/anomaly/flux/planetary + chart.test_site = site + chart.test_turf = spot + buyer.add_pending_rumor(chart) + + TEST_ASSERT_EQUAL(buyer.reveal_pending_rumor(chart), site, "Reveal did not chart the site") + TEST_ASSERT(!QDELETED(chart) && (chart in buyer.active_anomaly_charts), "Revealing consumed the chart before arrival") + TEST_ASSERT(!(chart in buyer.pending_rumors), "The revealed chart is still sealed on the helm") + TEST_ASSERT_NULL(locate(/obj/effect/anomaly) in spot, "Revealing the chart spawned the anomaly") + + stranger.docked = site + SEND_SIGNAL(stranger, "voidcrew_ship_docked") + TEST_ASSERT_NULL(locate(/obj/effect/anomaly) in spot, "Another crew's arrival surfaced the buyer's anomaly") + + buyer.docked = elsewhere + SEND_SIGNAL(buyer, "voidcrew_ship_docked") + TEST_ASSERT_NULL(locate(/obj/effect/anomaly) in spot, "Docking at a different site surfaced the anomaly") + + buyer.docked = site + SEND_SIGNAL(buyer, "voidcrew_ship_docked") + var/obj/effect/anomaly/anomaly = locate(/obj/effect/anomaly) in spot + if(anomaly) + spawned += anomaly + TEST_ASSERT_NOTNULL(anomaly, "The buyer docked at the site and no anomaly formed") + TEST_ASSERT(istype(anomaly, /obj/effect/anomaly/flux/planetary), "The wrong anomaly formed") + TEST_ASSERT(QDELETED(chart), "The chart outlived its anomaly") + + SEND_SIGNAL(buyer, "voidcrew_ship_docked") + var/count = 0 + for(var/obj/effect/anomaly/extra in spot) + count++ + spawned |= extra + TEST_ASSERT_EQUAL(count, 1, "A second arrival surfaced another anomaly") + + // A site that disappears before arrival hands the chart back sealed + buyer.docked = null + stranger.docked = null + var/datum/rumor_chart/anomaly/unit_test/second = new + second.anomaly_type = /obj/effect/anomaly/flux/planetary + second.test_site = elsewhere + second.test_turf = spot + buyer.add_pending_rumor(second) + TEST_ASSERT_EQUAL(buyer.reveal_pending_rumor(second), elsewhere, "Second reveal did not chart its site") + qdel(elsewhere) + TEST_ASSERT(!QDELETED(second), "Losing the site deleted the paid chart") + TEST_ASSERT((second in buyer.pending_rumors) && !(second in buyer.active_anomaly_charts), "Losing the site did not re-seal the chart") + TEST_ASSERT(!second.revealed, "The re-sealed chart still counts as revealed") + +/// Every anomaly type can seed on planets, as immortal site-bound subtypes, rarer +/// for the dangerous ones. +/datum/unit_test/voidcrew_planet_anomaly_table/Run() + var/list/base_types = list( + /obj/effect/anomaly/flux, + /obj/effect/anomaly/grav, + /obj/effect/anomaly/hallucination, + /obj/effect/anomaly/pyro, + /obj/effect/anomaly/bioscrambler, + /obj/effect/anomaly/ectoplasm, + /obj/effect/anomaly/dimensional, + /obj/effect/anomaly/bluespace, + /obj/effect/anomaly/bhole, + ) + var/list/weights = list() + for(var/base_type in base_types) + for(var/obj/effect/anomaly/entry as anything in GLOB.voidcrew_planet_anomalies) + if(!ispath(entry, base_type)) + continue + TEST_ASSERT(initial(entry.immortal), "[entry] times out on a planet") + TEST_ASSERT(initial(entry.site_bound), "[entry] can wander off its planet") + weights[base_type] = GLOB.voidcrew_planet_anomalies[entry] + TEST_ASSERT(weights[base_type], "[base_type] never seeds on planets") + TEST_ASSERT(weights[/obj/effect/anomaly/pyro] > weights[/obj/effect/anomaly/dimensional], "Uncommon planet anomalies are not rarer than common ones") + TEST_ASSERT(weights[/obj/effect/anomaly/dimensional] > weights[/obj/effect/anomaly/bhole], "Rare planet anomalies are not rarer than uncommon ones") + // Literals 1/2/3 = ZONE_GREEN/YELLOW/RED: every band can roll one, deeper bands more often + TEST_ASSERT_EQUAL(planet_anomaly_chance(1), 5, "Wrong green planet anomaly chance") + TEST_ASSERT_EQUAL(planet_anomaly_chance(2), 15, "Wrong yellow planet anomaly chance") + TEST_ASSERT_EQUAL(planet_anomaly_chance(3), 25, "Wrong red planet anomaly chance") + +/// The planetary pyroclastic anomaly burns whoever is next to it and never touches the air. +/datum/unit_test/voidcrew_planet_pyro_jets/Run() + var/obj/effect/anomaly/pyro/planetary/pyro = allocate(/obj/effect/anomaly/pyro/planetary) + var/turf/open/origin = get_turf(pyro) + var/turf/next_door = get_step(origin, NORTH) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, next_door) + var/datum/gas_mixture/air = origin.return_air() + var/moles_before = air.total_moles() + pyro.anomalyEffect(pyro.burst_delay) + TEST_ASSERT(victim.fire_stacks > 0, "Someone standing next to the burst was not set alight") + TEST_ASSERT(victim.getFireLoss() > 0, "Someone standing next to the burst took no burns") + air = origin.return_air() + TEST_ASSERT_EQUAL(air.total_moles(), moles_before, "The planetary pyroclastic anomaly released gas") + victim.extinguish_mob() diff --git a/code/modules/unit_tests/voidcrew_shop_catalog.dm b/code/modules/unit_tests/voidcrew_shop_catalog.dm index bbe4508086f..9f0881b9cd1 100644 --- a/code/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/code/modules/unit_tests/voidcrew_shop_catalog.dm @@ -24,6 +24,9 @@ continue shops_checked++ var/list/every_sku = shop.sku_types + shop.rotating_pool + shop.rare_pool + shop.chart_pool + for(var/datum/shop_rotation/rotation as anything in shop.live_rotations) + for(var/sku_type in rotation.pool) + every_sku += sku_type for(var/datum/shop_sku/sku_type as anything in every_sku) if(!ispath(sku_type, /datum/shop_sku)) TEST_FAIL("[shop_type] lists [sku_type], which is not a /datum/shop_sku") diff --git a/tgstation.dme b/tgstation.dme index bd2724e7502..415ca5850d0 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6798,6 +6798,7 @@ #include "voidcrew\edits\mobs\respawn_timer.dm" #include "voidcrew\edits\mobs\ruin_icons.dm" #include "voidcrew\edits\mobs\ship_join_menu.dm" +#include "voidcrew\edits\objects\items\event_horizon_rifle.dm" #include "voidcrew\edits\objects\items\extinguisher.dm" #include "voidcrew\edits\objects\items\kinetic_accelerator_modkits.dm" #include "voidcrew\edits\objects\structures\closet_containment.dm" @@ -6913,6 +6914,7 @@ #include "voidcrew\modules\captain_management\captain_management.dm" #include "voidcrew\modules\captain_management\command_transfer.dm" #include "voidcrew\modules\captain_management\ship_applications.dm" +#include "voidcrew\modules\cargo\anomaly_packs.dm" #include "voidcrew\modules\cargo\bank_account.dm" #include "voidcrew\modules\cargo\bank_machine.dm" #include "voidcrew\modules\cargo\exports.dm" @@ -7358,6 +7360,7 @@ #include "voidcrew\modules\storage\smart_locker.dm" #include "voidcrew\modules\surgery\experimental_dissection.dm" #include "voidcrew\modules\teams\_team.dm" +#include "voidcrew\modules\trade\anomaly_charts.dm" #include "voidcrew\modules\trade\blueprint_imprinter.dm" #include "voidcrew\modules\trade\modsuit_bench.dm" #include "voidcrew\modules\trade\outpost.dm" diff --git a/voidcrew/_DEFINES/overmap_zones.dm b/voidcrew/_DEFINES/overmap_zones.dm index 4116c638962..7b9e7e3458f 100644 --- a/voidcrew/_DEFINES/overmap_zones.dm +++ b/voidcrew/_DEFINES/overmap_zones.dm @@ -118,7 +118,8 @@ #define ZONE_PLANET_SPAWNER_SPACING 24 /** - * How many anomalies a planet may seed, per zone. + * Percent chance a planet seeds an anomaly, per zone. At most one per planet: they are + * meant to be a rare find, and a planet rebuilds with a fresh roll each time it reloads. * * Anomalies used to be a ship-scoped dynamic event, which made them something that * happened TO a crew in their own corridors with nowhere to stand back to. On a planet @@ -126,19 +127,17 @@ * open ground, and the crew chose to walk over there. Approaching one is a decision, and * a crew carrying a neutralizer (Anomaly Research, tier 3) gets a core out of it. * - * Green stays empty for the same reason it seeds no megafauna: it is where a crew takes - * its first landing. + * Green gets the occasional one, deeper bands more often. */ -#define ZONE_PLANET_ANOMALY_BUDGET_GREEN 0 -#define ZONE_PLANET_ANOMALY_BUDGET_YELLOW 2 -#define ZONE_PLANET_ANOMALY_BUDGET_RED 3 +#define ZONE_PLANET_ANOMALY_CHANCE_GREEN 5 +#define ZONE_PLANET_ANOMALY_CHANCE_YELLOW 15 +#define ZONE_PLANET_ANOMALY_CHANCE_RED 25 -/// Minimum tiles between two planet anomalies, so a budget of 3 is three separate finds -/// rather than one lethal clearing. +/// Minimum tiles between two planet anomalies, should a planet ever seed more than one #define ZONE_PLANET_ANOMALY_SPACING 20 -/// Cap on random turf draws while placing the anomaly budget. A planet whose open ground -/// is nearly all taken simply seeds fewer than its budget rather than spinning. +/// Cap on random turf draws while placing a planet's anomaly. A planet whose open ground +/// is nearly all taken simply goes without rather than spinning. #define PLANET_ANOMALY_PLACEMENT_ATTEMPTS 400 /// Multiplier on ore mined per planet rock wall (mineralAmt), per zone, green stays baseline (x1). Applied in /turf/closed/mineral/proc/zone_scaled_ore_amount() diff --git a/voidcrew/datums/mapgen/PlanetGenerator.dm b/voidcrew/datums/mapgen/PlanetGenerator.dm index 399c382326f..4b3ab8e1531 100644 --- a/voidcrew/datums/mapgen/PlanetGenerator.dm +++ b/voidcrew/datums/mapgen/PlanetGenerator.dm @@ -397,7 +397,7 @@ var/mob_chance_mult = 1 var/mob_upgrade_prob = 0 var/spawner_budget = ZONE_PLANET_SPAWNER_BUDGET_GREEN - var/anomaly_budget = ZONE_PLANET_ANOMALY_BUDGET_GREEN + var/anomaly_chance = ZONE_PLANET_ANOMALY_CHANCE_GREEN // Megafauna are apex content and stay out of the shallow end entirely - a green-zone // planet is where a crew takes its first landing. var/megafauna_allowed = FALSE @@ -418,13 +418,13 @@ mob_chance_mult = ZONE_PLANET_MOB_CHANCE_MULT_YELLOW mob_upgrade_prob = ZONE_PLANET_MOB_UPGRADE_PROB_YELLOW spawner_budget = ZONE_PLANET_SPAWNER_BUDGET_YELLOW - anomaly_budget = ZONE_PLANET_ANOMALY_BUDGET_YELLOW + anomaly_chance = ZONE_PLANET_ANOMALY_CHANCE_YELLOW megafauna_allowed = TRUE if(ZONE_RED) mob_chance_mult = ZONE_PLANET_MOB_CHANCE_MULT_RED mob_upgrade_prob = ZONE_PLANET_MOB_UPGRADE_PROB_RED spawner_budget = ZONE_PLANET_SPAWNER_BUDGET_RED - anomaly_budget = ZONE_PLANET_ANOMALY_BUDGET_RED + anomaly_chance = ZONE_PLANET_ANOMALY_CHANCE_RED megafauna_allowed = TRUE // Structure spawners and megafauna are placed after the pass, not during it. Both are @@ -562,6 +562,8 @@ var/spawners_placed = place_budgeted_spawners(spawner_candidates, spawner_budget) var/megafauna_placed = place_planet_megafauna(megafauna_candidates) + // One anomaly at most, and only on a successful roll: they are a rare find + var/anomaly_budget = prob(anomaly_chance) ? 1 : 0 var/anomalies_placed = place_budgeted_anomalies(turfs, anomaly_budget) log_world("[name] terrain population finished in [(REALTIMEOFDAY - start_time)/10]s! \ diff --git a/voidcrew/datums/mapgen/planet_anomalies.dm b/voidcrew/datums/mapgen/planet_anomalies.dm index d48ca372ae5..8339c6c92de 100644 --- a/voidcrew/datums/mapgen/planet_anomalies.dm +++ b/voidcrew/datums/mapgen/planet_anomalies.dm @@ -7,74 +7,285 @@ * has one route between compartments, so "avoid the gravitational anomaly" and "reach * engineering" were frequently the same tile. * - * The same object on open planet ground is the opposite proposition. It is stationary, - * it is lit and visible from well outside its own reach, and nothing brought it to the - * crew, the crew walked to it. Standing next to one is a decision, and a crew that - * researched Anomaly Research (tier 3) and printed a neutralizer turns that decision - * into an anomaly core. + * The same object on open planet ground is the opposite proposition. Nothing brought it + * to the crew, the crew walked to it, and a crew that researched Anomaly Research + * (tier 3) and printed a neutralizer turns it into an anomaly core. * * These are the ONLY anomalies that spawn naturally. The ship-scoped controls in * voidcrew/modules/dynamic_events/events/anomalies.dm are all weight 0 / admin-only. + * Boffin's anomaly charts (voidcrew/modules/trade/anomaly_charts.dm) spawn the same + * subtypes inside derelicts and on planets. * - * Two properties separate a planet anomaly from its parent: + * Every anomaly type can turn up, with its stock behaviour: they wander, the vortex eats + * what it reaches and the dimensional anomaly rewrites the ground around it. Planets are + * rebuilt when a crew leaves, so the damage does not outlast the visit. What differs + * from the station versions: * * - `immortal`: planets are prebuilt in the lobby and released minutes into the round, * so a normal ANOMALY_COUNTDOWN_TIMER anomaly would detonate and vanish long before - * anybody could land and look at it. Immortal also suppresses detonate(), which is - * correct here: the payoff for finding one is the core, not the explosion. - * - `move_chance = 0`: a wandering hazard cannot be routed around, and one that drifts - * into a landing site turns a considered approach into an ambush. Planet anomalies - * stay where the generator put them. + * anybody could land and look at it. Immortal also suppresses detonate(), so the + * payoff for finding one is the core, not the explosion. + * - `site_bound`: the anomaly stays on its own site (see /datum/component/anomaly_site_leash). + * It never wanders into the cordon, onto a neighbour's site on a shared z-level, onto a + * docked ship or into a planet's landing strip, and the dimensional anomaly relocates + * within the site instead of across the sector. + * - the pyroclastic anomaly spits short jets of flame instead of venting plasma. The + * stock version's gas release gives every planet turf it spreads to a private air + * mix that never goes back to the shared one (voidcrew/edits/planetary_shared_air.dm), + * so a wandering one leaves an ever-growing trail of active air. The jets are + * visual effects plus direct burns: they never touch atmos. + * - hallucination decoys stay off. Decoys are separate, unbound anomalies with their own + * timers, spawned in a cluster around the real one; on a planet they would wander off + * the site like any unbound anomaly. */ /** - * Weighted table of anomaly types a planet may seed. - * - * Deliberately not the full anomaly roster. Excluded, and why: - * - * - bioscrambler: swapped limbs need surgery, so the injury outlives the trip home. - * - vortex: eats what it reaches, and there is nothing to do about it but not be there. - * - dimensional: rewrites surrounding turfs into a themed set, which on generated - * terrain carves a chunk of somebody's station out of the landscape. - * - pyroclastic: dumps 1000K plasma into open air every few seconds. On a planet with - * planetary_atmos that is a fire with a whole biome to spread through. - * - ectoplasm: scales off orbiting ghost count, which has nothing to do with the planet. + * Weighted table of anomaly types a planet may seed, in three rarity bands that match + * the price bands Boffin sells their cores and charts in. Whether a planet gets one at + * all is a per-zone roll (ZONE_PLANET_ANOMALY_CHANCE_*, see planet_anomaly_chance()). */ GLOBAL_LIST_INIT(voidcrew_planet_anomalies, list( - /obj/effect/anomaly/flux/planetary = 30, - /obj/effect/anomaly/grav/planetary = 30, + // Common + /obj/effect/anomaly/flux/planetary = 25, + /obj/effect/anomaly/grav/planetary = 25, /obj/effect/anomaly/hallucination/planetary = 25, - /obj/effect/anomaly/bluespace/planetary = 15, + /obj/effect/anomaly/pyro/planetary = 25, + // Uncommon + /obj/effect/anomaly/bioscrambler/planetary = 12, + /obj/effect/anomaly/ectoplasm/planetary = 12, + /obj/effect/anomaly/dimensional/planetary = 12, + // Rare + /obj/effect/anomaly/bluespace/planetary = 5, + /obj/effect/anomaly/bhole/planetary = 5, )) -/// Dense, and shocks whatever touches it. The clearest of the four: it does nothing at -/// all until something makes contact with it. +/// Percent chance a planet in `zone_band` seeds its one anomaly +/proc/planet_anomaly_chance(zone_band) + switch(zone_band) + if(ZONE_YELLOW) + return ZONE_PLANET_ANOMALY_CHANCE_YELLOW + if(ZONE_RED) + return ZONE_PLANET_ANOMALY_CHANCE_RED + return ZONE_PLANET_ANOMALY_CHANCE_GREEN + +/obj/effect/anomaly + /// Whether this anomaly is leashed to the site it formed on (see anomaly_site_leash) + var/site_bound = FALSE + +// Chains onto the upstream Initialize: runs after it, before any subtype's own body. +/obj/effect/anomaly/Initialize(mapload, new_lifespan) + . = ..() + if(site_bound && . != INITIALIZE_HINT_QDEL) + AddComponent(/datum/component/anomaly_site_leash) + /obj/effect/anomaly/flux/planetary immortal = TRUE - move_chance = 0 + site_bound = TRUE -/// Pulls loose objects and unsecured people in from a few tiles out. The one with reach, -/// so it reads as a hazard at a distance rather than a trap. /obj/effect/anomaly/grav/planetary immortal = TRUE - move_chance = 0 + site_bound = TRUE -/** - * Pulses hallucinations at anyone within a few tiles. Harmless in the sense that matters - * here: it does no damage and leaves nothing behind once the crew walks away. - * - * Decoys are off. On a station they are a science minigame. Scan the cluster, find the - * one that answers. On a planet they would be three extra processing objects and three - * extra ghost points of interest per anomaly, sitting on a z-level that is prebuilt and - * then left alone for most of the round, in service of a puzzle nobody came here to do. - */ /obj/effect/anomaly/hallucination/planetary immortal = TRUE - move_chance = 0 + site_bound = TRUE spawn_decoys = FALSE -/// Dense, and throws whatever touches it a short distance. Planets are a single cordoned -/// z-level, so it can only ever relocate somebody within the same landmass. +/** + * Every few seconds it spits one to three jets of flame, three or four tiles long, + * stopped by anything dense and by the edge of its site. Anyone in a jet or standing + * next to the anomaly at the burst is set alight and takes a light burn. The flames + * are temp visuals only: no gas, no hotspots, nothing for SSair to process. + */ +/obj/effect/anomaly/pyro/planetary + immortal = TRUE + site_bound = TRUE + /// Seconds between bursts + var/burst_delay = 4 + /// Seconds since the last burst + var/burst_timer = 0 + /// Most jets in one burst + var/max_jets = 3 + /// Jet length range, in tiles + var/jet_length_min = 3 + var/jet_length_max = 4 + /// Fire stacks given to anyone caught + var/burn_stacks = 3 + /// Burn damage dealt to anyone caught + var/burn_damage = 5 + +/obj/effect/anomaly/pyro/planetary/anomalyEffect(seconds_per_tick) + // No ..(): the parent vents plasma. Only the base proc's drift is kept. +#ifndef UNIT_TESTS + if(SPT_PROB(move_chance, seconds_per_tick)) + move_anomaly() +#endif + burst_timer += seconds_per_tick + if(burst_timer < burst_delay) + return + burst_timer = 0 + spit_flames() + +/obj/effect/anomaly/pyro/planetary/proc/spit_flames() + var/turf/origin = get_turf(src) + if(!origin) + return + var/datum/component/anomaly_site_leash/leash = GetComponent(/datum/component/anomaly_site_leash) + var/list/burned = list() + for(var/turf/adjacent in range(1, origin)) + burn_turf(adjacent, burned) + var/list/directions = GLOB.alldirs.Copy() + for(var/_ in 1 to rand(1, max_jets)) + var/jet_dir = pick_n_take(directions) + var/turf/current = origin + for(var/__ in 1 to rand(jet_length_min, jet_length_max)) + var/turf/next = get_step(current, jet_dir) + if(!next || next.is_blocked_turf(exclude_mobs = TRUE)) + break + if(leash && !leash.allows(next)) + break + new /obj/effect/temp_visual/fire(next) + burn_turf(next, burned) + current = next + playsound(src, 'sound/effects/fire_puff.ogg', 50, TRUE) + +/// Sets anyone living on `target` alight, once per burst +/obj/effect/anomaly/pyro/planetary/proc/burn_turf(turf/target, list/burned) + for(var/mob/living/victim in target) + if(victim in burned) + continue + burned += victim + victim.adjust_fire_stacks(burn_stacks) + victim.ignite_mob() + victim.apply_damage(burn_damage, BURN) + +/obj/effect/anomaly/bioscrambler/planetary + immortal = TRUE + site_bound = TRUE + +/obj/effect/anomaly/ectoplasm/planetary + immortal = TRUE + site_bound = TRUE + +/obj/effect/anomaly/dimensional/planetary + immortal = TRUE + site_bound = TRUE + +/// Never converts ground off its own site: no docked hull, no cordon, no neighbour. +/obj/effect/anomaly/dimensional/planetary/prepare_area(new_theme_path) + . = ..() + var/datum/component/anomaly_site_leash/leash = GetComponent(/datum/component/anomaly_site_leash) + if(!leash) + return + for(var/turf/target as anything in target_turfs.Copy()) + if(!leash.allows(target)) + target_turfs -= target + +/// Upstream relocates to a random station area with a sector-wide announcement. Here it +/// jumps to another spot on the same site, quietly. +/obj/effect/anomaly/dimensional/planetary/relocate() + var/datum/component/anomaly_site_leash/leash = GetComponent(/datum/component/anomaly_site_leash) + var/turf/new_turf = leash?.random_open_turf() + if(new_turf) + forceMove(new_turf) + prepare_area() + /obj/effect/anomaly/bluespace/planetary immortal = TRUE - move_chance = 0 + site_bound = TRUE + +/obj/effect/anomaly/bhole/planetary + immortal = TRUE + site_bound = TRUE + +/** + * # Anomaly site leash + * + * Keeps a wandering anomaly on the site it formed on. Bounds are taken from the site's + * map footprint when the anomaly appears: the whole footprint for a planet, minus the + * landing strip along its bottom, or the ruin template's own rectangle for a space + * ruin. On top of that it never enters a ship (/area/shuttle), the cordon or open space + * (/area/space), or another z-level. + * + * Every way an anomaly moves itself (random drift, the bioscrambler's pursuit, deadchat + * steering of the ectoplasm anomaly) goes through Move(), so blocking PRE_MOVE covers + * them. The dimensional anomaly's forceMove relocation asks random_open_turf() instead. + */ +/datum/component/anomaly_site_leash + /// The z-level the anomaly formed on + var/home_z + /// Inclusive bounds, null when the anomaly formed outside any known site + var/low_x + var/low_y + var/high_x + var/high_y + +/datum/component/anomaly_site_leash/Initialize() + if(!istype(parent, /obj/effect/anomaly)) + return COMPONENT_INCOMPATIBLE + var/turf/home = get_turf(parent) + if(!home) + return COMPONENT_INCOMPATIBLE + home_z = home.z + derive_bounds(home) + +/datum/component/anomaly_site_leash/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move)) + +/datum/component/anomaly_site_leash/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE) + +/datum/component/anomaly_site_leash/proc/derive_bounds(turf/home) + var/datum/map_footprint/footprint = map_region_for_turf(home) + if(!istype(footprint)) + return + low_x = footprint.low_x + low_y = footprint.low_y + high_x = footprint.high_x + high_y = footprint.high_y + if(istype(footprint.owner, /obj/structure/overmap/planet)) + var/obj/structure/overmap/planet/planet = footprint.owner + var/strip_top = planet.get_dock_strip_top_y(footprint.level) + // An anomaly placed inside the strip by hand keeps the full footprint rather + // than being frozen in place. + if(!isnull(strip_top) && home.y > strip_top) + low_y = max(low_y, strip_top + 1) + else if(istype(footprint.owner, /obj/structure/overmap/space_ruin)) + var/obj/structure/overmap/space_ruin/ruin = footprint.owner + var/turf/corner = ruin.ruin_bottom_left + if(corner && ruin.ruin_template?.width && ruin.ruin_template?.height) + low_x = max(low_x, corner.x) + low_y = max(low_y, corner.y) + high_x = min(high_x, corner.x + ruin.ruin_template.width - 1) + high_y = min(high_y, corner.y + ruin.ruin_template.height - 1) + +/// Whether the anomaly may stand on `destination` +/datum/component/anomaly_site_leash/proc/allows(turf/destination) + if(!isturf(destination) || destination.z != home_z) + return FALSE + if(!isnull(low_x) && (destination.x < low_x || destination.x > high_x || destination.y < low_y || destination.y > high_y)) + return FALSE + if(istype(destination, /turf/cordon)) + return FALSE + var/area/destination_area = get_area(destination) + if(istype(destination_area, /area/shuttle) || istype(destination_area, /area/space)) + return FALSE + return TRUE + +/datum/component/anomaly_site_leash/proc/on_pre_move(atom/movable/source, atom/new_loc) + SIGNAL_HANDLER + if(!allows(new_loc)) + return COMPONENT_MOVABLE_BLOCK_PRE_MOVE + +/// A random open, walkable turf the anomaly may stand on, or null +/datum/component/anomaly_site_leash/proc/random_open_turf(attempts = 60) + if(isnull(low_x)) + return null + for(var/_ in 1 to attempts) + var/turf/candidate = locate(rand(low_x, high_x), rand(low_y, high_y), home_z) + if(!isopenturf(candidate) || isgroundlessturf(candidate) || candidate.density) + continue + if(!allows(candidate)) + continue + return candidate + return null diff --git a/voidcrew/edits/objects/items/event_horizon_rifle.dm b/voidcrew/edits/objects/items/event_horizon_rifle.dm new file mode 100644 index 00000000000..6039a35271e --- /dev/null +++ b/voidcrew/edits/objects/items/event_horizon_rifle.dm @@ -0,0 +1,48 @@ +// Event Horizon rifle rebalance. It is a rare loot find rather than a research print: +// three shots that never recharge, a minute between shots, on-screen range only, and a +// reality tear that eats the room but not the people in it. + +/datum/techweb_node/unregulated_bluespace + design_ids = list( + "desynchronizer", + ) + +/obj/item/gun/energy/event_horizon + selfcharge = FALSE + can_charge = FALSE + /// Time between shots. + var/shot_cooldown_time = 1 MINUTES + COOLDOWN_DECLARE(shot_cooldown) + +/obj/item/gun/energy/event_horizon/get_cell(atom/movable/interface, mob/user) + return null // Nothing may reach the cell to top it up. + +/obj/item/gun/energy/event_horizon/process_fire(atom/target, mob/living/user, message, params, zone_override, bonus_spread) + if(!COOLDOWN_FINISHED(src, shot_cooldown)) + balloon_alert(user, "ready in [round(COOLDOWN_TIMELEFT(src, shot_cooldown) / (1 SECONDS))]s!") + return + . = ..() + if(.) + COOLDOWN_START(src, shot_cooldown, shot_cooldown_time) + +/obj/item/gun/energy/event_horizon/examine(mob/user) + . = ..() + if(!COOLDOWN_FINISHED(src, shot_cooldown)) + . += span_notice("It is still cycling. It can fire again in [DisplayTimeText(COOLDOWN_TIMELEFT(src, shot_cooldown))].") + +/obj/item/ammo_casing/energy/event_horizon + e_cost = LASER_SHOTS(3, STANDARD_CELL_CHARGE) + +/obj/projectile/beam/event_horizon + range = 9 + +/obj/reality_tear/temporary/start_disaster() + . = ..() + var/datum/component/singularity/singularity = GetComponent(/datum/component/singularity) + singularity?.consume_callback = CALLBACK(src, PROC_REF(consume_without_gibbing)) + +/// Consumes everything the tear reaches except living mobs, which it only throws around. +/obj/reality_tear/temporary/proc/consume_without_gibbing(atom/thing, datum/component/singularity/source) + if(isliving(thing)) + return + thing.singularity_act(source.singularity_size, src) diff --git a/voidcrew/modules/cargo/anomaly_packs.dm b/voidcrew/modules/cargo/anomaly_packs.dm new file mode 100644 index 00000000000..86a28ed4be7 --- /dev/null +++ b/voidcrew/modules/cargo/anomaly_packs.dm @@ -0,0 +1,32 @@ +/** + * Raw anomaly cores are off the cargo catalogue. + * + * The anomaly core cap is lifted in this fork (voidcrew/modules/research/edits/ + * _experiments.dm), so a 2000 cr crate per core made them a routine order. + * Boffin at the Quartermain Depot is now the only store that sells raw cores, + * on a small rotating shelf, with anomaly charts as the other way to hunt one + * (shop_catalog_outfitter_vendors.dm, anomaly_charts.dm). + * + * SSshuttle only registers packs with a non-null `contains`. Overriding the var + * to null does not work for these: upstream sets `contains = list(...)`, and a + * list default is built at New() time, after the null has been applied, so the + * pack comes back with its contents. Clearing it in New() drops each crate from + * every console without touching the upstream definitions. + */ +GLOBAL_LIST_INIT(voidcrew_removed_supply_packs, typecacheof(list( + /datum/supply_pack/science/raw_flux_anomaly, + /datum/supply_pack/science/raw_hallucination_anomaly, + /datum/supply_pack/science/raw_grav_anomaly, + /datum/supply_pack/science/raw_vortex_anomaly, + /datum/supply_pack/science/raw_ectoplasm_anomaly, + /datum/supply_pack/science/raw_bluespace_anomaly, + /datum/supply_pack/science/raw_pyro_anomaly, + /datum/supply_pack/science/raw_bioscrambler_anomaly, + /datum/supply_pack/science/raw_dimensional_anomaly, +))) + +// Chains onto the upstream New. +/datum/supply_pack/New() + . = ..() + if(GLOB.voidcrew_removed_supply_packs[type]) + contains = null diff --git a/voidcrew/modules/dynamic_events/events/anomalies.dm b/voidcrew/modules/dynamic_events/events/anomalies.dm index 4fe5da0002a..e62e181b613 100644 --- a/voidcrew/modules/dynamic_events/events/anomalies.dm +++ b/voidcrew/modules/dynamic_events/events/anomalies.dm @@ -7,7 +7,7 @@ * * ALL OF THESE ARE ADMIN-ONLY. Every control below is weight 0 / max_occurrences 0, and * none of them roll naturally. Anomalies now generate on planet surfaces instead, see - * voidcrew/datums/mapgen/planet_anomalies.dm and the ZONE_PLANET_ANOMALY_BUDGET_* defines. + * voidcrew/datums/mapgen/planet_anomalies.dm and the ZONE_PLANET_ANOMALY_CHANCE_* defines. * * The reason is that a shuttle is the worst possible venue for one. A ship has a single * route between compartments, so an anomaly spawned in a corridor is not a hazard the diff --git a/voidcrew/modules/loot/themes/research.dm b/voidcrew/modules/loot/themes/research.dm index d3d145dd4c2..0cc73c690c6 100644 --- a/voidcrew/modules/loot/themes/research.dm +++ b/voidcrew/modules/loot/themes/research.dm @@ -43,7 +43,6 @@ /obj/item/gun/energy/laser/retro = 6, /obj/item/gun/energy/recharge/ebow = 4, /obj/item/reagent_containers/cup/beaker/noreact = 4, - /obj/item/raw_anomaly_core/random = 4, /obj/item/blueprint/gun/laser_carbine = 3, /obj/item/ship_parts/science = 7, // lab hardware worn instead of carried: a sonar transceiver socketed @@ -52,6 +51,7 @@ ) loot_prime = list( /obj/item/stack/ore/bluespace_crystal/refined = 10, + /obj/item/raw_anomaly_core/random = 4, /obj/item/stock_parts/capacitor/super = 8, /obj/item/stock_parts/micro_laser/ultra = 8, /obj/item/stock_parts/matter_bin/super = 7, @@ -89,6 +89,8 @@ /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, + // no longer researchable: three shots and no way to recharge it + /obj/item/gun/energy/event_horizon = 1, ) /obj/structure/closet/crate/zone_loot/research diff --git a/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm b/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm index 1875cd6f973..ee3ebd126c9 100644 --- a/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm +++ b/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm @@ -56,6 +56,9 @@ GLOBAL_LIST_EMPTY(space_ruin_signals) /// Rare ruins come from rumor charts, not natural seeding: tinted gold on /// the map, and cleaning one out never spawns a replacement. var/rare = FALSE + /// Spawned for one purpose (an anomaly chart's site): cleaning it out does not seed + /// a replacement, so the sector's ruin count stays where natural seeding left it. + var/no_replacement = FALSE /// Live missions currently pointed at this ruin; target picks prefer unclaimed ruins var/mission_claims = 0 /// A mission owns this ruin's lifecycle: the empty-ruin cleanup in @@ -761,7 +764,7 @@ GLOBAL_LIST_EMPTY(space_ruin_signals) // Spawn a new ruin somewhere else on the overmap BEFORE we delete ourselves. // Rare rumor ruins are one-shots: clearing one doesn't seed anything new. - if(!rare) + if(!rare && !no_replacement) spawn_replacement_ruin(old_template) // Delete this overmap object diff --git a/voidcrew/modules/shuttle/helm/_helm.dm b/voidcrew/modules/shuttle/helm/_helm.dm index 2932dcbf3b4..7fa09e83eca 100644 --- a/voidcrew/modules/shuttle/helm/_helm.dm +++ b/voidcrew/modules/shuttle/helm/_helm.dm @@ -1016,13 +1016,14 @@ var/datum/rumor_chart/chart = locate(params["chart"]) in current_ship.pending_rumors if(!chart) return - var/obj/structure/overmap/space_ruin/ruin = current_ship.reveal_pending_rumor(chart) - if(!ruin) + var/reveal_noun = chart.reveal_noun + var/obj/structure/overmap/site = current_ship.reveal_pending_rumor(chart) + if(!site) say("ERROR: Unable to pin down the rumor's coordinates. Retry shortly.") playsound(src, 'sound/machines/terminal/terminal_error.ogg', 30) return - var/list/coords = ruin.get_relative_overmap_coords() - say("Rumor decrypted: rare signal located at ([coords[1]], [coords[2]]). Charted under Rumors.") + var/list/coords = site.get_relative_overmap_coords() + say("Rumor decrypted: [reveal_noun] located at ([coords[1]], [coords[2]]). Charted under Rumors.") playsound(src, 'sound/machines/ping.ogg', 40) return if("reload_ship") diff --git a/voidcrew/modules/trade/anomaly_charts.dm b/voidcrew/modules/trade/anomaly_charts.dm new file mode 100644 index 00000000000..a11b91b0b6d --- /dev/null +++ b/voidcrew/modules/trade/anomaly_charts.dm @@ -0,0 +1,408 @@ +/** + * # Anomaly charts + * + * Boffin's other way to an anomaly core. A chart names one anomaly type. Buying + * it uploads a sealed rumor to the buyer's ship like a ruin chart; the helm's + * reveal button then picks the site, either a fresh ordinary derelict spawned in + * the chart's zone band or a terrain planet already in that band, and charts it + * under Rumors. + * + * Nothing spawns on reveal. The anomaly forms only when the BUYING hull docks at + * that site, somewhere inside the derelict or out on the planet away from the + * landing strip. A crew that gets there first finds an ordinary site. One chart + * surfaces one anomaly, then the chart is spent. + * + * If the site is cleaned up before the buyer arrives (another crew loaded and + * left a derelict, an admin deleted it), the chart goes back to the helm sealed + * and can be revealed again for a new site. It dies with the buying hull. + * + * The anomalies are the site-bound planetary subtypes from + * voidcrew/datums/mapgen/planet_anomalies.dm: immortal, stock behaviour, leashed + * to the site they formed on. + */ + +/// How far from the buyer's docking port a derelict anomaly prefers to form +#define ANOMALY_CHART_RUIN_MIN_DISTANCE 6 +/// Rows of ground kept clear between a planet's landing strip and a chart anomaly +#define ANOMALY_CHART_PLANET_CLEARANCE 12 +/// Random ground samples tried on a planet before giving up for this arrival +#define ANOMALY_CHART_PLANET_ATTEMPTS 400 + +/obj/structure/overmap/ship + /// Revealed anomaly charts still waiting for this ship to dock at their site + var/list/datum/rumor_chart/anomaly/active_anomaly_charts = list() + +/datum/rumor_chart/anomaly + name = "anomaly chart" + reveal_noun = "anomaly site" + keep_after_reveal = TRUE + spawn_zone = ZONE_YELLOW + /// The site-bound anomaly this chart surfaces + var/obj/effect/anomaly/anomaly_type + /// Percent chance the reveal tries a planet before a derelict + var/planet_chance = 50 + /// The ship that revealed the chart; only its arrival surfaces the anomaly + var/obj/structure/overmap/ship/buyer + /// The revealed site + var/datum/weakref/site_ref + /// Set once the anomaly exists + var/surfaced = FALSE + +/datum/rumor_chart/anomaly/Destroy() + release_site() + if(buyer) + UnregisterSignal(buyer, list(COMSIG_VOIDCREW_SHIP_DOCKED, COMSIG_QDELETING)) + buyer.active_anomaly_charts -= src + buyer.pending_rumors -= src + buyer = null + return ..() + +/datum/rumor_chart/anomaly/proc/waypoint_key() + return "anomaly_[REF(src)]" + +/datum/rumor_chart/anomaly/reveal(obj/structure/overmap/ship/ship) + if(QDELETED(ship) || revealed || !ispath(anomaly_type, /obj/effect/anomaly)) + return null + var/obj/structure/overmap/site = pick_site(ship) + if(!site) + return null + + revealed = TRUE + buyer = ship + site_ref = WEAKREF(site) + ship.active_anomaly_charts |= src + RegisterSignal(ship, COMSIG_VOIDCREW_SHIP_DOCKED, PROC_REF(on_buyer_docked)) + RegisterSignal(ship, COMSIG_QDELETING, PROC_REF(on_buyer_deleted)) + RegisterSignal(site, COMSIG_QDELETING, PROC_REF(on_site_deleted)) + + var/list/coords = site.get_relative_overmap_coords() + ship.add_waypoint(waypoint_key(), name, coords[1], coords[2], "Rumors", site) + log_game("Anomaly chart '[name]' revealed for [ship]: [site] ([site.type]) at ([coords[1]], [coords[2]])") + return site + +/** + * A planet or a fresh derelict in the chart's band, in random order, falling back + * to the other kind when the first has nothing to offer. + */ +/datum/rumor_chart/anomaly/proc/pick_site(obj/structure/overmap/ship/ship) + var/planet_first = prob(planet_chance) + var/obj/structure/overmap/site = planet_first ? pick_planet(ship) : spawn_derelict() + if(site) + return site + return planet_first ? spawn_derelict() : pick_planet(ship) + +/// A terrain planet in the chart's band that the buyer is not already docked at +/datum/rumor_chart/anomaly/proc/pick_planet(obj/structure/overmap/ship/ship) + var/list/candidates = list() + for(var/obj/structure/overmap/planet/planet as anything in GLOB.overmap_planets) + if(QDELETED(planet) || planet.unloading || !planet.is_terrain_planet()) + continue + if(!istype(get_turf(planet), /turf/open/overmap)) + continue + if(planet.get_effective_zone_band() != spawn_zone) + continue + // The anomaly waits for an arrival, so the site has to be somewhere to arrive at + if(ship.docked == planet) + continue + candidates += planet + if(!length(candidates)) + return null + return pick(candidates) + +/// A fresh ordinary derelict in the chart's band, left unloaded like any other signal +/datum/rumor_chart/anomaly/proc/spawn_derelict() + var/list/live_templates = list() + for(var/obj/structure/overmap/space_ruin/live as anything in GLOB.space_ruin_signals) + if(!QDELETED(live) && live.ruin_template) + live_templates[live.ruin_template] = TRUE + + var/list/weighted = list() + var/list/weighted_duplicates = list() + for(var/template_id in SSmapping.space_ruins_templates) + var/datum/map_template/ruin/space/template = SSmapping.space_ruins_templates[template_id] + if(!istype(template) || template.unpickable) + continue + // Oversized ruins take a whole z-level; keep charts to the ones that pack + if(template.width && !SSovermap.ruin_fits_in_slot(template)) + continue + if(live_templates[template]) + if(template.allow_duplicates) + weighted_duplicates[template] = template.placement_weight || 1 + continue + weighted[template] = template.placement_weight || 1 + if(!length(weighted)) + weighted = weighted_duplicates + if(!length(weighted)) + return null + + var/turf/spawn_turf = SSovermap.get_unused_overmap_square_in_zone_band(spawn_zone) + if(!spawn_turf) + return null + var/obj/structure/overmap/space_ruin/ruin = new(spawn_turf) + ruin.set_ruin_template(pick_weight(weighted)) + ruin.no_replacement = TRUE + return ruin + +/datum/rumor_chart/anomaly/proc/on_buyer_docked(obj/structure/overmap/ship/source) + SIGNAL_HANDLER + if(surfaced) + return + var/obj/structure/overmap/site = site_ref?.resolve() + if(!site || source.docked != site) + return + INVOKE_ASYNC(src, PROC_REF(surface_anomaly), site) + +/** + * The buyer has docked at the site: put the anomaly somewhere on it and tell the + * crew. If no spot is found the chart stays live for the next arrival. + */ +/datum/rumor_chart/anomaly/proc/surface_anomaly(obj/structure/overmap/site) + if(surfaced || QDELETED(src) || QDELETED(site) || QDELETED(buyer) || buyer.docked != site) + return FALSE + var/turf/spawn_turf = find_spawn_turf(site) + if(!spawn_turf) + buyer.ship_notify("Faint anomalous readings near [site.name], too unsteady to fix. They may settle on your next approach.", "SENSORS", SHIP_NOTIFY_WARNING, 'voidcrew/sound/warn.ogg', 25) + return FALSE + + surfaced = TRUE + var/obj/effect/anomaly/anomaly = new anomaly_type(spawn_turf) + var/where = describe_position(spawn_turf) + buyer.ship_notify("Anomalous readings: \a [anomaly] has formed [where].", "SENSORS", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) + log_game("Anomaly chart '[name]' surfaced [anomaly] at [AREACOORD(spawn_turf)] for [buyer]") + qdel(src) + return TRUE + +/// "about 30 metres north of the ship", from the buyer's docking port +/datum/rumor_chart/anomaly/proc/describe_position(turf/spawn_turf) + var/turf/ship_turf = get_turf(buyer?.shuttle) + if(!ship_turf || ship_turf.z != spawn_turf.z) + return "on the site" + var/distance = get_dist(ship_turf, spawn_turf) + var/direction = dir2text(get_dir(ship_turf, spawn_turf)) + if(!direction || distance < 2) + return "right by the ship" + return "about [distance] metres [direction] of the ship" + +/datum/rumor_chart/anomaly/proc/find_spawn_turf(obj/structure/overmap/site) + if(istype(site, /obj/structure/overmap/space_ruin)) + return find_derelict_turf(site) + if(istype(site, /obj/structure/overmap/planet)) + return find_planet_turf(site) + return null + +/// Whether an anomaly may form on `candidate`: open walkable ground on the site itself +/datum/rumor_chart/anomaly/proc/is_valid_spot(turf/candidate, datum/map_footprint/footprint) + if(!isopenturf(candidate) || isgroundlessturf(candidate) || isspaceturf(candidate)) + return FALSE + if(islava(candidate) || istype(candidate, /turf/open/water)) + return FALSE + if(candidate.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + var/area/candidate_area = get_area(candidate) + if(istype(candidate_area, /area/shuttle) || istype(candidate_area, /area/space)) + return FALSE + if(footprint && !footprint.contains_turf(candidate)) + return FALSE + if(locate(/obj/effect/anomaly) in candidate) + return FALSE + return TRUE + +/// Open floor inside the derelict's own template, preferring a few steps from the ship +/datum/rumor_chart/anomaly/proc/find_derelict_turf(obj/structure/overmap/space_ruin/ruin) + var/turf/corner = ruin.ruin_bottom_left + if(!ruin.loaded || !corner || !ruin.ruin_template?.width || !ruin.ruin_template?.height) + return null + var/turf/far_corner = locate(corner.x + ruin.ruin_template.width - 1, corner.y + ruin.ruin_template.height - 1, corner.z) + if(!far_corner) + return null + var/turf/ship_turf = get_turf(buyer?.shuttle) + var/list/near = list() + var/list/far = list() + for(var/turf/candidate as anything in block(corner, far_corner)) + if(!is_valid_spot(candidate, ruin.footprint)) + continue + if(ship_turf && get_dist(candidate, ship_turf) < ANOMALY_CHART_RUIN_MIN_DISTANCE) + near += candidate + else + far += candidate + CHECK_TICK + if(length(far)) + return pick(far) + if(length(near)) + return pick(near) + return null + +/// Open ground on the planet, well clear of the landing strip +/datum/rumor_chart/anomaly/proc/find_planet_turf(obj/structure/overmap/planet/planet) + var/datum/map_footprint/footprint = planet.footprint + if(!planet.loaded || !footprint || isnull(footprint.low_x)) + return null + var/strip_top = planet.get_dock_strip_top_y(footprint.level) + if(isnull(strip_top)) + strip_top = footprint.low_y + var/min_x = footprint.low_x + 3 + var/max_x = footprint.high_x - 3 + var/max_y = footprint.high_y - 3 + var/min_y = min(strip_top + ANOMALY_CHART_PLANET_CLEARANCE, max_y) + min_y = max(min_y, strip_top + 1) + if(min_x > max_x || min_y > max_y) + return null + for(var/_ in 1 to ANOMALY_CHART_PLANET_ATTEMPTS) + var/turf/candidate = locate(rand(min_x, max_x), rand(min_y, max_y), footprint.z_value) + if(is_valid_spot(candidate, footprint)) + return candidate + return null + +/datum/rumor_chart/anomaly/proc/on_buyer_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/rumor_chart/anomaly/proc/on_site_deleted(datum/source) + SIGNAL_HANDLER + if(surfaced) + return + INVOKE_ASYNC(src, PROC_REF(reseal)) + +/// Forgets the site, drops the waypoint and stops listening to the site +/datum/rumor_chart/anomaly/proc/release_site() + var/obj/structure/overmap/site = site_ref?.resolve() + if(site) + UnregisterSignal(site, COMSIG_QDELETING) + site_ref = null + if(buyer) + var/datum/ship_waypoint/waypoint = buyer.get_waypoint(waypoint_key()) + if(waypoint) + buyer.delete_waypoint(waypoint) + +/** + * The site vanished before the buyer arrived. The chart goes back on the helm, + * sealed, so the crew can reveal it again for a new site. + */ +/datum/rumor_chart/anomaly/proc/reseal() + if(QDELETED(src) || surfaced || QDELETED(buyer)) + return + var/obj/structure/overmap/ship/ship = buyer + release_site() + UnregisterSignal(ship, list(COMSIG_VOIDCREW_SHIP_DOCKED, COMSIG_QDELETING)) + ship.active_anomaly_charts -= src + buyer = null + revealed = FALSE + ship.pending_rumors |= src + ship.ship_notify("The site on the \"[name]\" chart has dropped off the scopes. The chart is sealed again and can be revealed from the helm for a new fix.", "HELM", SHIP_NOTIFY_WARNING, 'voidcrew/sound/warn.ogg', 25) + +/** + * # Anomaly chart SKU + * + * Sold on Boffin's chart rotation. No goods change hands: the sealed chart goes + * straight to the buyer's ship, and its reveal and arrival do the rest. Stock is + * one, and a sold chart's slot turns over to a different anomaly at the next + * convoy rather than refilling (see /datum/shop_rotation). + */ +/datum/shop_sku/anomaly_chart + category = "Anomalies" + icon_override = 'icons/obj/scrolls.dmi' + icon_state_override = "blueprints" + is_chart = TRUE + stock_min = 1 + stock_max = 1 + contract_reward = FALSE + /// The site-bound anomaly the chart surfaces + var/obj/effect/anomaly/anomaly_type + /// Zone band the chart's site is found in + var/spawn_zone = ZONE_YELLOW + +/datum/shop_sku/anomaly_chart/New() + . = ..() + if(!anomaly_type) + return + var/anomaly_name = initial(anomaly_type.name) + var/band_name = spawn_zone == ZONE_RED ? ZONE_NAME_RED : ZONE_NAME_YELLOW + name = "anomaly chart: [anomaly_name]" + desc = "Sensor logs on \a [anomaly_name] settling somewhere in the [band_name], either aboard a derelict or out on a planet. Uploaded sealed to your helm. The readings only firm up once your own ship is docked at the site." + +/datum/shop_sku/anomaly_chart/get_denial_reason(mob/living/user) + . = ..() + if(.) + return + if(!get_crew_ship(user)) + return "No crew registration, you need a ship to upload the chart to." + +/datum/shop_sku/anomaly_chart/try_purchase(mob/living/user, mob/living/basic/outpost_trader/vendor) + if(stock <= 0) + return FALSE + var/obj/structure/overmap/ship/ship = get_crew_ship(user) + if(!ship) + return FALSE + + var/credit_price = get_credit_price(user) + var/datum/bank_account/account + if(credit_price > 0) + account = get_account(user) + if(!account || !account.has_money(credit_price)) + return FALSE + + if(price_vouchers > 0 && !consume_trade_vouchers(user, price_vouchers)) + return FALSE + if(credit_price > 0 && !account.adjust_money(-credit_price, "Trader Outpost: [name]")) + return FALSE + + stock-- + ship.add_pending_rumor(create_chart()) + to_chat(user, span_notice("The chart is encrypted and beamed to [ship]'s helm console. Reveal it when your crew is ready to move.")) + return TRUE + +/// A fresh sealed chart for one purchase +/datum/shop_sku/anomaly_chart/proc/create_chart() + var/datum/rumor_chart/anomaly/chart = new + var/anomaly_name = initial(anomaly_type.name) + chart.name = capitalize(anomaly_name) + chart.desc = "[capitalize("\a [anomaly_name]")] in the [spawn_zone == ZONE_RED ? ZONE_NAME_RED : ZONE_NAME_YELLOW]. Nothing will show on the site until your ship docks there." + chart.anomaly_type = anomaly_type + chart.spawn_zone = spawn_zone + return chart + +// Three bands, the same as the raw cores, at half the core's list price. Normal +// favor discounts and specials apply. +// The two rarest point into the lawless deep. + +/datum/shop_sku/anomaly_chart/flux + anomaly_type = /obj/effect/anomaly/flux/planetary + price_credits = 12500 + +/datum/shop_sku/anomaly_chart/grav + anomaly_type = /obj/effect/anomaly/grav/planetary + price_credits = 12500 + +/datum/shop_sku/anomaly_chart/hallucination + anomaly_type = /obj/effect/anomaly/hallucination/planetary + price_credits = 12500 + +/datum/shop_sku/anomaly_chart/pyro + anomaly_type = /obj/effect/anomaly/pyro/planetary + price_credits = 12500 + +/datum/shop_sku/anomaly_chart/bioscrambler + anomaly_type = /obj/effect/anomaly/bioscrambler/planetary + price_credits = 15000 + +/datum/shop_sku/anomaly_chart/ectoplasm + anomaly_type = /obj/effect/anomaly/ectoplasm/planetary + price_credits = 15000 + +/datum/shop_sku/anomaly_chart/dimensional + anomaly_type = /obj/effect/anomaly/dimensional/planetary + price_credits = 15000 + +/datum/shop_sku/anomaly_chart/bluespace + anomaly_type = /obj/effect/anomaly/bluespace/planetary + price_credits = 17500 + spawn_zone = ZONE_RED + +/datum/shop_sku/anomaly_chart/vortex + anomaly_type = /obj/effect/anomaly/bhole/planetary + price_credits = 17500 + spawn_zone = ZONE_RED + +#undef ANOMALY_CHART_RUIN_MIN_DISTANCE +#undef ANOMALY_CHART_PLANET_CLEARANCE +#undef ANOMALY_CHART_PLANET_ATTEMPTS diff --git a/voidcrew/modules/trade/rumor_charts.dm b/voidcrew/modules/trade/rumor_charts.dm index b8ae9634427..bc8b43251ea 100644 --- a/voidcrew/modules/trade/rumor_charts.dm +++ b/voidcrew/modules/trade/rumor_charts.dm @@ -31,6 +31,11 @@ var/spawn_zone = ZONE_RED /// One successful reveal per paid chart; failed placement remains retryable. var/revealed = FALSE + /// What the helm calls the revealed target in its readout + var/reveal_noun = "rare signal" + /// Charts with work left after the reveal (anomaly charts wait for the crew to + /// arrive) manage their own lifetime instead of being deleted on reveal. + var/keep_after_reveal = FALSE /** * Spawns the chart's ruin at an unused square in the chart's zone band and @@ -78,18 +83,19 @@ ship_notify("Encrypted rumor data received: \"[chart.name]\". Reveal it from the helm console when ready.", "HELM", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) /** - * Reveals a pending rumor: spawns its ruin, charts the waypoint and consumes - * the sealed entry. Returns the ruin, or null on failure (entry is kept). + * Reveals a pending rumor: spawns or picks its site, charts the waypoint and + * consumes the sealed entry. Returns the site, or null on failure (entry is kept). */ /obj/structure/overmap/ship/proc/reveal_pending_rumor(datum/rumor_chart/chart) if(!(chart in pending_rumors)) return null - var/obj/structure/overmap/space_ruin/ruin = chart.reveal(src) - if(!ruin) + var/obj/structure/overmap/site = chart.reveal(src) + if(!site) return null pending_rumors -= chart - qdel(chart) - return ruin + if(!chart.keep_after_reveal) + qdel(chart) + return site /** * Gets an unused overmap square in the given zone band (ZONE_RED/YELLOW/GREEN). diff --git a/voidcrew/modules/trade/shop.dm b/voidcrew/modules/trade/shop.dm index 8fd29a88169..68683a4d93a 100644 --- a/voidcrew/modules/trade/shop.dm +++ b/voidcrew/modules/trade/shop.dm @@ -10,6 +10,9 @@ * - CORE: the sku_types list, stocked every round. * - ROTATING: a few picks rolled from rotating_pool each round, limited supply. * - RARE: 1-2 showcase picks rolled from rare_pool, single unit each. + * - Weighted rotations (/datum/shop_rotation): small dedicated shelves with a + * weighted pool of their own. Every sold-out slot swaps for a different pick + * on convoy restock, so scarce goods never refill in place. * One credit-priced SKU per round also goes on special (discount_pct). * * Vouchers are deliberately the only road to the best stock ("the currency @@ -68,6 +71,10 @@ var/list/chart_pool = list() /// How many chart picks land on the shelf per round var/chart_picks = 3 + /// /datum/shop_rotation typepaths: dedicated weighted shelves (see below) + var/list/rotations = list() + /// Live rotation instances, one per entry in `rotations` + var/list/datum/shop_rotation/live_rotations = list() /// Live SKU instances (hold the shared per-round stock) var/list/datum/shop_sku/skus = list() /// Buyback typepaths: what this trader buys from players (see shop_buyback.dm) @@ -106,6 +113,11 @@ // Each outpost deals its own chart shelf. Separate purchases reveal fresh sites. for(var/sku_type in deal_chart_picks()) add_sku(sku_type, SHELF_ROTATING) + for(var/rotation_type in rotations) + var/datum/shop_rotation/rotation = new rotation_type + live_rotations += rotation + for(var/sku_type in rotation.deal(rotation.slots)) + add_rotation_sku(rotation, sku_type) roll_special() for(var/buyback_type in buyback_types) buybacks += new buyback_type @@ -113,6 +125,7 @@ /datum/outpost_shop/Destroy() QDEL_LIST(skus) QDEL_LIST(buybacks) + QDEL_LIST(live_rotations) outpost = null trader_npc = null return ..() @@ -162,6 +175,42 @@ sku.stock = FAVOR_UNIQUE_CREW_LIMIT skus += sku +/** + * Puts a SKU dealt by `rotation` on the rotating shelf and tags it, so the + * convoy swaps it through the rotation rather than the ordinary rotating pool. + */ +/datum/outpost_shop/proc/add_rotation_sku(datum/shop_rotation/rotation, sku_type) + add_sku(sku_type, SHELF_ROTATING) + var/datum/shop_sku/sku = skus[length(skus)] + sku.rotation = rotation + return sku + +/** + * Every sold-out rotation slot is replaced with a different weighted pick from + * the same rotation. Nothing refills in place: the shelf only ever shows what + * the convoy happened to bring. + */ +/datum/outpost_shop/proc/restock_rotations() + for(var/datum/shop_rotation/rotation as anything in live_rotations) + var/list/sold_out = list() + var/list/on_shelf = list() + for(var/datum/shop_sku/sku as anything in skus) + if(sku.rotation != rotation) + continue + on_shelf += sku.type + if(sku.stock <= 0) + sold_out += sku + for(var/datum/shop_sku/gone as anything in sold_out) + skus -= gone + qdel(gone) + // The sold type and everything still on the shelf are held back, so + // a slot always turns over into something different + var/replacement = rotation.pick_one(on_shelf) + if(!replacement) + continue + on_shelf += replacement + add_rotation_sku(rotation, replacement) + /** * Puts one random credit-priced SKU on special for the round (15-30% off). */ @@ -173,6 +222,8 @@ // get_credit_price if(sku.shelf == SHELF_FAVOR) continue + if(sku.fixed_price) + continue if(sku.price_credits > 0 && !sku.discount_pct) eligible += sku if(!length(eligible)) @@ -200,7 +251,7 @@ // a sold tip for an unrelated good. Named tips replenish separately above. var/list/depleted = list() for(var/datum/shop_sku/sku as anything in skus) - if(sku.shelf == SHELF_ROTATING && !sku.is_chart && sku.stock <= 0) + if(sku.shelf == SHELF_ROTATING && !sku.is_chart && !sku.rotation && sku.stock <= 0) depleted += sku if(length(depleted)) var/datum/shop_sku/gone = pick(depleted) @@ -211,6 +262,9 @@ qdel(gone) add_sku(length(unstocked) ? pick(unstocked) : pick(rotating_pool), SHELF_ROTATING) + // Weighted rotations turn their sold-out slots over + restock_rotations() + // Yesterday's special is over; roll a new one for(var/datum/shop_sku/sku as anything in skus) sku.discount_pct = initial(sku.discount_pct) @@ -284,6 +338,9 @@ // contract's pay bundle, that would leak the back room past its gate if(sku.shelf == SHELF_FAVOR) continue + // Scarce stock (anomaly cores) is sold over the counter only + if(!sku.contract_reward) + continue var/worth = get_sku_value(sku) if(worth <= 0) continue @@ -422,6 +479,12 @@ var/icon_state_override /// Cached base64 icon for the UI, computed once per instance var/cached_icon + /// The weighted rotation that dealt this SKU, if any (see /datum/shop_rotation) + var/datum/shop_rotation/rotation + /// Ignores specials and crew favor discounts: the price on the tag is the price + var/fixed_price = FALSE + /// Whether outpost contracts may pay this out as part of a reward bundle + var/contract_reward = TRUE /datum/shop_sku/New() ..() @@ -435,6 +498,7 @@ /datum/shop_sku/Destroy() shop = null + rotation = null return ..() /** @@ -444,7 +508,7 @@ * Without a buyer (catalog/static contexts) only the authored special shows. */ /datum/shop_sku/proc/get_credit_price(mob/living/user) - if(price_credits <= 0) + if(price_credits <= 0 || fixed_price) return price_credits var/pct = discount_pct if(user && shop) @@ -675,5 +739,48 @@ return team.ship return null +/** + * # Shop rotation + * + * A small dedicated shelf with its own weighted pool, for stock that should be + * scarce and changing rather than always on hand. `slots` distinct picks go up + * when the shop opens, each with whatever stock its SKU rolls (keep those at + * 1 for one-off goods). On every convoy restock each sold-out slot swaps for a + * different weighted pick (see /datum/outpost_shop/proc/restock_rotations). + * + * Kept apart from rotating_pool so a shop's ordinary rotating shelf is never + * diluted by it. A shop lists rotation typepaths in its `rotations` var. + */ +/datum/shop_rotation + /// SKU typepath -> weight. Higher weight turns up more often. + var/list/pool = list() + /// How many picks sit on the shelf at once + var/slots = 2 + +/** + * Up to `count` distinct weighted picks from the pool. + */ +/datum/shop_rotation/proc/deal(count) + var/list/picked = list() + for(var/_ in 1 to count) + var/choice = pick_one(picked) + if(!choice) + break + picked += choice + return picked + +/** + * One weighted pick, skipping anything in `exclude`. Null when nothing is left. + */ +/datum/shop_rotation/proc/pick_one(list/exclude) + var/list/candidates = list() + for(var/sku_type in pool) + if(exclude && (sku_type in exclude)) + continue + candidates[sku_type] = max(1, pool[sku_type]) + if(!length(candidates)) + return null + return pick_weight(candidates) + // The rumor SKU and every other chart line now live in shop_catalog_charts.dm, // keeping this file to machinery as its header describes. diff --git a/voidcrew/modules/trade/shop_catalog_outfitter_vendors.dm b/voidcrew/modules/trade/shop_catalog_outfitter_vendors.dm index 9640fdcd4df..44d0d6dbedc 100644 --- a/voidcrew/modules/trade/shop_catalog_outfitter_vendors.dm +++ b/voidcrew/modules/trade/shop_catalog_outfitter_vendors.dm @@ -13,6 +13,12 @@ * - Boffin's tier-2 parts and cells offer immediate upgrades while a crew is * still assembling or researching its fabrication equipment. Cargo remains * cheaper in bulk, and a working laboratory rewards making parts aboard ship. + * - Boffin is the only store that sells raw anomaly cores (cargo no longer + * carries them) and anomaly charts. Both sit on small weighted rotations of + * two slots, one unit each. A sold slot turns over to a different type at + * the next convoy. Both take the normal favor discounts and specials. The + * 400 cr core buyback is far below any core's price, so buying to resell + * loses money. * - The buyback ledger only wants what a ship can't print: anomaly cores, * slime extracts, and raw exotics out of the ground. No circuit boards. * Anything a lathe spits out is a money loop waiting to happen. @@ -50,6 +56,7 @@ "Gadgetry", "Ship Retrofit", "Power & Optics", + "Anomalies", "Job Packs", ) sku_types = list( @@ -84,6 +91,10 @@ /datum/shop_sku/skunk/rare/deluxe_parts, /datum/shop_sku/skunk/rare/hyper_cell, ) + rotations = list( + /datum/shop_rotation/skunk_cores, + /datum/shop_rotation/skunk_anomaly_charts, + ) buyback_types = list( /datum/shop_buyback/skunk/anomaly_core, /datum/shop_buyback/skunk/slime_extract, @@ -259,6 +270,81 @@ item_path = /obj/item/stock_parts/power_store/cell/hyper price_credits = 1200 +// ===== ANOMALIES ===== +// Raw cores still need the refinery and a bomb. Rarer types turn up less often +// and cost more. The charts for the same types live in anomaly_charts.dm. + +/datum/shop_rotation/skunk_cores + slots = 2 + pool = list( + /datum/shop_sku/skunk/anomaly_core/flux = 6, + /datum/shop_sku/skunk/anomaly_core/grav = 6, + /datum/shop_sku/skunk/anomaly_core/hallucination = 6, + /datum/shop_sku/skunk/anomaly_core/pyro = 6, + /datum/shop_sku/skunk/anomaly_core/bioscrambler = 3, + /datum/shop_sku/skunk/anomaly_core/ectoplasm = 3, + /datum/shop_sku/skunk/anomaly_core/dimensional = 3, + /datum/shop_sku/skunk/anomaly_core/bluespace = 1, + /datum/shop_sku/skunk/anomaly_core/vortex = 1, + ) + +/datum/shop_rotation/skunk_anomaly_charts + slots = 2 + pool = list( + /datum/shop_sku/anomaly_chart/flux = 6, + /datum/shop_sku/anomaly_chart/grav = 6, + /datum/shop_sku/anomaly_chart/hallucination = 6, + /datum/shop_sku/anomaly_chart/pyro = 6, + /datum/shop_sku/anomaly_chart/bioscrambler = 3, + /datum/shop_sku/anomaly_chart/ectoplasm = 3, + /datum/shop_sku/anomaly_chart/dimensional = 3, + /datum/shop_sku/anomaly_chart/bluespace = 1, + /datum/shop_sku/anomaly_chart/vortex = 1, + ) + +/// Raw cores: normal favor discounts and specials apply; contracts never pay them out +/datum/shop_sku/skunk/anomaly_core + category = "Anomalies" + stock_min = 1 + stock_max = 1 + contract_reward = FALSE + +/datum/shop_sku/skunk/anomaly_core/flux + item_path = /obj/item/raw_anomaly_core/flux + price_credits = 25000 + +/datum/shop_sku/skunk/anomaly_core/grav + item_path = /obj/item/raw_anomaly_core/grav + price_credits = 25000 + +/datum/shop_sku/skunk/anomaly_core/hallucination + item_path = /obj/item/raw_anomaly_core/hallucination + price_credits = 25000 + +/datum/shop_sku/skunk/anomaly_core/pyro + item_path = /obj/item/raw_anomaly_core/pyro + price_credits = 25000 + +/datum/shop_sku/skunk/anomaly_core/bioscrambler + item_path = /obj/item/raw_anomaly_core/bioscrambler + price_credits = 30000 + +/datum/shop_sku/skunk/anomaly_core/ectoplasm + item_path = /obj/item/raw_anomaly_core/ectoplasm + price_credits = 30000 + +/datum/shop_sku/skunk/anomaly_core/dimensional + item_path = /obj/item/raw_anomaly_core/dimensional + price_credits = 30000 + +/datum/shop_sku/skunk/anomaly_core/bluespace + item_path = /obj/item/raw_anomaly_core/bluespace + price_credits = 35000 + +/datum/shop_sku/skunk/anomaly_core/vortex + item_path = /obj/item/raw_anomaly_core/vortex + price_credits = 35000 + // ===== BOFFIN'S SPECIMEN LEDGER (buybacks) ===== // Nothing a lathe can print. Planet exotics and things that glow wrong. diff --git a/wiki/content/research.md b/wiki/content/research.md index c2821893582..da7a80ef113 100644 --- a/wiki/content/research.md +++ b/wiki/content/research.md @@ -94,7 +94,7 @@ A better **micro-laser** cuts the power draw, which matters: a scanner on the ch Feed a **refined anomaly core** to the destructive analyzer and it pays **200 points**. Nine core types exist and each pays once per techweb, so the full set is 1,800. Load an item and the analyzer tells you what it is worth before you pull the trigger. -Getting there is about 240 points of bluespace research: Bluespace Theory, Applied Bluespace, then **Anomaly Research** for the refinery. Raw cores come from anomalies that turn up aboard your ship as a [dynamic event](dynamic-events.md), and from research-themed loot caches. +Getting there is about 240 points of bluespace research: Bluespace Theory, Applied Bluespace, then **Anomaly Research** for the refinery. Raw cores come from Boffin at Quartermain Depot (see [Trader Outposts](trader-outposts.md)) and from research-themed loot caches. Neutralizing an anomaly on a [planet](planets.md) or at an anomaly chart's site gives you a finished core directly. Cores are the only items that pay research points when deconstructed. diff --git a/wiki/content/trader-outposts.md b/wiki/content/trader-outposts.md index f151690cc0e..b8d5474f344 100644 --- a/wiki/content/trader-outposts.md +++ b/wiki/content/trader-outposts.md @@ -45,7 +45,7 @@ Each outpost's concourse has its own side vendors, each with their own shopkeep | Halcyon | The Potting Shed | Fern | Seeds, growing supplies, the botany starter pack | | Halcyon | Pike's Bait & Tackle | Pike | Rods, bait and tackle, beside an indoor pond | | Halcyon | The Chowder Pot | Roux | The diner. Hot meals in tiers, galley staples, buys home cooking | -| Quartermain | The Skunkworks | Boffin | Research and fabrication gear, robotics and xenobiology starter packs | +| Quartermain | The Skunkworks | Boffin | Research and fabrication gear, robotics and xenobiology starter packs, raw anomaly cores and anomaly charts | | Quartermain | The Fitting Bay | Wick | Civilian through advanced MODsuits | | The Undertow | The Dregs | Dram | The cantina | | The Undertow | Patch-Up Clinic | Sawbones | Medical supplies, the genetics starter pack | @@ -96,6 +96,18 @@ Sold ruin charts restock with supply convoys. Buying another gives you another f More on what is inside them is on [Ruins](ruins.md). +### Anomaly cores and charts + +Boffin at the Skunkworks is the only place that sells **raw anomaly cores**. Cargo does not carry them. He has two on the shelf at a time, one of each. When one sells, the next convoy brings a different type. Rare types turn up less often. Raw cores still need the refinery and a bomb. Standing discounts and specials apply to cores and charts as usual (the better of the two, never both). + +Boffin also sells **anomaly charts** the same way. A chart names one anomaly. It uploads to your helm sealed, like a ruin chart, and the reveal marks either a derelict or a planet in the named band. The anomaly only forms once **your own ship** docks there. Another crew that gets there first finds nothing. Neutralize it for its core. If the site vanishes before you arrive, the chart goes back on your helm sealed and you can reveal it again. The chart is lost if your ship is. + +| Anomalies | Raw core | Chart | Chart band | +|---|---|---|---| +| Flux, gravitational, hallucination, pyroclastic | 25000 cr | 12500 cr | Contested | +| Bioscrambler, ectoplasm, dimensional | 30000 cr | 15000 cr | Contested | +| Bluespace, vortex | 35000 cr | 17500 cr | Lawless | + ## Trader contracts The main trader's **Contracts** menu is a second, separate mission board. Four offers stay posted, and they pay in **goods off that trader's own shelves** rather than money, which makes them worth taking when your account is empty. From ca0b2b6c90a9b6d4c10ae7bed14c5f53529ec73b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:18:32 -0700 Subject: [PATCH 2/3] Keep the Event Horizon scope, shorten its cooldown and block emitters The rifle keeps its upstream scope and range again, with 20 seconds between shots instead of a minute. Whoever the beam hits takes enough burn damage to die without being gibbed. The rifle can no longer be mounted in an emitter or turret, which fired it on their own power and skipped the shot limit and cooldown. --- .../projectiles/guns/energy/beam_rifle.dm | 10 ++++++++-- .../edits/objects/items/event_horizon_rifle.dm | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/code/modules/projectiles/guns/energy/beam_rifle.dm b/code/modules/projectiles/guns/energy/beam_rifle.dm index 198e5bfa9df..e5e36337ae2 100644 --- a/code/modules/projectiles/guns/energy/beam_rifle.dm +++ b/code/modules/projectiles/guns/energy/beam_rifle.dm @@ -21,10 +21,16 @@ selfcharge = TRUE self_charge_amount = STANDARD_ENERGY_GUN_SELF_CHARGE_RATE * 10 -// VOIDCREW EDIT REMOVAL: scope component and "must be scoped" check. It fires on screen -// only; see voidcrew/edits/objects/items/event_horizon_rifle.dm. +/obj/item/gun/energy/event_horizon/Initialize(mapload) + . = ..() + AddComponent(/datum/component/scope, range_modifier = 4) /obj/item/gun/energy/event_horizon/process_fire(atom/target, mob/living/user, message, params, zone_override, bonus_spread) + + if(!HAS_TRAIT(user, TRAIT_USER_SCOPED)) + balloon_alert(user, "must be scoped!") + return + . = ..() message_admins("[ADMIN_LOOKUPFLW(user)] has fired an anti-existential beam at [ADMIN_VERBOSEJMP(user)].") diff --git a/voidcrew/edits/objects/items/event_horizon_rifle.dm b/voidcrew/edits/objects/items/event_horizon_rifle.dm index 6039a35271e..4f366f7d38a 100644 --- a/voidcrew/edits/objects/items/event_horizon_rifle.dm +++ b/voidcrew/edits/objects/items/event_horizon_rifle.dm @@ -1,6 +1,6 @@ // Event Horizon rifle rebalance. It is a rare loot find rather than a research print: -// three shots that never recharge, a minute between shots, on-screen range only, and a -// reality tear that eats the room but not the people in it. +// three shots that never recharge and 20 seconds between shots. Whoever it hits dies but is +// not gibbed, and the reality tear eats the room but only throws people around. /datum/techweb_node/unregulated_bluespace design_ids = list( @@ -10,8 +10,10 @@ /obj/item/gun/energy/event_horizon selfcharge = FALSE can_charge = FALSE + // Emitters and turrets would fire it on their own power, skipping the shot limit and cooldown. + gun_flags = TURRET_INCOMPATIBLE /// Time between shots. - var/shot_cooldown_time = 1 MINUTES + var/shot_cooldown_time = 20 SECONDS COOLDOWN_DECLARE(shot_cooldown) /obj/item/gun/energy/event_horizon/get_cell(atom/movable/interface, mob/user) @@ -33,8 +35,13 @@ /obj/item/ammo_casing/energy/event_horizon e_cost = LASER_SHOTS(3, STANDARD_CELL_CHARGE) -/obj/projectile/beam/event_horizon - range = 9 +/// Whoever the beam hits is burned to death, not gibbed, so the body stays behind. +/obj/projectile/beam/event_horizon/on_hit(atom/target, blocked = 0, pierce_hit) + . = ..() + if(. != BULLET_ACT_HIT || !isliving(target)) + return + var/mob/living/victim = target + victim.adjustFireLoss(200) /obj/reality_tear/temporary/start_disaster() . = ..() From 0832ef40387ee62e16dd29f59c394a931759b45d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:22:37 -0700 Subject: [PATCH 3/3] Move the anomaly trade test into the Voidcrew test folder for the modularization check --- code/modules/unit_tests/_unit_tests.dm | 2 +- tools/ci/modularization_exceptions.json | 6 ++++++ .../modules/unit_tests/voidcrew_anomaly_trade.dm | 0 3 files changed, 7 insertions(+), 1 deletion(-) rename {code => voidcrew}/modules/unit_tests/voidcrew_anomaly_trade.dm (100%) diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index e606437f527..d5572ac51f9 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -333,7 +333,7 @@ #include "verify_config_tags.dm" #include "verify_emoji_names.dm" #include "voidcrew_ammo_box_materials.dm" -#include "voidcrew_anomaly_trade.dm" +#include "../../../voidcrew/modules/unit_tests/voidcrew_anomaly_trade.dm" // VOIDCREW EDIT ADDITION #include "voidcrew_assault_pod.dm" #include "voidcrew_autopilot_course.dm" #include "voidcrew_autotranslate_morph.dm" diff --git a/tools/ci/modularization_exceptions.json b/tools/ci/modularization_exceptions.json index 7b42968dab8..afdd41dcb4f 100644 --- a/tools/ci/modularization_exceptions.json +++ b/tools/ci/modularization_exceptions.json @@ -77,6 +77,12 @@ ], "reason": "Voidcrew-only file, not in /tg/station; it predates the modular folders, so all of it is fork code." }, + "code/modules/unit_tests/voidcrew_shop_catalog.dm": { + "rules": [ + "markers" + ], + "reason": "Voidcrew-only file, not in /tg/station; it predates the modular folders, so all of it is fork code." + }, "code/modules/unit_tests/voidcrew_survey_archive.dm": { "rules": [ "markers" diff --git a/code/modules/unit_tests/voidcrew_anomaly_trade.dm b/voidcrew/modules/unit_tests/voidcrew_anomaly_trade.dm similarity index 100% rename from code/modules/unit_tests/voidcrew_anomaly_trade.dm rename to voidcrew/modules/unit_tests/voidcrew_anomaly_trade.dm