Adds a unit test for Cargo Crates to prevent infinite credit oversights. (#89023)

## About The Pull Request

I was kinda shocked that we didn't have something for this considering
that it's an unspoken rule of cargo to check that a crate does not sell
back for more than it's price and that the one comment saying to do so
has been doing some heavy lifting for the last 12 years.

I'm a novice when it comes to unit testing specifically so hopefully the
smoothing out that needs to be done should be fairly quick.

*Edit (2/22/25):*
The following changes were also made in order to allow for this unit
test to work smoothly:

Exports now have a market define added to them, as the purpose of this
unit test is to test exports that occur on the station, bought from
supply and then sold back to supply. As such, these market defines
exclude exports specific to pirates (since the cargo shuttle cannot sell
living mobs back, preventing you from making 10k per parrot crate.).

I've also added the `abstract` variable to some export datums, to
signify that a given export is either variable, or not meant to be
compared against the value of it's own container, such as with gas
canister's base export value as their export datums are generated
dynamically. (The subtypes are not abstract, however.)

The verb, `/mob/living/verb/tally_physical_credits()` has been changed
to `/mob/living/proc/tally_physical_credits()`, because that's my B and
does effect some economy back end but it's a one line fix so I just
absent-mindedly fixed it here instead of atomizing it out. I can
one-line it otherwise.

Mulebots now no longer runtime on spawn as they set their own to their
own `get_turf` as opposed to pulling their `loc`.

A few supply packs have had their prices bumped up slightly to actually
pass the test itself:

* `/datum/supply_pack/misc/candles_bulk`
* `/datum/supply_pack/security/armor`
* `/datum/supply_pack/security/helmets`
* `/datum/supply_pack/security/baton`

## Why It's Good For The Game

Prevents future infinite credit bugs that could have been missed by
simply checking the sale value in game.

## Changelog

🆑
fix: To prevent infinite sales issues, security helmets, armors, and
batons packs now all cost 600 credits, up from 400.
fix: Candle packs now cost 400 credits, up from 300, and candles now
sell for 12.25 cr each.
/🆑

---------

Co-authored-by: LemonInTheDark <58055496+LemonInTheDark@users.noreply.github.com>
This commit is contained in:
ArcaneMusic
2025-03-13 18:26:53 -04:00
committed by GitHub
parent 93c6f22c4d
commit 5624a33c26
18 changed files with 99 additions and 17 deletions

View File

@@ -13,6 +13,9 @@
#define SUPPLYPOD_X_OFFSET -16
///DO NOT GO ANY LOWER THAN X1.4 the "CARGO_CRATE_VALUE" value if using regular crates, or infinite profit will be possible! This is also unit tested against.
#define CARGO_MINIMUM_COST CARGO_CRATE_VALUE * 1.4
/// The baseline unit for cargo crates. Adjusting this will change the cost of all in-game shuttles, crate export values, bounty rewards, and all supply pack import values, as they use this as their unit of measurement.
#define CARGO_CRATE_VALUE 200
@@ -29,6 +32,12 @@
/// Universal Scanner mode for using the price tagger.
#define SCAN_PRICE_TAG 3
// Defines for use with `export_item_and_contents()`, aka the export code that sells the items.
/// Default export define, these are things that are sold to centcom.
#define EXPORT_MARKET_STATION "supply"
/// Export market for pirates.
#define EXPORT_MARKET_PIRACY "piracy"
///Used by coupons to define that they're cursed
#define COUPON_OMEN "omen"

View File

@@ -89,6 +89,11 @@
cell = null
return ..()
/obj/machinery/space_heater/Exited(atom/movable/gone, direction)
. = ..()
if(gone == cell)
cell = null
/obj/machinery/space_heater/examine(mob/user)
. = ..()
. += "\The [src] is [on ? "on" : "off"], and the hatch is [panel_open ? "open" : "closed"]."
@@ -288,7 +293,6 @@
if("eject")
if(panel_open && cell)
usr.put_in_hands(cell)
cell = null
. = TRUE
/obj/machinery/space_heater/proc/toggle_power(user)

View File

@@ -111,6 +111,7 @@
icon_state = "atmos_secure"
base_icon_state = "atmos_secure"
/obj/structure/closet/crate/secure/science
name = "secure science crate"
desc = "A crate with a lock on it, painted in the scheme of the station's scientists."

View File

@@ -367,7 +367,7 @@
///there are still licing mobs inside that item. Stop, don't sell it ffs.
if(locate(/mob/living) in item_on_pad.get_all_contents())
continue
export_item_and_contents(item_on_pad, apply_elastic = FALSE, dry_run = dry_run, delete_unsold = FALSE, external_report = report, ignore_typecache = nosell_typecache)
export_item_and_contents(item_on_pad, apply_elastic = FALSE, dry_run = dry_run, delete_unsold = FALSE, external_report = report, ignore_typecache = nosell_typecache, export_market = EXPORT_MARKET_PIRACY)
return report
/// Prepares to sell the items on the pad
@@ -401,6 +401,9 @@
pad.icon_state = pad.idle_state
deltimer(sending_timer)
/datum/export/pirate
sales_market = EXPORT_MARKET_PIRACY
/// Attempts to find the thing on station
/datum/export/pirate/proc/find_loot()
return

View File

@@ -77,6 +77,7 @@
hidden = TRUE
crate_name = "bitrunning delivery crate"
access = list(ACCESS_BIT_DEN)
abstract = TRUE
/datum/supply_pack/bitrunning/New(purchaser, cost, list/contains)
. = ..()

View File

@@ -46,8 +46,9 @@ Then the player gets the profit from selling his own wasted time.
** dry_run: if the item should be actually sold, or if it's just a pirce test
** external_report: works as "transaction" object, pass same one in if you're doing more than one export in single go
** ignore_typecache: typecache containing types that should be completely ignored
** export_market: Defines the market that the items are being sold to.
*/
/proc/export_item_and_contents(atom/movable/exported_atom, apply_elastic = TRUE, delete_unsold = TRUE, dry_run = FALSE, datum/export_report/external_report, list/ignore_typecache)
/proc/export_item_and_contents(atom/movable/exported_atom, apply_elastic = TRUE, delete_unsold = TRUE, dry_run = FALSE, datum/export_report/external_report, list/ignore_typecache, export_market = EXPORT_MARKET_STATION)
external_report = init_export(external_report)
var/list/contents = exported_atom.get_all_contents_ignoring(ignore_typecache)
@@ -55,7 +56,7 @@ Then the player gets the profit from selling his own wasted time.
// We go backwards, so it'll be innermost objects sold first. We also make sure nothing is accidentally delete before everything is sold.
var/list/to_delete = list()
for(var/atom/movable/thing as anything in reverse_range(contents))
var/sold = _export_loop(thing, apply_elastic, dry_run, external_report)
var/sold = _export_loop(thing, apply_elastic, dry_run, external_report, export_market)
if(!dry_run && (sold || delete_unsold) && sold != EXPORT_SOLD_DONT_DELETE)
if(ismob(thing))
thing.investigate_log("deleted through cargo export", INVESTIGATE_CARGO)
@@ -68,10 +69,10 @@ Then the player gets the profit from selling his own wasted time.
return external_report
/// It works like export_item_and_contents(), however it ignores the contents. Meaning only `exported_atom` will be valued.
/proc/export_single_item(atom/movable/exported_atom, apply_elastic = TRUE, delete_unsold = TRUE, dry_run = FALSE, datum/export_report/external_report)
/proc/export_single_item(atom/movable/exported_atom, apply_elastic = TRUE, delete_unsold = TRUE, dry_run = FALSE, datum/export_report/external_report, export_market = EXPORT_MARKET_STATION)
external_report = init_export(external_report)
var/sold = _export_loop(exported_atom, apply_elastic, dry_run, external_report)
var/sold = _export_loop(exported_atom, apply_elastic, dry_run, external_report, export_market)
if(!dry_run && (sold || delete_unsold) && sold != EXPORT_SOLD_DONT_DELETE)
if(ismob(exported_atom))
exported_atom.investigate_log("deleted through cargo export", INVESTIGATE_CARGO)
@@ -80,10 +81,10 @@ Then the player gets the profit from selling his own wasted time.
return external_report
/// The main bit responsible for selling the item. Shared by export_single_item() and export_item_and_contents()
/proc/_export_loop(atom/movable/exported_atom, apply_elastic = TRUE, dry_run = FALSE, datum/export_report/external_report)
/proc/_export_loop(atom/movable/exported_atom, apply_elastic = TRUE, dry_run = FALSE, datum/export_report/external_report, export_market)
var/sold = EXPORT_NOT_SOLD
for(var/datum/export/export as anything in GLOB.exports_list)
if(export.applies_to(exported_atom, apply_elastic))
if(export.applies_to(exported_atom, apply_elastic, export_market))
if(!dry_run && (SEND_SIGNAL(exported_atom, COMSIG_ITEM_PRE_EXPORT) & COMPONENT_STOP_EXPORT))
break
//Don't add value of unscannable items for a dry run report
@@ -116,6 +117,8 @@ Then the player gets the profit from selling his own wasted time.
var/list/exclude_types = list()
/// Set to false if the cost shouldn't be determinable by an export scanner
var/scannable = TRUE
/// Export market that this export applies to. Defaults to EXPORT_MARKET_STATION for items sold to the standard supply shuttle, replacements exist for pirates, etc.
var/sales_market = EXPORT_MARKET_STATION
/// cost includes elasticity, this does not.
var/init_cost
@@ -157,13 +160,15 @@ Then the player gets the profit from selling his own wasted time.
return 1
/// Checks if the item is fit for export datum.
/datum/export/proc/applies_to(obj/exported_item, apply_elastic = TRUE)
/datum/export/proc/applies_to(obj/exported_item, apply_elastic = TRUE, export_market)
if(!is_type_in_typecache(exported_item, export_types))
return FALSE
if(include_subtypes && is_type_in_typecache(exported_item, exclude_types))
return FALSE
if(!get_cost(exported_item, apply_elastic))
return FALSE
if(export_market != sales_market)
return FALSE
if(exported_item.flags_1 & HOLOGRAM_1)
return FALSE
return TRUE

View File

@@ -153,6 +153,6 @@
export_types = list(/obj/item/soap/omega)
/datum/export/candle
cost = CARGO_CRATE_VALUE * 0.125
cost = CARGO_CRATE_VALUE * 0.06125
unit_name = "candle"
export_types = list(/obj/item/flashlight/flare/candle)

View File

@@ -3,6 +3,7 @@
access = NONE
group = "Goodies"
goody = TRUE
crate_type = null
discountable = SUPPLY_PACK_STD_DISCOUNTABLE
/datum/supply_pack/goody/clear_pda

View File

@@ -37,10 +37,12 @@
var/special_pod
/// Was this spawned through an admin proc?
var/admin_spawned = FALSE
/// Goodies can only be purchased by private accounts and can have coupons apply to them. They also come in a lockbox instead of a full crate, so the 700 min doesn't apply
/// Goodies can only be purchased by private accounts and can have coupons apply to them. They also come in a lockbox instead of a full crate, so the crate price min doesn't apply
var/goody = FALSE
/// Can coupons target this pack? If so, how rarely?
var/discountable = SUPPLY_PACK_NOT_DISCOUNTABLE
/// Is this supply pack considered unpredictable for the purposes of testing unit testing? Examples include the stock market, or miner supply crates. If true, exempts from unit testing
var/abstract = FALSE
/datum/supply_pack/New()
id = type
@@ -59,6 +61,12 @@
return data
/**
* Proc that takes a given supply_pack, and attempts to create a crate containing the pack's contents as determined by fill()
*
* @ atom/A: The location or turf that the pack is being generated onto. Cargo shuttle provides an empty turf, other generate()s call this either null or otherwise.
* @ datum/bank_account/paying_account: The account to associate the supply pack with when going and generating the crate. Only the paying account can open said secure crate/case.
*/
/datum/supply_pack/proc/generate(atom/A, datum/bank_account/paying_account)
var/obj/structure/closet/crate/C
if(paying_account)
@@ -121,6 +129,7 @@
hidden = TRUE
crate_name = "shaft mining delivery crate"
access = ACCESS_MINING
abstract = TRUE
/datum/supply_pack/custom/New(purchaser, cost, list/contains)
. = ..()

View File

@@ -178,7 +178,7 @@
/datum/supply_pack/misc/candles_bulk
name = "Candle Box Crate"
desc = "Keep your local chapel lit with three candle boxes!"
cost = CARGO_CRATE_VALUE * 1.5
cost = CARGO_CRATE_VALUE * 2
contains = list(/obj/item/storage/fancy/candle_box = 3)
crate_name = "candle box crate"
@@ -214,6 +214,7 @@
contains = list()
crate_name = "syndicate gear crate"
crate_type = /obj/structure/closet/crate
abstract = TRUE // Not
///Total TC worth of contained uplink items
var/crate_value = 30
///What uplink the contents are pulled from

View File

@@ -231,6 +231,7 @@
/datum/supply_pack/critter/fish
crate_type = /obj/structure/closet/crate
abstract = ABSTRACT // However, we should be wary of how possible it is to get more valuable fish out of this on average, depending on sample size.
/datum/supply_pack/critter/fish/aquarium_fish
name = "Aquarium Fish Case"

View File

@@ -75,6 +75,7 @@
cost = CARGO_CRATE_VALUE * 0.05
contains = list(/obj/machinery/portable_atmospherics/canister)
crate_type = /obj/structure/closet/crate/large
abstract = TRUE
/datum/supply_pack/materials/gas_canisters/generate_supply_packs()
var/list/canister_packs = list()
@@ -100,6 +101,8 @@
pack.cost = cost + moleCount * initial(gas.base_value) * 1.6
pack.cost = CEILING(pack.cost, 10)
pack.abstract = FALSE
pack.contains = list(GLOB.gas_id_to_canister[initial(gas.id)])
pack.crate_type = crate_type

View File

@@ -20,7 +20,7 @@
/datum/supply_pack/security/armor
name = "Armor Crate"
desc = "Three vests of well-rounded, decently-protective armor."
cost = CARGO_CRATE_VALUE * 2
cost = CARGO_CRATE_VALUE * 3
access_view = ACCESS_SECURITY
contains = list(/obj/item/clothing/suit/armor/vest = 3)
crate_name = "armor crate"
@@ -53,7 +53,7 @@
/datum/supply_pack/security/helmets
name = "Helmets Crate"
desc = "Contains three standard-issue brain buckets."
cost = CARGO_CRATE_VALUE * 2
cost = CARGO_CRATE_VALUE * 3
contains = list(/obj/item/clothing/head/helmet/sec = 3)
crate_name = "helmet crate"
@@ -154,7 +154,7 @@
/datum/supply_pack/security/baton
name = "Stun Batons Crate"
desc = "Arm the Civil Protection Forces with three stun batons. Batteries included."
cost = CARGO_CRATE_VALUE * 2
cost = CARGO_CRATE_VALUE * 3
access_view = ACCESS_SECURITY
contains = list(/obj/item/melee/baton/security/loaded = 3)
crate_name = "stun baton crate"

View File

@@ -2983,7 +2983,7 @@ GLOBAL_LIST_EMPTY(fire_appearances)
/**
* Totals the physical cash on the mob and returns the total.
*/
/mob/living/verb/tally_physical_credits()
/mob/living/proc/tally_physical_credits()
//Here is all the possible non-ID payment methods.
var/list/counted_money = list()
var/physical_cash_total = 0

View File

@@ -86,7 +86,7 @@
suffix = null
if(name == "\improper MULEbot")
name = "\improper MULEbot [id]"
set_home(loc)
set_home(get_turf(src))
/mob/living/simple_animal/bot/mulebot/Exited(atom/movable/gone, direction)
. = ..()

View File

@@ -102,6 +102,7 @@
maxcharge = STANDARD_CELL_CHARGE * 10
custom_materials = list(/datum/material/glass=SMALL_MATERIAL_AMOUNT*0.6)
chargerate = STANDARD_CELL_RATE * 0.75
/obj/item/stock_parts/power_store/cell/high/empty
empty = TRUE

View File

@@ -114,6 +114,7 @@
#include "can_see.dm"
#include "card_mismatch.dm"
#include "cardboard_cutouts.dm"
#include "cargo_crate_sanity.dm"
#include "cargo_dep_order_locations.dm"
#include "cargo_selling.dm"
#include "chain_pull_through_space.dm"

View File

@@ -0,0 +1,42 @@
/**
* This unit test loops through all cargo crates that are available to purchase, and confirms that they're below the expected sanity minimum when sold.
* This prevents us from merging a crate that sells for more that it costs to buy.
*/
/datum/unit_test/cargo_crate_sanity
/datum/unit_test/cargo_crate_sanity/Run()
for(var/crate in subtypesof(/datum/supply_pack))
var/datum/supply_pack/new_crate = allocate(crate)
if(new_crate.abstract)
continue // We can safely ignore custom supply packs like the stock market or mining supply crates.
if(!new_crate?.crate_type)
continue
var/obj/crate_type = allocate(new_crate.crate_type)
var/turf/open/floor/testing_floor = get_turf(crate_type)
var/datum/export_report/minimum_cost = export_item_and_contents(crate_type, delete_unsold = TRUE, dry_run = TRUE)
var/crate_value = counterlist_sum(minimum_cost.total_value)
var/obj/results = new_crate.generate(testing_floor)
var/datum/export_report/export_log = export_item_and_contents(results, apply_elastic = TRUE, delete_unsold = TRUE, export_market = EXPORT_MARKET_STATION)
// The value of the crate and all of it's contents.
var/value = counterlist_sum(export_log.total_value)
// We're selling the crate and it's contents for more value than it's supply_pack costs.
if(value > new_crate.get_cost())
TEST_FAIL("Cargo crate [new_crate.type] had a sale value of [value], Selling for more than [new_crate.get_cost()], the cost to buy")
// We're selling the crate & it's contents for less than the value of it's own crate, meaning you can buy and infinite number
if(crate_value > new_crate.get_cost())
TEST_FAIL("Cargo crate [new_crate.type] container sells for [crate_value], Selling for more than [new_crate.get_cost()], the cost to buy")
for(var/atom/stuff as anything in results.contents)
qdel(stuff)
stuff = null
qdel(results)
results = null
new_crate = null
minimum_cost = null
export_log = null