193 lines
9.5 KiB
GDScript
193 lines
9.5 KiB
GDScript
extends SceneTree
|
|
|
|
const LedgerEntryType = preload("res://scripts/economy/ledger_entry.gd")
|
|
const EconomyServiceType = preload("res://scripts/economy/economy_service.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S2: Economy Service")
|
|
_test_initialization_and_insufficient_balance()
|
|
_test_idempotency_posts_once()
|
|
_test_reverse_and_partial_refund()
|
|
_test_compound_post_is_atomic()
|
|
_test_daily_aggregation_and_reconciliation()
|
|
_test_snapshot_round_trip_is_atomic()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_initialization_and_insufficient_balance() -> void:
|
|
var economy := _economy()
|
|
var initialized = economy.initialize_account(&"PLAYER_CASH", 1_000, 360, &"init-player")
|
|
_expect(initialized.is_success(), "initial Gold is posted through SYSTEM_SOURCE")
|
|
_expect(economy.get_balance(&"PLAYER_CASH") == 1_000, "initialization debits player cash")
|
|
_expect(economy.get_balance(EconomyServiceType.SYSTEM_SOURCE) == -1_000, "SYSTEM_SOURCE is the explicit counter-account")
|
|
var entry = economy.get_transaction_entries(initialized.details[&"transaction_id"])[0]
|
|
_expect(entry.amount == 1_000 and entry.category == &"INITIALIZATION", "initialization creates a positive integer ledger entry")
|
|
|
|
var before := economy.create_snapshot()
|
|
var rejected = economy.transfer(
|
|
&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 1_001, 361, &"TEST", &"", &"too-much"
|
|
)
|
|
_expect(not rejected.is_success(), "an account without credit cannot overspend")
|
|
_expect(rejected.code == CommandResultType.Code.VALIDATION_FAILED, "insufficient Gold is a validation failure")
|
|
_expect(economy.create_snapshot() == before, "rejected transfer changes neither ledger nor balances")
|
|
_expect(not economy.register_account(&"PLAYER_CASH"), "an account definition cannot be replaced to bypass its policy")
|
|
|
|
|
|
func _test_idempotency_posts_once() -> void:
|
|
var economy := _economy()
|
|
economy.initialize_account(&"PLAYER_CASH", 500, 360, &"init")
|
|
var first = economy.transfer(
|
|
&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 125, 400, &"GIFT", &"player", &"gift-once"
|
|
)
|
|
var entry_count := economy.get_entries().size()
|
|
var duplicate = economy.transfer(
|
|
&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 125, 400, &"GIFT", &"player", &"gift-once"
|
|
)
|
|
_expect(first.is_success(), "first idempotent command posts")
|
|
_expect(duplicate.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, "duplicate idempotency key is rejected explicitly")
|
|
_expect(economy.get_entries().size() == entry_count, "duplicate command appends no second entry")
|
|
_expect(economy.get_balance(&"PLAYER_CASH") == 375, "sender is credited exactly once")
|
|
_expect(economy.get_balance(&"GUEST_WALLET:guest_1") == 125, "receiver is debited exactly once")
|
|
_expect(
|
|
economy.get_transaction_id_for_idempotency_key(&"gift-once") == first.details[&"transaction_id"],
|
|
"idempotency index resolves the original transaction"
|
|
)
|
|
|
|
|
|
func _test_reverse_and_partial_refund() -> void:
|
|
var economy := _economy()
|
|
economy.initialize_account(&"GUEST_WALLET:guest_1", 500, 360, &"init-guest")
|
|
var payment = economy.transfer(
|
|
&"GUEST_WALLET:guest_1", &"FACILITY_REVENUE:inn", 200, 500, &"SERVICE_SALE", &"inn", &"sale-1"
|
|
)
|
|
var payment_id: StringName = payment.details[&"transaction_id"]
|
|
var reversed = economy.reverse(payment_id, 510, &"reverse-sale")
|
|
_expect(reversed.is_success(), "an existing transaction reverses with a new entry")
|
|
_expect(economy.get_balance(&"GUEST_WALLET:guest_1") == 500, "reversal restores the payer balance")
|
|
_expect(economy.get_balance(&"FACILITY_REVENUE:inn") == 0, "reversal restores the receiver balance")
|
|
var reverse_entry = economy.get_transaction_entries(reversed.details[&"transaction_id"])[0]
|
|
_expect(reverse_entry.related_transaction_id == payment_id, "reversal retains the original transaction reference")
|
|
_expect(reverse_entry.debit_account == &"GUEST_WALLET:guest_1" and reverse_entry.credit_account == &"FACILITY_REVENUE:inn", "reversal swaps debit and credit")
|
|
_expect(not economy.reverse(payment_id, 511, &"reverse-again").is_success(), "a transaction cannot be fully reversed twice")
|
|
|
|
var second_payment = economy.transfer(
|
|
&"GUEST_WALLET:guest_1", &"FACILITY_REVENUE:inn", 120, 520, &"SERVICE_SALE", &"inn", &"sale-2"
|
|
)
|
|
var second_id: StringName = second_payment.details[&"transaction_id"]
|
|
_expect(economy.refund(second_id, 40, 530, &"refund-1").is_success(), "a one-line transaction supports a partial refund")
|
|
_expect(economy.refund(second_id, 80, 540, &"refund-2").is_success(), "remaining refundable Gold can be returned")
|
|
_expect(economy.get_refunded_amount(second_id) == 120, "refund total is derived from related entries")
|
|
_expect(not economy.refund(second_id, 1, 541, &"refund-over").is_success(), "refunds cannot exceed the source transaction")
|
|
|
|
|
|
func _test_compound_post_is_atomic() -> void:
|
|
var economy := _economy()
|
|
economy.initialize_account(&"PLAYER_CASH", 400, 360, &"init-player")
|
|
economy.initialize_account(&"GUEST_WALLET:guest_1", 50, 360, &"init-guest")
|
|
var before := economy.create_snapshot()
|
|
var entries: Array = [
|
|
_entry(600, &"STAFF_WALLET:staff_1", &"PLAYER_CASH", 100, &"PAYROLL"),
|
|
_entry(600, &"STAFF_WALLET:staff_1", &"GUEST_WALLET:guest_1", 51, &"PAYROLL"),
|
|
]
|
|
var rejected = economy.post(entries, &"compound-fail")
|
|
_expect(not rejected.is_success(), "one invalid line rejects a compound transaction")
|
|
_expect(economy.create_snapshot() == before, "compound failure rolls back every line and index")
|
|
|
|
entries[1].amount = 50
|
|
var posted = economy.post(entries, &"compound-ok")
|
|
_expect(posted.is_success(), "a fully valid compound transaction posts")
|
|
_expect(economy.get_transaction_entries(posted.details[&"transaction_id"]).size() == 2, "compound lines share one transaction ID")
|
|
_expect(economy.get_balance(&"STAFF_WALLET:staff_1") == 150, "compound debit deltas commit together")
|
|
_expect(economy.get_balance(&"PLAYER_CASH") == 300 and economy.get_balance(&"GUEST_WALLET:guest_1") == 0, "compound credits commit together")
|
|
|
|
|
|
func _test_daily_aggregation_and_reconciliation() -> void:
|
|
var economy := _economy()
|
|
economy.initialize_account(&"PLAYER_CASH", 1_000, 360, &"init")
|
|
economy.transfer(&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 75, 700, &"GIFT", &"player", &"gift")
|
|
economy.transfer(&"PLAYER_CASH", &"STAFF_WALLET:staff_1", 125, 1_500, &"WAGE", &"staff_1", &"wage")
|
|
var day_one := economy.get_daily_summary(1)
|
|
_expect(day_one[&"transaction_count"] == 2 and day_one[&"balanced"], "day close counts only transactions inside its minute range")
|
|
_expect(day_one[&"category_totals"][&"INITIALIZATION"] == 1_000, "day close aggregates category amounts")
|
|
_expect(day_one[&"account_totals"][&"PLAYER_CASH"][&"net"] == 925, "day close aggregates per-account debit, credit and net")
|
|
_expect(economy.validate_ledger()[&"ok"], "ledger and derived balance cache reconcile")
|
|
|
|
economy._balance_cache[&"PLAYER_CASH"] = 999_999
|
|
var mismatch := economy.validate_ledger()
|
|
_expect(not mismatch[&"ok"] and mismatch[&"errors"][-1][&"code"] == &"BALANCE_CACHE_MISMATCH", "reconciliation detects a damaged balance cache")
|
|
_expect(economy.rebuild_balance_cache(), "balance cache can be rebuilt only from valid ledger entries")
|
|
_expect(economy.get_balance(&"PLAYER_CASH") == 800, "rebuild never invents a compensating ledger entry")
|
|
_expect(economy.validate_ledger()[&"ok"], "rebuilt balance cache reconciles exactly")
|
|
|
|
|
|
func _test_snapshot_round_trip_is_atomic() -> void:
|
|
var source := _economy()
|
|
source.set_current_minute(777)
|
|
source.initialize_account(&"PLAYER_CASH", 900, 360, &"init")
|
|
source.transfer(&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 120, 650, &"GIFT", &"player", &"gift")
|
|
var snapshot := source.create_snapshot()
|
|
var restored := EconomyServiceType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "valid economy snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "economy snapshot round trip is exact")
|
|
_expect(restored.validate_ledger()[&"ok"], "restored ledger and balances reconcile")
|
|
|
|
var before_invalid := restored.create_snapshot()
|
|
var corrupt := snapshot.duplicate(true)
|
|
corrupt[&"entries"][0][&"amount"] = 0
|
|
_expect(not restored.restore_snapshot(corrupt), "snapshot containing a zero-amount entry is rejected")
|
|
_expect(restored.create_snapshot() == before_invalid, "failed snapshot restore is fully atomic")
|
|
|
|
var duplicate_account := snapshot.duplicate(true)
|
|
duplicate_account[&"accounts"].append(duplicate_account[&"accounts"][0].duplicate(true))
|
|
_expect(not restored.restore_snapshot(duplicate_account), "snapshot with duplicate account definitions is rejected")
|
|
_expect(restored.create_snapshot() == before_invalid, "second rejected restore also preserves live state")
|
|
|
|
|
|
func _economy() -> EconomyService:
|
|
return EconomyServiceType.new({
|
|
&"PLAYER_CASH": {&"allow_negative": false},
|
|
&"GUEST_WALLET:guest_1": {&"allow_negative": false},
|
|
&"FACILITY_REVENUE:inn": {&"allow_negative": false},
|
|
&"STAFF_WALLET:staff_1": {&"allow_negative": false},
|
|
&"STAFF_PAYABLE:staff_1": {&"allow_negative": true},
|
|
})
|
|
|
|
|
|
func _entry(
|
|
absolute_minute: int,
|
|
debit_account: StringName,
|
|
credit_account: StringName,
|
|
amount: int,
|
|
category: StringName
|
|
) -> LedgerEntry:
|
|
return LedgerEntryType.new().configure(
|
|
&"",
|
|
absolute_minute,
|
|
debit_account,
|
|
credit_account,
|
|
amount,
|
|
category
|
|
)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|