Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dskbexctiolv5
|
||||
@@ -0,0 +1,312 @@
|
||||
extends SceneTree
|
||||
|
||||
const FacilityInstanceType = preload("res://scripts/facility/facility_instance.gd")
|
||||
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
|
||||
const FacilityServiceType = preload("res://scripts/facility/facility_service.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S2: Facility Service")
|
||||
_test_state_priority_and_opening_hours()
|
||||
_test_policy_revision_and_request_snapshot()
|
||||
_test_deterministic_queue_and_departure()
|
||||
_test_capacity_progress_and_boundaries()
|
||||
_test_interruption_and_recovery()
|
||||
_test_determinism()
|
||||
_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_state_priority_and_opening_hours() -> void:
|
||||
var facility := _facility(&"priority_room", 1, 4, 1, 2)
|
||||
facility.set_operational_conditions({&"active_staff_ids": []}, 0)
|
||||
_expect(facility.state == FacilityInstanceType.State.NO_STAFF, "staff shortage initially blocks opening")
|
||||
facility.set_operational_conditions({
|
||||
&"durability": 0,
|
||||
&"access_available": false,
|
||||
&"power_available": false,
|
||||
&"operator_open": false,
|
||||
&"active_staff_ids": [],
|
||||
&"degraded_reasons": [&"DIRTY"],
|
||||
}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.BROKEN, "BROKEN wins over every other state")
|
||||
_expect(facility.state_reasons.size() == 6, "all simultaneous state reasons are retained")
|
||||
facility.set_operational_conditions({
|
||||
&"durability": 5_000,
|
||||
&"access_available": false,
|
||||
&"power_available": false,
|
||||
&"operator_open": false,
|
||||
&"active_staff_ids": [],
|
||||
&"degraded_reasons": [],
|
||||
}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.NO_ACCESS, "NO_ACCESS wins after repair")
|
||||
facility.set_operational_conditions({&"access_available": true}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.NO_POWER, "NO_POWER wins over staffing and closure")
|
||||
facility.set_operational_conditions({&"power_available": true}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.NO_STAFF, "NO_STAFF wins over manual closure")
|
||||
facility.set_operational_conditions({&"active_staff_ids": [&"staff_a"]}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.CLOSED, "manual closure follows operational failures")
|
||||
facility.set_operational_conditions({&"operator_open": true, &"durability": 2_999}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.DEGRADED, "low durability produces DEGRADED")
|
||||
facility.set_operational_conditions({&"durability": 5_000}, 600)
|
||||
_expect(facility.state == FacilityInstanceType.State.OPEN, "healthy staffed facility opens")
|
||||
_expect(facility.set_opening_hours(600, 1_200), "valid opening hours are accepted")
|
||||
facility.evaluate_state(500)
|
||||
_expect(facility.state == FacilityInstanceType.State.CLOSED, "schedule closes facility before opening")
|
||||
facility.evaluate_state(600)
|
||||
_expect(facility.state == FacilityInstanceType.State.OPEN, "opening minute is inclusive")
|
||||
facility.evaluate_state(1_200)
|
||||
_expect(facility.state == FacilityInstanceType.State.CLOSED, "closing minute is exclusive")
|
||||
_expect(facility.set_opening_hours(1_200, 0), "midnight close creates a valid overnight interval")
|
||||
_expect(facility.is_within_opening_hours(1_300), "midnight-close schedule remains open late at night")
|
||||
|
||||
|
||||
func _test_policy_revision_and_request_snapshot() -> void:
|
||||
var service := FacilityServiceType.new()
|
||||
var facility := _facility(&"policy_room", 1, 3, 0, 1)
|
||||
_expect(service.register_instance(facility).is_success(), "facility instance registers")
|
||||
_expect(not service.register_instance(facility).is_success(), "duplicate facility instance ID is rejected")
|
||||
var changed = service.set_policy(facility.instance_id, {&"price_mode": &"standard"})
|
||||
_expect(changed.is_success() and facility.policy_revision == 1, "immediate policy patch increments revision")
|
||||
var request := service.create_request(
|
||||
&"policy_request",
|
||||
facility.instance_id,
|
||||
[&"guest_a"],
|
||||
&"meal",
|
||||
100,
|
||||
{&"quoted_price": 120}
|
||||
)
|
||||
_expect(service.enqueue_request(request, 100).is_success(), "policy-quoted request enters queue")
|
||||
service.set_policy(facility.instance_id, {&"price_mode": &"premium"})
|
||||
_expect(request.policy_snapshot[&"price_mode"] == &"standard", "queued request retains original policy snapshot")
|
||||
_expect(request.policy_revision == 1 and facility.policy_revision == 2, "request retains original policy revision")
|
||||
var scheduled = service.set_policy(facility.instance_id, {&"risk_mode": true}, 120)
|
||||
_expect(scheduled.is_success() and facility.policy_pending_revision == 3, "future policy receives pending revision")
|
||||
service.advance_one_minute(119)
|
||||
_expect(not facility.policy_current.has(&"risk_mode"), "pending policy does not apply early")
|
||||
service.advance_one_minute(120)
|
||||
_expect(facility.policy_current.get(&"risk_mode", false), "pending policy applies at exact minute")
|
||||
_expect(facility.policy_revision == 3, "scheduled application increments current revision once")
|
||||
|
||||
|
||||
func _test_deterministic_queue_and_departure() -> void:
|
||||
var service := FacilityServiceType.new()
|
||||
var facility := _facility(&"queue_room", 1, 3, 0, 2)
|
||||
service.register_instance(facility)
|
||||
var request_b := _request(service, facility, &"request_b", 10, 0, 2)
|
||||
var request_a := _request(service, facility, &"request_a", 10, 0, 2)
|
||||
var request_high := _request(service, facility, &"request_high", 11, 5, 2)
|
||||
_expect(service.enqueue_request(request_b, 10).is_success(), "first request enters queue")
|
||||
_expect(service.enqueue_request(request_a, 10).is_success(), "same-minute request enters queue")
|
||||
_expect(service.enqueue_request(request_high, 11).is_success(), "later high-priority request enters queue")
|
||||
_expect(
|
||||
_queue_ids(service, facility.instance_id) == [&"request_high", &"request_a", &"request_b"],
|
||||
"queue sorts by priority, arrival minute, then request ID"
|
||||
)
|
||||
_expect(request_high.expected_wait == 0, "high-priority request wait estimate excludes requests behind it")
|
||||
_expect(not service.enqueue_request(request_a, 12).is_success(), "duplicate request ID is rejected permanently")
|
||||
var overflow := _request(service, facility, &"request_overflow", 12, 0, 2)
|
||||
_expect(not service.enqueue_request(overflow, 12).is_success(), "queue capacity rejects overflow")
|
||||
_expect(service.leave_queue(facility.instance_id, &"request_a").is_success(), "guest can leave queue")
|
||||
_expect(_queue_ids(service, facility.instance_id) == [&"request_high", &"request_b"], "queue departure removes entry immediately")
|
||||
_expect(not service.enqueue_request(request_a, 13).is_success(), "departed request ID cannot be reused")
|
||||
|
||||
|
||||
func _test_capacity_progress_and_boundaries() -> void:
|
||||
var service := FacilityServiceType.new()
|
||||
var facility := _facility(&"capacity_room", 2, 4, 0, 2)
|
||||
service.register_instance(facility)
|
||||
var starts: Array[StringName] = []
|
||||
var completions: Array[StringName] = []
|
||||
service.configure_callbacks(
|
||||
func(request: ServiceRequest, _facility_instance: FacilityInstance, _minute: int) -> bool:
|
||||
starts.append(request.request_id)
|
||||
return true,
|
||||
func(service_instance: ServiceInstance, _request_value: ServiceRequest, _facility_instance: FacilityInstance, _minute: int) -> bool:
|
||||
completions.append(service_instance.service_id)
|
||||
return true
|
||||
)
|
||||
for request_id: StringName in [&"request_c", &"request_a", &"request_b"]:
|
||||
service.enqueue_request(_request(service, facility, request_id, 100, 0, 2), 100)
|
||||
var filled := service.fill_available_slots(facility.instance_id, 100)
|
||||
_expect(filled.size() == 2 and filled[0].is_success() and filled[1].is_success(), "capacity opens exactly two slots")
|
||||
_expect(starts == [&"request_a", &"request_b"], "slot starts follow deterministic request-ID tie break")
|
||||
_expect(facility.active_services.size() == 2, "two services occupy both slots")
|
||||
_expect(not service.start_next_service(facility.instance_id, 100).is_success(), "full facility cannot start a third service")
|
||||
service.advance_one_minute(101)
|
||||
_expect(completions.is_empty(), "two-minute service does not complete after one tick")
|
||||
service.advance_one_minute(102)
|
||||
_expect(completions.size() == 2, "both occupied slots complete independently")
|
||||
_expect(facility.active_services.size() == 1, "completion fills one freed slot from queue")
|
||||
_expect(starts == [&"request_a", &"request_b", &"request_c"], "next queued request is promoted after completion")
|
||||
|
||||
var rejecting := FacilityServiceType.new()
|
||||
var rejection_room := _facility(&"rejection_room", 1, 2, 0, 1)
|
||||
rejecting.register_instance(rejection_room)
|
||||
rejecting.configure_callbacks(
|
||||
func(_request_value: ServiceRequest, _instance: FacilityInstance, _minute: int) -> bool:
|
||||
return true,
|
||||
func(_service_value: ServiceInstance, _request_value: ServiceRequest, _instance: FacilityInstance, _minute: int) -> String:
|
||||
return "ledger transaction rejected"
|
||||
)
|
||||
rejecting.enqueue_request(_request(rejecting, rejection_room, &"atomic_request", 0, 0, 1), 0)
|
||||
var started = rejecting.start_next_service(rejection_room.instance_id, 0)
|
||||
var service_id: StringName = started.details[&"service_id"]
|
||||
var results := rejecting.advance_one_minute(1)
|
||||
_expect(results.any(func(result: CommandResult) -> bool: return not result.is_success()), "completion callback failure is surfaced")
|
||||
_expect(rejection_room.active_services.has(service_id), "failed completion transaction leaves service active")
|
||||
_expect(rejecting.get_request(&"atomic_request") != null, "failed completion transaction retains request")
|
||||
|
||||
|
||||
func _test_interruption_and_recovery() -> void:
|
||||
var service := FacilityServiceType.new()
|
||||
var facility := _facility(&"interrupt_room", 1, 2, 0, 3)
|
||||
service.register_instance(facility)
|
||||
service.enqueue_request(_request(service, facility, &"interrupt_request", 0, 0, 3), 0)
|
||||
var started = service.start_next_service(facility.instance_id, 0)
|
||||
var service_id: StringName = started.details[&"service_id"]
|
||||
service.set_operational_status(facility.instance_id, {&"power_available": false}, 1)
|
||||
service.advance_one_minute(1)
|
||||
var active := service.get_service(service_id)
|
||||
_expect(active.state == ServiceInstanceType.State.PAUSED, "power loss safely pauses active service")
|
||||
_expect(active.elapsed_minutes == 0, "paused service makes no progress")
|
||||
service.set_operational_status(facility.instance_id, {&"power_available": true}, 2)
|
||||
service.advance_one_minute(2)
|
||||
_expect(active.state == ServiceInstanceType.State.ACTIVE and active.elapsed_minutes == 1, "power restoration resumes service")
|
||||
service.interrupt_service(service_id, FacilityServiceType.INTERRUPTION_PLAYER_CLOSED, 2)
|
||||
_expect(active.state == ServiceInstanceType.State.ACTIVE, "normal player closure lets active service finish")
|
||||
service.set_operational_status(facility.instance_id, {&"durability": 0}, 3)
|
||||
service.advance_one_minute(3)
|
||||
_expect(active.state == ServiceInstanceType.State.FAILED, "facility breakage immediately fails active service")
|
||||
_expect(facility.active_services.is_empty(), "failed service frees its capacity slot")
|
||||
|
||||
var grace_service := FacilityServiceType.new()
|
||||
var grace_room := _facility(&"grace_room", 1, 1, 0, 3)
|
||||
grace_service.register_instance(grace_room)
|
||||
grace_service.enqueue_request(_request(grace_service, grace_room, &"grace_request", 10, 0, 3), 10)
|
||||
var grace_started = grace_service.start_next_service(grace_room.instance_id, 10)
|
||||
var grace_id: StringName = grace_started.details[&"service_id"]
|
||||
grace_service.interrupt_service(grace_id, FacilityServiceType.INTERRUPTION_STAFF_UNAVAILABLE, 10)
|
||||
grace_service.advance_one_minute(14)
|
||||
_expect(grace_service.get_service(grace_id).state == ServiceInstanceType.State.WAITING_FOR_STAFF, "staff replacement waits through minute four")
|
||||
grace_service.advance_one_minute(15)
|
||||
_expect(grace_service.get_service(grace_id).state == ServiceInstanceType.State.FAILED, "staff replacement fails at five-minute deadline")
|
||||
|
||||
|
||||
func _test_determinism() -> void:
|
||||
var first := _deterministic_run()
|
||||
var second := _deterministic_run()
|
||||
_expect(first == second, "identical commands produce byte-equivalent facility snapshots")
|
||||
|
||||
|
||||
func _test_snapshot_round_trip_is_atomic() -> void:
|
||||
var service := FacilityServiceType.new()
|
||||
var facility := _facility(&"save_room", 1, 3, 0, 4)
|
||||
service.register_instance(facility)
|
||||
service.set_policy(facility.instance_id, {&"mode": &"quiet"})
|
||||
service.set_policy(facility.instance_id, {&"mode": &"busy"}, 500)
|
||||
service.enqueue_request(_request(service, facility, &"active_request", 400, 2, 4), 400)
|
||||
service.enqueue_request(_request(service, facility, &"queued_request", 401, 0, 4), 401)
|
||||
service.start_next_service(facility.instance_id, 401)
|
||||
service.advance_one_minute(402)
|
||||
var snapshot := service.create_snapshot()
|
||||
var restored := FacilityServiceType.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "valid facility snapshot restores")
|
||||
_expect(restored.create_snapshot() == snapshot, "snapshot round trip preserves full facility state")
|
||||
var before_invalid := restored.create_snapshot()
|
||||
var corrupt := snapshot.duplicate(true)
|
||||
corrupt[&"requests"].append(corrupt[&"requests"][0].duplicate(true))
|
||||
_expect(not restored.restore_snapshot(corrupt), "duplicate request in snapshot is rejected")
|
||||
_expect(restored.create_snapshot() == before_invalid, "rejected restore leaves live state untouched")
|
||||
var corrupt_reference := snapshot.duplicate(true)
|
||||
corrupt_reference[&"instances"][0][&"queue_entries"] = ["missing_request"]
|
||||
_expect(not restored.restore_snapshot(corrupt_reference), "orphan queue reference is rejected")
|
||||
_expect(restored.create_snapshot() == before_invalid, "orphan rejection is also atomic")
|
||||
|
||||
|
||||
func _deterministic_run() -> Dictionary:
|
||||
var service := FacilityServiceType.new()
|
||||
var facility := _facility(&"deterministic_room", 2, 6, 0, 2)
|
||||
service.register_instance(facility)
|
||||
for request_data: Array in [
|
||||
[&"request_d", 0],
|
||||
[&"request_b", 1],
|
||||
[&"request_c", 1],
|
||||
[&"request_a", 0],
|
||||
]:
|
||||
var request_id: StringName = request_data[0]
|
||||
var priority: int = request_data[1]
|
||||
service.enqueue_request(_request(service, facility, request_id, 50, priority, 2), 50)
|
||||
service.fill_available_slots(facility.instance_id, 50)
|
||||
service.advance_one_minute(51)
|
||||
service.advance_one_minute(52)
|
||||
service.advance_one_minute(53)
|
||||
return service.create_snapshot()
|
||||
|
||||
|
||||
func _facility(
|
||||
instance_id: StringName,
|
||||
capacity: int,
|
||||
queue_capacity: int,
|
||||
staff_min: int,
|
||||
service_minutes: int
|
||||
) -> FacilityInstance:
|
||||
return FacilityInstanceType.new().configure(
|
||||
instance_id,
|
||||
&"test_facility",
|
||||
{
|
||||
&"capacity": capacity,
|
||||
&"queue_capacity": queue_capacity,
|
||||
&"staff_min": staff_min,
|
||||
&"staff_max": maxi(staff_min, 2),
|
||||
&"active_staff_ids": [&"staff_a"] if staff_min > 0 else [],
|
||||
&"base_service_minutes": service_minutes,
|
||||
&"base_quality": 75,
|
||||
&"base_price": 100,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
func _request(
|
||||
service: FacilityService,
|
||||
facility: FacilityInstance,
|
||||
request_id: StringName,
|
||||
created_at: int,
|
||||
priority: int,
|
||||
service_minutes: int
|
||||
) -> ServiceRequest:
|
||||
return service.create_request(
|
||||
request_id,
|
||||
facility.instance_id,
|
||||
[StringName("guest_%s" % request_id)],
|
||||
&"test_service",
|
||||
created_at,
|
||||
{&"priority": priority, &"service_minutes": service_minutes}
|
||||
)
|
||||
|
||||
|
||||
func _queue_ids(service: FacilityService, instance_id: StringName) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for request: ServiceRequest in service.get_queue(instance_id):
|
||||
ids.append(request.request_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgrxu5w7jm0an
|
||||
@@ -0,0 +1,591 @@
|
||||
extends SceneTree
|
||||
|
||||
const InventoryServiceScript = preload("res://scripts/inventory/inventory_service.gd")
|
||||
const LogisticsRequestScript = preload("res://scripts/inventory/logistics_request.gd")
|
||||
const LogisticsDispatcherScript = preload("res://scripts/inventory/logistics_dispatcher.gd")
|
||||
const InventoryReservationScript = preload("res://scripts/inventory/inventory_reservation.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S2: Inventory and Logistics")
|
||||
_test_reservation_limits_and_idempotency()
|
||||
_test_transfer_never_duplicates_stock()
|
||||
_test_expiration_rules()
|
||||
_test_orphan_repair_report()
|
||||
_test_dispatch_priority_capacity_and_tie_break()
|
||||
_test_dispatch_missing_carrier_preserves_reserved_load()
|
||||
_test_atomic_transfer_batches_match_sequential_operations()
|
||||
_test_idempotency_conflicts_are_preflighted()
|
||||
_test_request_transitions()
|
||||
_test_snapshot_round_trip_and_atomic_restore()
|
||||
_test_strict_snapshot_migration_and_id_tombstones()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_reservation_limits_and_idempotency() -> void:
|
||||
var service = InventoryServiceScript.new()
|
||||
var added: Dictionary = service.add_stack(&"warehouse", &"meal", 10, 90, -1, 0, 2)
|
||||
_expect(added[&"ok"], "stock can be added to an owner inventory")
|
||||
var first: Dictionary = service.reserve_item(
|
||||
&"warehouse", &"meal", 7, &"cafeteria", &"service", 10, 60, &"reserve-a"
|
||||
)
|
||||
_expect(first[&"ok"] and service.available_quantity(&"warehouse", &"meal", 10) == 3, "reservation reduces available but not physical quantity")
|
||||
var replay: Dictionary = service.reserve_item(
|
||||
&"warehouse", &"meal", 7, &"cafeteria", &"service", 10, 60, &"reserve-a"
|
||||
)
|
||||
_expect(replay[&"ok"] and replay[&"replay"] and service.get_reservations(true).size() == 1, "reservation operation id replays without creating stock claims")
|
||||
var rejected: Dictionary = service.reserve_item(
|
||||
&"warehouse", &"meal", 4, &"kitchen", &"service", 10, 60, &"reserve-b"
|
||||
)
|
||||
_expect(not rejected[&"ok"] and rejected[&"code"] == &"INSUFFICIENT_AVAILABLE", "over-reservation is rejected atomically")
|
||||
var reservation_id: StringName = first[&"reservation_ids"][0]
|
||||
var consumed: Dictionary = service.consume_reservation(reservation_id, &"consume-a", 20)
|
||||
var quantity_after := service.total_quantity(&"warehouse", &"meal")
|
||||
var consumed_again: Dictionary = service.consume_reservation(reservation_id, &"consume-b", 21)
|
||||
_expect(consumed[&"ok"] and quantity_after == 3, "consumption removes exactly the reserved physical stock")
|
||||
_expect(consumed_again[&"ok"] and consumed_again[&"replay"] and service.total_quantity(&"warehouse", &"meal") == quantity_after, "a consumed reservation cannot consume twice")
|
||||
_expect(service.validate_invariants()[&"ok"], "reserve and consume preserve quantity minus reserved invariant")
|
||||
|
||||
|
||||
func _test_transfer_never_duplicates_stock() -> void:
|
||||
var service = InventoryServiceScript.new()
|
||||
service.add_stack(&"source", &"material", 12, 75, 500, 3, 3)
|
||||
var before := service.total_quantity(&"source", &"material") + service.total_quantity(&"destination", &"material")
|
||||
var moved: Dictionary = service.transfer_available(
|
||||
&"source", &"destination", &"material", 5, &"transfer-a", 20
|
||||
)
|
||||
var after := service.total_quantity(&"source", &"material") + service.total_quantity(&"destination", &"material")
|
||||
_expect(moved[&"ok"] and before == after and after == 12, "owner transfer conserves global item quantity")
|
||||
_expect(service.total_quantity(&"source", &"material") == 7 and service.total_quantity(&"destination", &"material") == 5, "transfer subtracts source before adding destination")
|
||||
var replay: Dictionary = service.transfer_available(
|
||||
&"source", &"destination", &"material", 5, &"transfer-a", 20
|
||||
)
|
||||
_expect(replay[&"replay"] and service.total_quantity(&"destination", &"material") == 5, "transfer replay cannot duplicate destination stock")
|
||||
|
||||
var reserved: Dictionary = service.reserve_item(
|
||||
&"source", &"material", 4, &"delivery-1", &"logistics", 21, 100, &"pickup-reserve"
|
||||
)
|
||||
var pickup: Dictionary = service.transfer_reserved(
|
||||
reserved[&"reservation_ids"], &"carrier-cargo", &"pickup-a", 22
|
||||
)
|
||||
_expect(pickup[&"ok"] and pickup[&"weight"] == 12, "reserved pickup transfers the exact reserved weight")
|
||||
_expect(service.total_quantity(&"source", &"material") == 3 and service.total_quantity(&"carrier-cargo", &"material") == 4, "reserved pickup consumes reservation and creates cargo once")
|
||||
_expect(service.validate_invariants()[&"ok"], "available and reserved remain valid after transfer")
|
||||
|
||||
|
||||
func _test_expiration_rules() -> void:
|
||||
var service = InventoryServiceScript.new()
|
||||
var added: Dictionary = service.add_stack(&"cold-store", &"food", 8, 80, 30, 0, 1)
|
||||
var reservation: Dictionary = service.reserve_item(
|
||||
&"cold-store", &"food", 3, &"service-a", &"meal", 10, 50, &"expiry-reserve"
|
||||
)
|
||||
_expect(not service.is_stack_expired(added[&"stack_id"], 29), "stack remains valid before expires_at")
|
||||
_expect(service.is_stack_expired(added[&"stack_id"], 30), "stack expires exactly at expires_at")
|
||||
var report: Dictionary = service.process_expired(30)
|
||||
_expect(report[&"discarded_quantity"] == 5 and service.total_quantity(&"cold-store", &"food") == 3, "expiration discards only unreserved quantity")
|
||||
var consume: Dictionary = service.consume_reservation(reservation[&"reservation_ids"][0], &"expired-consume", 30)
|
||||
_expect(not consume[&"ok"] and consume[&"code"] == &"STACK_EXPIRED", "reserved stack is checked again before consumption")
|
||||
service.process_expired(50)
|
||||
_expect(service.total_quantity(&"cold-store", &"food") == 0, "reservation expiry releases and discards remaining expired stock")
|
||||
|
||||
|
||||
func _test_orphan_repair_report() -> void:
|
||||
var service = InventoryServiceScript.new()
|
||||
var added: Dictionary = service.add_stack(&"store", &"part", 5)
|
||||
var reservation: Dictionary = service.reserve_item(
|
||||
&"store", &"part", 2, &"repair-job", &"build", 0, -1, &"orphan-reserve"
|
||||
)
|
||||
var stacks: Dictionary = service._inventories[&"store"]
|
||||
stacks.erase(added[&"stack_id"])
|
||||
service._inventories[&"store"] = stacks
|
||||
var report: Dictionary = service.audit_and_repair_reservations(5)
|
||||
var repaired_reservation = service.get_reservation(reservation[&"reservation_ids"][0])
|
||||
_expect(report[&"orphan_count"] == 1 and report[&"released_reservation_ids"].size() == 1, "orphan repair returns a structured repair report")
|
||||
_expect(repaired_reservation.state == InventoryReservationScript.State.RELEASED, "orphan repair closes the dangling reservation")
|
||||
|
||||
|
||||
func _test_dispatch_priority_capacity_and_tie_break() -> void:
|
||||
var high = _reserved_request(&"request-high", 90, 6, 300)
|
||||
var low = _reserved_request(&"request-low", 20, 6, 400)
|
||||
var dispatcher = LogisticsDispatcherScript.new()
|
||||
var carriers := {
|
||||
&"carrier-b": {
|
||||
&"position": Vector3i(1, 0, 1),
|
||||
&"capacity": 10,
|
||||
&"current_load": 0,
|
||||
&"role": &"logistics",
|
||||
&"path_minutes": {&"default": 5},
|
||||
},
|
||||
&"carrier-a": {
|
||||
&"position": Vector3i(0, 0, 1),
|
||||
&"capacity": 10,
|
||||
&"current_load": 0,
|
||||
&"role": &"logistics",
|
||||
&"path_minutes": {&"default": 5},
|
||||
},
|
||||
}
|
||||
var assignments: Array[Dictionary] = dispatcher.dispatch([low, high], carriers, 100)
|
||||
_expect(assignments.size() == 2 and assignments[0][&"request_id"] == &"request-high", "dispatcher evaluates requests in deterministic priority order")
|
||||
_expect(assignments[0][&"carrier_id"] == &"carrier-a", "equal scores use lexical carrier id as tie break")
|
||||
_expect(assignments[1][&"carrier_id"] == &"carrier-b", "reserved load prevents assigning beyond one carrier capacity")
|
||||
_expect(dispatcher.get_reserved_load(&"carrier-a") == 6 and dispatcher.get_reserved_load(&"carrier-b") == 6, "dispatcher records capacity reservations for later passes")
|
||||
var reordered_dispatcher = LogisticsDispatcherScript.new()
|
||||
var reordered_carriers := {
|
||||
&"carrier-a": carriers[&"carrier-a"],
|
||||
&"carrier-b": carriers[&"carrier-b"],
|
||||
}
|
||||
var reordered_assignments: Array[Dictionary] = reordered_dispatcher.dispatch([
|
||||
_reserved_request(&"request-high", 90, 6, 300),
|
||||
_reserved_request(&"request-low", 20, 6, 400),
|
||||
], reordered_carriers, 100)
|
||||
_expect(
|
||||
reordered_assignments == assignments,
|
||||
"one-pass carrier ordering remains deterministic across request and Dictionary insertion order"
|
||||
)
|
||||
|
||||
|
||||
func _test_dispatch_missing_carrier_preserves_reserved_load() -> void:
|
||||
var dispatcher = LogisticsDispatcherScript.new()
|
||||
var carrier := {
|
||||
&"carrier-a": {
|
||||
&"position": Vector3i.ZERO,
|
||||
&"capacity": 10,
|
||||
&"current_load": 0,
|
||||
&"role": &"logistics",
|
||||
&"path_minutes": 1,
|
||||
},
|
||||
}
|
||||
var first = _reserved_request(&"request-carrier-present", 90, 6, 300)
|
||||
_expect(
|
||||
dispatcher.dispatch([first], carrier, 100).size() == 1,
|
||||
"carrier accepts an assignment within capacity"
|
||||
)
|
||||
var second = _reserved_request(&"request-carrier-absent", 80, 6, 310)
|
||||
_expect(
|
||||
dispatcher.dispatch([second], {}, 101).is_empty()
|
||||
and dispatcher.get_reserved_load(&"carrier-a") == 6,
|
||||
"temporarily absent carrier keeps its accepted reserved load"
|
||||
)
|
||||
var absent_snapshot: Dictionary = dispatcher.create_snapshot()
|
||||
var restored = LogisticsDispatcherScript.new()
|
||||
_expect(
|
||||
restored.restore_snapshot(absent_snapshot)
|
||||
and restored.create_snapshot() == absent_snapshot,
|
||||
"missing-carrier reservation snapshot restores exactly"
|
||||
)
|
||||
_expect(
|
||||
restored.dispatch([second], carrier, 102).is_empty()
|
||||
and restored.get_reserved_load(&"carrier-a") == 6,
|
||||
"reappearing carrier cannot accept another six units into capacity ten"
|
||||
)
|
||||
_expect(
|
||||
restored.release_assignment(&"carrier-a", 6) == 0
|
||||
and restored.create_snapshot()[&"carrier_loads"].is_empty(),
|
||||
"explicit assignment release removes the retained carrier load"
|
||||
)
|
||||
|
||||
|
||||
func _test_atomic_transfer_batches_match_sequential_operations() -> void:
|
||||
var seed = InventoryServiceScript.new()
|
||||
seed.add_stack(&"warehouse", &"supply", 4, 87, 600, 3, 2, &"batch-source")
|
||||
var reservation_a: Dictionary = seed.reserve_item(
|
||||
&"warehouse", &"supply", 2, &"request-a", &"delivery", 10, 100, &"batch-reserve-a"
|
||||
)
|
||||
var reservation_b: Dictionary = seed.reserve_item(
|
||||
&"warehouse", &"supply", 2, &"request-b", &"delivery", 10, 100, &"batch-reserve-b"
|
||||
)
|
||||
var ids_a: Array[StringName] = []
|
||||
var ids_b: Array[StringName] = []
|
||||
ids_a.assign(reservation_a[&"reservation_ids"])
|
||||
ids_b.assign(reservation_b[&"reservation_ids"])
|
||||
|
||||
var sequential = InventoryServiceScript.new()
|
||||
var batched = InventoryServiceScript.new()
|
||||
sequential.restore_snapshot(seed.create_snapshot())
|
||||
batched.restore_snapshot(seed.create_snapshot())
|
||||
sequential.transfer_reserved(ids_a, &"cargo-a", &"pickup-a", 20)
|
||||
sequential.transfer_reserved(ids_b, &"cargo-b", &"pickup-b", 20)
|
||||
var pickup_entries: Array[Dictionary] = [
|
||||
{&"reservation_ids": ids_a, &"destination_inventory_id": &"cargo-a", &"operation_id": &"pickup-a"},
|
||||
{&"reservation_ids": ids_b, &"destination_inventory_id": &"cargo-b", &"operation_id": &"pickup-b"},
|
||||
]
|
||||
var pickup_batch: Dictionary = batched.transfer_reserved_batch(pickup_entries, 20)
|
||||
_expect(
|
||||
pickup_batch[&"ok"] and pickup_batch[&"transfer_count"] == 2,
|
||||
"reserved-stock batch applies every ordered pickup atomically"
|
||||
)
|
||||
_expect(
|
||||
batched.create_snapshot() == sequential.create_snapshot(),
|
||||
"reserved-stock batch is snapshot-equivalent to sequential public operations"
|
||||
)
|
||||
var before_replay := batched.create_snapshot()
|
||||
var pickup_replay: Dictionary = batched.transfer_reserved_batch(pickup_entries, 20)
|
||||
_expect(
|
||||
pickup_replay[&"ok"]
|
||||
and pickup_replay[&"results"][0][&"replay"]
|
||||
and pickup_replay[&"results"][1][&"replay"]
|
||||
and batched.create_snapshot() == before_replay,
|
||||
"batch replay preserves per-operation idempotency without another transfer"
|
||||
)
|
||||
|
||||
var pickup_snapshot := batched.create_snapshot()
|
||||
sequential.transfer_available(&"cargo-a", &"destination", &"supply", 2, &"deliver-a", 21)
|
||||
sequential.transfer_available(&"cargo-b", &"destination", &"supply", 2, &"deliver-b", 21)
|
||||
var delivery_batch: Dictionary = batched.transfer_available_batch([
|
||||
{
|
||||
&"source_inventory_id": &"cargo-a",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 2,
|
||||
&"operation_id": &"deliver-a",
|
||||
},
|
||||
{
|
||||
&"source_inventory_id": &"cargo-b",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 2,
|
||||
&"operation_id": &"deliver-b",
|
||||
},
|
||||
], 21)
|
||||
_expect(
|
||||
delivery_batch[&"ok"] and batched.create_snapshot() == sequential.create_snapshot(),
|
||||
"available-stock batch preserves FEFO results, stack ids, and operation order"
|
||||
)
|
||||
|
||||
var rollback = InventoryServiceScript.new()
|
||||
rollback.restore_snapshot(pickup_snapshot)
|
||||
var before_failure := rollback.create_snapshot()
|
||||
var failed_batch: Dictionary = rollback.transfer_available_batch([
|
||||
{
|
||||
&"source_inventory_id": &"cargo-a",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 2,
|
||||
&"operation_id": &"rollback-first",
|
||||
},
|
||||
{
|
||||
&"source_inventory_id": &"missing-cargo",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 1,
|
||||
&"operation_id": &"rollback-second",
|
||||
},
|
||||
], 22)
|
||||
_expect(
|
||||
not failed_batch[&"ok"] and failed_batch[&"code"] == &"ATOMIC_BATCH_FAILED",
|
||||
"a failed line rejects the complete transfer batch"
|
||||
)
|
||||
_expect(
|
||||
rollback.create_snapshot() == before_failure,
|
||||
"failed batch rolls back stock, generated ids, and idempotency records exactly"
|
||||
)
|
||||
|
||||
|
||||
func _test_idempotency_conflicts_are_preflighted() -> void:
|
||||
var sequential = InventoryServiceScript.new()
|
||||
sequential.add_stack(&"source-a", &"supply", 4)
|
||||
sequential.add_stack(&"source-b", &"supply", 4)
|
||||
var first: Dictionary = sequential.transfer_available(
|
||||
&"source-a", &"destination", &"supply", 2, &"shared-operation", 10
|
||||
)
|
||||
var after_first := sequential.create_snapshot()
|
||||
var conflict: Dictionary = sequential.transfer_available(
|
||||
&"source-b", &"destination", &"supply", 2, &"shared-operation", 10
|
||||
)
|
||||
_expect(first[&"ok"], "first normalized input binds its operation id")
|
||||
_expect(
|
||||
not conflict[&"ok"]
|
||||
and conflict[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
||||
and sequential.create_snapshot() == after_first,
|
||||
"sequential operation-id reuse with different input is rejected without side effects"
|
||||
)
|
||||
var action_conflict: Dictionary = sequential.reserve_item(
|
||||
&"source-a", &"supply", 1, &"requester", &"delivery", 10, -1, &"shared-operation"
|
||||
)
|
||||
_expect(
|
||||
not action_conflict[&"ok"]
|
||||
and action_conflict[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
||||
and sequential.create_snapshot() == after_first,
|
||||
"operation id is bound to its action as well as normalized input"
|
||||
)
|
||||
|
||||
var duplicate_batch = InventoryServiceScript.new()
|
||||
duplicate_batch.add_stack(&"source-a", &"supply", 4)
|
||||
duplicate_batch.add_stack(&"source-b", &"supply", 4)
|
||||
var before_duplicate := duplicate_batch.create_snapshot()
|
||||
var duplicate_result: Dictionary = duplicate_batch.transfer_available_batch([
|
||||
{
|
||||
&"source_inventory_id": &"source-a",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 2,
|
||||
&"operation_id": &"batch-shared",
|
||||
},
|
||||
{
|
||||
&"source_inventory_id": &"source-b",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 2,
|
||||
&"operation_id": &"batch-shared",
|
||||
},
|
||||
], 10)
|
||||
_expect(
|
||||
not duplicate_result[&"ok"]
|
||||
and duplicate_result[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
||||
and duplicate_batch.create_snapshot() == before_duplicate,
|
||||
"A-to-D then B-to-D duplicate operation id is rejected before the batch mutates"
|
||||
)
|
||||
|
||||
var existing_conflict = InventoryServiceScript.new()
|
||||
existing_conflict.add_stack(&"source-a", &"supply", 4)
|
||||
existing_conflict.add_stack(&"source-b", &"supply", 4)
|
||||
existing_conflict.transfer_available(
|
||||
&"source-a", &"destination", &"supply", 1, &"already-bound", 11
|
||||
)
|
||||
var before_existing_conflict := existing_conflict.create_snapshot()
|
||||
var existing_result: Dictionary = existing_conflict.transfer_available_batch([
|
||||
{
|
||||
&"source_inventory_id": &"source-b",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 1,
|
||||
&"operation_id": &"would-be-first",
|
||||
},
|
||||
{
|
||||
&"source_inventory_id": &"source-b",
|
||||
&"destination_inventory_id": &"destination",
|
||||
&"item_id": &"supply",
|
||||
&"quantity": 1,
|
||||
&"operation_id": &"already-bound",
|
||||
},
|
||||
], 11)
|
||||
_expect(
|
||||
not existing_result[&"ok"]
|
||||
and existing_result[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
||||
and existing_conflict.create_snapshot() == before_existing_conflict,
|
||||
"existing operation-id conflict is found before an earlier batch row can apply"
|
||||
)
|
||||
|
||||
|
||||
func _test_request_transitions() -> void:
|
||||
var request = _reserved_request(&"request-state", 50, 4, 200)
|
||||
_expect(request.assign(&"carrier", 20), "RESERVED request accepts a carrier assignment")
|
||||
_expect(request.transition_to(LogisticsRequestScript.State.PICKING_UP, "", 21), "ASSIGNED transitions to PICKING_UP")
|
||||
_expect(request.transition_to(LogisticsRequestScript.State.DELIVERING, "", 22), "PICKING_UP transitions to DELIVERING")
|
||||
_expect(request.transition_to(LogisticsRequestScript.State.COMPLETED, "", 23), "DELIVERING transitions to COMPLETED")
|
||||
_expect(not request.transition_to(LogisticsRequestScript.State.OPEN, "", 24), "terminal logistics request cannot reopen")
|
||||
|
||||
|
||||
func _test_snapshot_round_trip_and_atomic_restore() -> void:
|
||||
var original = InventoryServiceScript.new()
|
||||
original.add_stack(&"warehouse", &"supply", 10, 88, 700, 5, 2)
|
||||
original.reserve_item(&"warehouse", &"supply", 4, &"facility", &"restock", 10, 80, &"snapshot-reserve")
|
||||
var snapshot: Dictionary = original.create_snapshot()
|
||||
var restored = InventoryServiceScript.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "valid inventory snapshot restores")
|
||||
_expect(restored.create_snapshot() == snapshot, "inventory snapshot round trip is exact")
|
||||
|
||||
var before_invalid: Dictionary = restored.create_snapshot()
|
||||
var invalid: Dictionary = snapshot.duplicate(true)
|
||||
invalid[&"inventories"][0][&"stacks"][0][&"reserved_quantity"] = 99
|
||||
_expect(not restored.restore_snapshot(invalid), "invalid inventory snapshot is rejected")
|
||||
_expect(restored.create_snapshot() == before_invalid, "failed restore leaves live inventory state untouched")
|
||||
|
||||
var request = _reserved_request(&"request-save", 70, 3, 500)
|
||||
var request_snapshot: Dictionary = request.create_snapshot()
|
||||
var restored_request = LogisticsRequestScript.from_snapshot(request_snapshot)
|
||||
_expect(restored_request != null and restored_request.create_snapshot() == request_snapshot, "logistics request round trip preserves state")
|
||||
|
||||
var dispatcher = LogisticsDispatcherScript.new()
|
||||
dispatcher.dispatch([request], {
|
||||
&"carrier": {
|
||||
&"capacity": 10,
|
||||
&"current_load": 0,
|
||||
&"role": &"porter",
|
||||
&"path_minutes": 2,
|
||||
&"position": Vector3i.ZERO,
|
||||
},
|
||||
}, 40)
|
||||
var dispatcher_snapshot: Dictionary = dispatcher.create_snapshot()
|
||||
var restored_dispatcher = LogisticsDispatcherScript.new()
|
||||
_expect(restored_dispatcher.restore_snapshot(dispatcher_snapshot), "dispatcher snapshot restores")
|
||||
_expect(restored_dispatcher.create_snapshot() == dispatcher_snapshot, "dispatcher snapshot round trip is exact")
|
||||
|
||||
|
||||
func _test_strict_snapshot_migration_and_id_tombstones() -> void:
|
||||
var base = InventoryServiceScript.new()
|
||||
base.add_stack(&"warehouse", &"supply", 3, 90, 100, 5, 1)
|
||||
base.reserve_item(
|
||||
&"warehouse", &"supply", 1, &"requester", &"delivery", 6, 90, &"legacy-reserve"
|
||||
)
|
||||
var snapshot: Dictionary = base.create_snapshot()
|
||||
var guard = InventoryServiceScript.new()
|
||||
_expect(guard.restore_snapshot(snapshot), "strict snapshot fixture restores before corruption checks")
|
||||
var guard_snapshot := guard.create_snapshot()
|
||||
|
||||
var negative_minute := snapshot.duplicate(true)
|
||||
negative_minute[&"current_minute"] = -1
|
||||
var negative_created := snapshot.duplicate(true)
|
||||
negative_created[&"inventories"][0][&"stacks"][0][&"created_at"] = -1
|
||||
var negative_expiry := snapshot.duplicate(true)
|
||||
negative_expiry[&"inventories"][0][&"stacks"][0][&"expires_at"] = -2
|
||||
var invalid_terminal := snapshot.duplicate(true)
|
||||
invalid_terminal[&"reservations"][0][&"terminal_at"] = 0
|
||||
_expect(
|
||||
not guard.restore_snapshot(negative_minute)
|
||||
and not guard.restore_snapshot(negative_created)
|
||||
and not guard.restore_snapshot(negative_expiry)
|
||||
and not guard.restore_snapshot(invalid_terminal),
|
||||
"restore rejects negative or state-inconsistent canonical timestamps instead of clamping"
|
||||
)
|
||||
_expect(
|
||||
guard.create_snapshot() == guard_snapshot,
|
||||
"strict timestamp rejection is atomic"
|
||||
)
|
||||
|
||||
var low_stack_sequence := snapshot.duplicate(true)
|
||||
low_stack_sequence[&"next_stack_sequence"] = 1
|
||||
var low_reservation_sequence := snapshot.duplicate(true)
|
||||
low_reservation_sequence[&"next_reservation_sequence"] = 1
|
||||
_expect(
|
||||
not guard.restore_snapshot(low_stack_sequence)
|
||||
and not guard.restore_snapshot(low_reservation_sequence)
|
||||
and guard.create_snapshot() == guard_snapshot,
|
||||
"next sequences at or below existing canonical ids are atomically rejected"
|
||||
)
|
||||
|
||||
var tombstone_source = InventoryServiceScript.new()
|
||||
var first_stack: Dictionary = tombstone_source.add_stack(&"source", &"part", 1)
|
||||
var reservation: Dictionary = tombstone_source.reserve_item(
|
||||
&"source", &"part", 1, &"consumer", &"use", 0, -1, &"tombstone-reserve"
|
||||
)
|
||||
tombstone_source.consume_reservation(
|
||||
reservation[&"reservation_ids"][0], &"tombstone-consume", 1
|
||||
)
|
||||
var tombstone_snapshot := tombstone_source.create_snapshot()
|
||||
_expect(
|
||||
tombstone_source.total_quantity(&"source", &"part") == 0
|
||||
and tombstone_snapshot[&"known_stack_ids"].has(String(first_stack[&"stack_id"])),
|
||||
"deleted generated stack id remains in persisted known-id history"
|
||||
)
|
||||
var corrupt_tombstone_sequence := tombstone_snapshot.duplicate(true)
|
||||
corrupt_tombstone_sequence[&"next_stack_sequence"] = 1
|
||||
var tombstone_restored = InventoryServiceScript.new()
|
||||
_expect(
|
||||
not tombstone_restored.restore_snapshot(corrupt_tombstone_sequence),
|
||||
"tombstone prevents a corrupted sequence from reusing a deleted auto id"
|
||||
)
|
||||
_expect(
|
||||
tombstone_restored.restore_snapshot(tombstone_snapshot)
|
||||
and tombstone_restored.create_snapshot() == tombstone_snapshot
|
||||
and tombstone_restored.add_stack(&"source", &"part", 1)[&"stack_id"] == &"stack_00000002",
|
||||
"valid tombstone snapshot round-trips exactly and allocates the next unused id"
|
||||
)
|
||||
var legacy_deleted = InventoryServiceScript.new()
|
||||
var migrated_deleted_ok := legacy_deleted.restore_snapshot(
|
||||
_as_legacy_inventory_snapshot(tombstone_snapshot)
|
||||
)
|
||||
_expect(
|
||||
migrated_deleted_ok
|
||||
and legacy_deleted.create_snapshot()[&"known_stack_ids"].has("stack_00000001"),
|
||||
"version-1 counter migration reconstructs tombstones for deleted generated ids"
|
||||
)
|
||||
|
||||
var legacy_snapshot := _as_legacy_inventory_snapshot(snapshot)
|
||||
var migrated = InventoryServiceScript.new()
|
||||
var legacy_restored := migrated.restore_snapshot(legacy_snapshot)
|
||||
var legacy_replay: Dictionary = migrated.reserve_item(
|
||||
&"warehouse", &"supply", 1, &"requester", &"delivery", 6, 90, &"legacy-reserve"
|
||||
)
|
||||
_expect(
|
||||
legacy_restored and legacy_replay[&"ok"] and legacy_replay[&"replay"],
|
||||
"version-1 snapshot migrates while preserving legacy replay behavior"
|
||||
)
|
||||
var rebound_conflict: Dictionary = migrated.reserve_item(
|
||||
&"warehouse", &"supply", 2, &"requester", &"delivery", 6, 90, &"legacy-reserve"
|
||||
)
|
||||
_expect(
|
||||
not rebound_conflict[&"ok"] and rebound_conflict[&"code"] == &"IDEMPOTENCY_CONFLICT",
|
||||
"first legacy replay upgrades the operation id to an input-bound record"
|
||||
)
|
||||
var migrated_snapshot: Dictionary = migrated.create_snapshot()
|
||||
var migrated_roundtrip = InventoryServiceScript.new()
|
||||
_expect(
|
||||
migrated_snapshot[&"version"] == 2
|
||||
and migrated_roundtrip.restore_snapshot(migrated_snapshot)
|
||||
and migrated_roundtrip.create_snapshot() == migrated_snapshot,
|
||||
"migrated version-2 snapshot is canonical and exact on subsequent restore"
|
||||
)
|
||||
var ambiguous_legacy := _as_legacy_inventory_snapshot(snapshot)
|
||||
ambiguous_legacy[&"idempotency_results"].append({
|
||||
&"operation_key": "transfer|legacy-reserve",
|
||||
&"result": {&"ok": true, &"code": &"OK", &"replay": false},
|
||||
})
|
||||
var ambiguous = InventoryServiceScript.new()
|
||||
var ambiguous_loaded := ambiguous.restore_snapshot(ambiguous_legacy)
|
||||
var ambiguous_use: Dictionary = ambiguous.transfer_available(
|
||||
&"warehouse", &"destination", &"supply", 1, &"legacy-reserve", 6
|
||||
)
|
||||
_expect(
|
||||
ambiguous_loaded
|
||||
and not ambiguous_use[&"ok"]
|
||||
and ambiguous_use[&"code"] == &"IDEMPOTENCY_CONFLICT",
|
||||
"legacy cross-action duplicate ids load compatibly and fail closed on replay"
|
||||
)
|
||||
|
||||
|
||||
func _as_legacy_inventory_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
var legacy_snapshot := snapshot.duplicate(true)
|
||||
legacy_snapshot[&"version"] = 1
|
||||
legacy_snapshot.erase(&"known_stack_ids")
|
||||
legacy_snapshot.erase(&"known_reservation_ids")
|
||||
var legacy_results: Array[Dictionary] = []
|
||||
for record: Dictionary in legacy_snapshot[&"idempotency_results"]:
|
||||
legacy_results.append({
|
||||
&"operation_key": "%s|%s" % [record[&"action"], record[&"operation_id"]],
|
||||
&"result": (record[&"result"] as Dictionary).duplicate(true),
|
||||
})
|
||||
legacy_snapshot[&"idempotency_results"] = legacy_results
|
||||
return legacy_snapshot
|
||||
|
||||
|
||||
func _reserved_request(
|
||||
request_id: StringName,
|
||||
priority: int,
|
||||
weight: int,
|
||||
deadline: int
|
||||
):
|
||||
var request = LogisticsRequestScript.new().configure(
|
||||
request_id,
|
||||
&"warehouse",
|
||||
&"facility",
|
||||
{&"supply": weight},
|
||||
priority,
|
||||
10,
|
||||
deadline,
|
||||
weight
|
||||
)
|
||||
var reservations: Array[StringName] = [StringName("reservation-%s" % request_id)]
|
||||
var stacks: Array[StringName] = [StringName("stack-%s" % request_id)]
|
||||
request.mark_reserved(reservations, stacks, 11)
|
||||
return request
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0445hcoy7wh2
|
||||
@@ -0,0 +1,75 @@
|
||||
extends SceneTree
|
||||
|
||||
const StateScript = preload("res://autoload/game_state.gd")
|
||||
const EconomyScript = preload("res://scripts/economy/economy_service.gd")
|
||||
const AdapterScript = preload("res://scripts/economy/ledger_backed_resource_state.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S2: ledger-backed resource adapter")
|
||||
var state = StateScript.new()
|
||||
state.reset()
|
||||
var economy = EconomyScript.new({
|
||||
&"PLAYER_CASH": false,
|
||||
&"CONSTRUCTION_EXPENSE": false,
|
||||
})
|
||||
economy.initialize_account(&"PLAYER_CASH", state.get_resource(&"gold"), 360, &"initial-player")
|
||||
var minute := 400
|
||||
var adapter = AdapterScript.new(
|
||||
state,
|
||||
economy,
|
||||
&"CONSTRUCTION_EXPENSE",
|
||||
&"CONSTRUCTION",
|
||||
&"CONSTRUCTION_REFUND",
|
||||
&"construction_service",
|
||||
func() -> int: return minute
|
||||
)
|
||||
|
||||
_expect(adapter.can_afford({&"gold": 500, &"materials": 8}), "combined ledger and material balances validate together")
|
||||
_expect(adapter.spend({&"gold": 500, &"materials": 8}), "construction cost commits through one resource contract")
|
||||
_expect(economy.get_balance(&"PLAYER_CASH") == 4500, "Gold spending changes the authoritative ledger balance")
|
||||
_expect(state.get_resource(&"gold") == 4500, "GameState Gold remains a read-compatible UI mirror")
|
||||
_expect(state.get_resource(&"materials") == 42, "non-Gold resources commit with the same spend")
|
||||
_expect(economy.get_balance(&"CONSTRUCTION_EXPENSE") == 500, "construction uses an explicit counter-account")
|
||||
var expense_entries: Array = economy.get_entries()
|
||||
_expect(expense_entries[-1].absolute_minute == 400 and expense_entries[-1].category == &"CONSTRUCTION", "ledger entry captures minute and construction category")
|
||||
|
||||
minute = 410
|
||||
_expect(adapter.apply_resource_deltas({&"gold": 125, &"materials": 2}), "demolition refund restores Gold and material atomically")
|
||||
_expect(economy.get_balance(&"PLAYER_CASH") == 4625 and state.get_resource(&"gold") == 4625, "refund keeps ledger and UI mirror equal")
|
||||
_expect(economy.get_balance(&"CONSTRUCTION_EXPENSE") == 375, "refund reduces its original expense counter-account")
|
||||
_expect(state.get_resource(&"materials") == 44, "material refund is applied once")
|
||||
|
||||
var before: Dictionary = adapter.create_snapshot()
|
||||
_expect(not adapter.spend({&"gold": 10_000, &"materials": 1}), "insufficient Gold rejects a mixed spend")
|
||||
_expect(adapter.create_snapshot() == before, "rejected mixed spend changes neither ledger nor resources")
|
||||
_expect(not adapter.apply_resource_deltas({&"gold": 1000}), "refund cannot exceed the recorded expense balance")
|
||||
_expect(adapter.create_snapshot() == before, "failed refund rolls back every resource and ledger field")
|
||||
|
||||
adapter.spend({&"gold": 100, &"materials": 1})
|
||||
_expect(adapter.restore_snapshot(before), "adapter restores resource and economy snapshots together")
|
||||
_expect(adapter.create_snapshot() == before, "adapter snapshot round trip is exact")
|
||||
_expect(economy.validate_ledger()[&"ok"], "restored authoritative ledger still reconciles")
|
||||
|
||||
state.free()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfd08k411g8a7
|
||||
@@ -0,0 +1,135 @@
|
||||
extends SceneTree
|
||||
|
||||
const ManaNetworkServiceScript = preload("res://scripts/mana/mana_network_service.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S2: deterministic Mana network")
|
||||
_test_graph_and_priority_allocation()
|
||||
_test_brownout_threshold_and_hold()
|
||||
_test_storage_flow()
|
||||
_test_cadence_and_disabled_nodes()
|
||||
_test_snapshot_and_invariants()
|
||||
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_graph_and_priority_allocation() -> void:
|
||||
var service = ManaNetworkServiceScript.new()
|
||||
_expect(service.register_source(&"tree", 10), "a source registers with integer output")
|
||||
_expect(service.register_connector(&"trunk_bus"), "a connector registers independently from the walking graph")
|
||||
_expect(service.register_consumer(&"clinic", 6, service.ConsumerPriority.ESSENTIAL), "an essential consumer registers")
|
||||
_expect(service.register_consumer(&"casino", 5, service.ConsumerPriority.LUXURY), "a luxury consumer registers")
|
||||
service.connect_nodes(&"tree", &"trunk_bus")
|
||||
service.connect_nodes(&"clinic", &"trunk_bus")
|
||||
service.connect_nodes(&"casino", &"trunk_bus")
|
||||
var report: Dictionary = service.evaluate_now(0)
|
||||
_expect(report[&"evaluated"] and report[&"components"].size() == 1, "connected nodes evaluate as one Mana component")
|
||||
_expect(service.get_consumer_state(&"clinic") == service.ConsumerState.ACTIVE, "essential demand is supplied first")
|
||||
_expect(service.get_allocated_mana(&"clinic") == 6, "essential consumer receives its full demand")
|
||||
_expect(service.get_consumer_state(&"casino") == service.ConsumerState.DEGRADED, "remaining 80 percent supply creates a brownout")
|
||||
_expect(service.get_allocated_mana(&"casino") == 4, "lower-priority consumer receives only the deterministic remainder")
|
||||
|
||||
var isolated = ManaNetworkServiceScript.new()
|
||||
isolated.register_source(&"source", 4)
|
||||
isolated.register_consumer(&"z_consumer", 4, isolated.ConsumerPriority.NORMAL)
|
||||
isolated.register_consumer(&"a_consumer", 4, isolated.ConsumerPriority.NORMAL)
|
||||
isolated.connect_nodes(&"source", &"z_consumer")
|
||||
isolated.connect_nodes(&"source", &"a_consumer")
|
||||
isolated.evaluate_now(0)
|
||||
_expect(isolated.get_allocated_mana(&"a_consumer") == 4, "equal priorities use consumer id as a stable tie break")
|
||||
_expect(isolated.get_allocated_mana(&"z_consumer") == 0, "stable tie break leaves the later consumer unpowered")
|
||||
|
||||
|
||||
func _test_brownout_threshold_and_hold() -> void:
|
||||
var service = ManaNetworkServiceScript.new()
|
||||
service.register_source(&"source", 75)
|
||||
service.register_consumer(&"workshop", 100)
|
||||
service.connect_nodes(&"source", &"workshop")
|
||||
service.evaluate_now(0)
|
||||
_expect(service.get_consumer_state(&"workshop") == service.ConsumerState.DEGRADED, "exactly 75 percent supply is DEGRADED")
|
||||
service.set_source_output(&"source", 74)
|
||||
service.evaluate_now(10)
|
||||
_expect(service.get_consumer_state(&"workshop") == service.ConsumerState.NO_POWER, "supply below 75 percent becomes NO_POWER")
|
||||
_expect(service.get_node(&"workshop")[&"hold_until"] == 20, "NO_POWER starts a ten-minute minimum off hold")
|
||||
service.set_source_output(&"source", 100)
|
||||
service.evaluate_now(15)
|
||||
_expect(service.get_allocated_mana(&"workshop") == 0, "consumer cannot oscillate on during its off hold")
|
||||
service.evaluate_now(20)
|
||||
_expect(service.get_consumer_state(&"workshop") == service.ConsumerState.ACTIVE, "consumer recovers at the first evaluation after its hold")
|
||||
_expect(service.get_allocated_mana(&"workshop") == 100, "recovered consumer receives full demand")
|
||||
|
||||
|
||||
func _test_storage_flow() -> void:
|
||||
var service = ManaNetworkServiceScript.new()
|
||||
service.register_source(&"source", 4)
|
||||
service.register_storage(&"battery", 20, 10, 6)
|
||||
service.register_consumer(&"lights", 10, service.ConsumerPriority.ESSENTIAL)
|
||||
service.connect_nodes(&"source", &"battery")
|
||||
service.connect_nodes(&"battery", &"lights")
|
||||
var discharge_report: Dictionary = service.evaluate_now(0)
|
||||
_expect(discharge_report[&"components"][0][&"discharged"] == 6, "storage discharge is capped by its max flow")
|
||||
_expect(service.get_storage_current(&"battery") == 4, "discharged Mana is removed exactly once")
|
||||
_expect(service.get_consumer_state(&"lights") == service.ConsumerState.ACTIVE, "stored Mana completes essential supply")
|
||||
service.set_source_output(&"source", 20)
|
||||
var charge_report: Dictionary = service.evaluate_now(10)
|
||||
_expect(charge_report[&"components"][0][&"charged"] == 6, "surplus charges storage at its max flow")
|
||||
_expect(service.get_storage_current(&"battery") == 10, "charging preserves storage capacity invariants")
|
||||
|
||||
|
||||
func _test_cadence_and_disabled_nodes() -> void:
|
||||
var service = ManaNetworkServiceScript.new()
|
||||
service.register_source(&"source", 5)
|
||||
service.register_consumer(&"pump", 5)
|
||||
service.connect_nodes(&"source", &"pump")
|
||||
_expect(not service.advance_one_minute(1)[&"evaluated"], "Mana allocation skips non-ten-minute ticks")
|
||||
_expect(service.advance_one_minute(10)[&"evaluated"], "Mana allocation runs on a ten-minute boundary")
|
||||
_expect(not service.advance_one_minute(10)[&"evaluated"], "the same boundary cannot evaluate twice")
|
||||
_expect(service.set_node_enabled(&"pump", false), "a consumer can be administratively disabled")
|
||||
_expect(service.get_consumer_state(&"pump") == service.ConsumerState.DISABLED, "disabled state applies immediately")
|
||||
service.evaluate_now(20)
|
||||
_expect(service.get_allocated_mana(&"pump") == 0, "disabled consumer receives no Mana")
|
||||
_expect(service.set_node_enabled(&"pump", true), "a disabled consumer can be enabled")
|
||||
service.evaluate_now(30)
|
||||
_expect(service.get_consumer_state(&"pump") == service.ConsumerState.ACTIVE, "enabled consumer returns at the next evaluation")
|
||||
|
||||
|
||||
func _test_snapshot_and_invariants() -> void:
|
||||
var original = ManaNetworkServiceScript.new()
|
||||
original.register_source(&"tree", 12)
|
||||
original.register_connector(&"junction")
|
||||
original.register_storage(&"crystal", 30, 8, 4)
|
||||
original.register_consumer(&"room", 10)
|
||||
original.connect_nodes(&"tree", &"junction")
|
||||
original.connect_nodes(&"junction", &"crystal")
|
||||
original.connect_nodes(&"junction", &"room")
|
||||
original.evaluate_now(10)
|
||||
var snapshot: Dictionary = original.create_snapshot()
|
||||
var restored = ManaNetworkServiceScript.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "Mana graph and runtime state restore from a snapshot")
|
||||
_expect(restored.create_snapshot() == snapshot, "restored Mana snapshot is byte-structure equivalent")
|
||||
_expect(restored.node_count() == 4 and restored.edge_count() == 3, "snapshot restores all nodes and undirected edges")
|
||||
_expect(restored.validate_invariants()[&"valid"], "restored allocation, storage and graph invariants hold")
|
||||
_expect(restored.unregister_node(&"junction"), "removing a node also removes its incident edges")
|
||||
_expect(restored.edge_count() == 0, "node removal cannot leave orphan Mana edges")
|
||||
_expect(restored.validate_invariants()[&"valid"], "node removal keeps graph symmetry valid")
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cv3qj8jpvyxd7
|
||||
@@ -0,0 +1,247 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
var _facility_id: StringName
|
||||
var _facility_inventory_id: StringName
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S2: runtime supply-to-service cycle")
|
||||
_reset_runtime()
|
||||
_build_operational_cafeteria()
|
||||
_deliver_inputs_through_logistics()
|
||||
_run_service_sale_with_round_trip()
|
||||
_test_power_projection()
|
||||
_cleanup()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _reset_runtime() -> void:
|
||||
_cleanup()
|
||||
_clock = load("res://autoload/game_clock.gd").new()
|
||||
_state = load("res://autoload/game_state.gd").new()
|
||||
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
var report: Dictionary = _runtime.initialize(_clock, _state, 2202)
|
||||
_clock.set_paused(true)
|
||||
_expect(report[&"valid"], "runtime initializes all S2 definitions")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 5000, "starting Gold is initialized through the ledger")
|
||||
_expect(_runtime.economy_service.validate_ledger()[&"ok"], "initial ledger reconciles")
|
||||
|
||||
|
||||
func _cleanup() -> void:
|
||||
if _runtime != null:
|
||||
_runtime.free()
|
||||
if _clock != null:
|
||||
_clock.free()
|
||||
if _state != null:
|
||||
_state.free()
|
||||
_runtime = null
|
||||
_clock = null
|
||||
_state = null
|
||||
|
||||
|
||||
func _build_operational_cafeteria() -> void:
|
||||
var build = _runtime.submit_command(
|
||||
&"build_facility",
|
||||
{&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT), &"rotation": 0},
|
||||
&"player", &"", 50, -1, &"s2.build.cafeteria"
|
||||
)
|
||||
_expect(build.is_success(), "cafeteria construction starts")
|
||||
_clock.advance_minutes(180)
|
||||
var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities()
|
||||
_expect(facilities.size() == 1, "construction produces one authoritative facility")
|
||||
_facility_id = StringName(facilities[0][&"instance_id"])
|
||||
var operational = _runtime.facility_service.get_instance(_facility_id)
|
||||
_expect(operational != null and operational.definition_id == &"cafeteria", "completed construction registers with FacilityService")
|
||||
_expect(operational.state == FacilityInstance.State.NO_STAFF, "staff requirement prevents premature opening")
|
||||
_expect(_runtime.economy_service.has_account(StringName("FACILITY_REVENUE:%s" % _facility_id)), "facility receives a dedicated revenue account")
|
||||
_expect(not _runtime.mana_network_service.get_node(_facility_id).is_empty(), "Mana demand is registered for the completed facility")
|
||||
_facility_inventory_id = operational.local_inventory_id
|
||||
var staffing = _runtime.submit_command(
|
||||
&"set_facility_conditions",
|
||||
{&"facility_id": _facility_id, &"conditions": {&"active_staff_ids": [&"cook_1", &"server_1"]}},
|
||||
&"manager", _facility_id, 20, -1, &"s2.staff.cafeteria"
|
||||
)
|
||||
_expect(staffing.is_success(), "two active staff can be assigned through a command")
|
||||
_expect(operational.state == FacilityInstance.State.OPEN, "staffed and powered cafeteria becomes operational")
|
||||
_expect(_state.get_resource(&"gold") == 4200, "construction Gold is paid once through the authoritative ledger")
|
||||
_expect(_runtime.economy_service.get_balance(&"CONSTRUCTION_EXPENSE") == 800, "construction counter-account retains the capital flow")
|
||||
|
||||
|
||||
func _deliver_inputs_through_logistics() -> void:
|
||||
var stocked = _runtime.submit_command(
|
||||
&"add_inventory_stack",
|
||||
{
|
||||
&"owner_inventory_id": &"supplier.main",
|
||||
&"item_id": &"ingredient/basic_meal",
|
||||
&"quantity": 3,
|
||||
&"quality": 70,
|
||||
&"unit_weight": 1,
|
||||
},
|
||||
&"supplier", &"supplier.main", 10, -1, &"s2.stock.ingredients"
|
||||
)
|
||||
_expect(stocked.is_success(), "supplier stock enters InventoryService")
|
||||
var created = _runtime.submit_command(
|
||||
&"create_logistics_request",
|
||||
{
|
||||
&"request_id": &"delivery_meals_1",
|
||||
&"source_inventory_id": &"supplier.main",
|
||||
&"destination_inventory_id": _facility_inventory_id,
|
||||
&"item_requirements": {&"ingredient/basic_meal": 2},
|
||||
&"priority": 80,
|
||||
&"deadline": _clock.absolute_minute + 120,
|
||||
},
|
||||
&"manager", _facility_id, 10, -1, &"s2.logistics.create"
|
||||
)
|
||||
_expect(created.is_success(), "logistics request reserves supplier stock")
|
||||
var logistics: LogisticsRequest = _runtime.logistics_requests[&"delivery_meals_1"]
|
||||
_expect(logistics.state == LogisticsRequest.State.RESERVED and logistics.total_weight == 2, "request records reserved weight and state")
|
||||
_expect(_runtime.inventory_service.available_quantity(&"supplier.main", &"ingredient/basic_meal") == 1, "reserved supplier stock cannot be reused")
|
||||
|
||||
var dispatched = _runtime.submit_command(
|
||||
&"dispatch_logistics",
|
||||
{&"carriers": {
|
||||
&"porter_1": {&"capacity": 10, &"current_load": 0, &"path_minutes": 2, &"role": &"logistics"},
|
||||
&"porter_2": {&"capacity": 10, &"current_load": 0, &"path_minutes": 4, &"role": &"carrier"},
|
||||
}},
|
||||
&"dispatcher", &"delivery_meals_1", 10, -1, &"s2.logistics.dispatch"
|
||||
)
|
||||
_expect(dispatched.is_success(), "dispatcher evaluates the reserved request")
|
||||
_expect(logistics.assigned_carrier_id == &"porter_1", "deterministic score chooses the suitable nearest carrier")
|
||||
_expect(logistics.state == LogisticsRequest.State.ASSIGNED, "dispatch advances request to ASSIGNED")
|
||||
|
||||
var start_pickup = _advance_delivery(&"s2.logistics.to_pickup")
|
||||
_expect(start_pickup.is_success() and logistics.state == LogisticsRequest.State.PICKING_UP, "assigned carrier begins pickup")
|
||||
var picked_up = _advance_delivery(&"s2.logistics.pickup")
|
||||
_expect(picked_up.is_success() and logistics.state == LogisticsRequest.State.DELIVERING, "source arrival transfers reserved stock into cargo")
|
||||
_expect(_runtime.inventory_service.total_quantity(&"cargo:porter_1", &"ingredient/basic_meal") == 2, "cargo owns exactly the picked quantity")
|
||||
_expect(_runtime.inventory_service.total_quantity(&"supplier.main", &"ingredient/basic_meal") == 1, "pickup subtracts source stock without duplication")
|
||||
var delivered = _advance_delivery(&"s2.logistics.delivery")
|
||||
_expect(delivered.is_success() and logistics.state == LogisticsRequest.State.COMPLETED, "destination arrival completes logistics")
|
||||
_expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 2, "facility inventory receives the delivered inputs")
|
||||
_expect(_runtime.inventory_service.total_quantity(&"cargo:porter_1", &"ingredient/basic_meal") == 0, "completed delivery leaves no duplicated cargo")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(&"porter_1") == 0, "carrier capacity releases on completion")
|
||||
_expect(_runtime.inventory_service.validate_invariants()[&"ok"], "delivery preserves every inventory invariant")
|
||||
|
||||
|
||||
func _advance_delivery(idempotency_key: StringName) -> CommandResult:
|
||||
return _runtime.submit_command(
|
||||
&"advance_logistics_request",
|
||||
{&"request_id": &"delivery_meals_1"},
|
||||
&"porter_1", &"delivery_meals_1", 10, -1, idempotency_key
|
||||
)
|
||||
|
||||
|
||||
func _run_service_sale_with_round_trip() -> void:
|
||||
var wallet = _runtime.submit_command(
|
||||
&"register_guest_wallet",
|
||||
{&"guest_id": &"guest_1", &"gold": 100},
|
||||
&"guest_1", &"guest_1", 10, -1, &"s2.guest.wallet"
|
||||
)
|
||||
_expect(wallet.is_success(), "guest wallet is initialized through SYSTEM_SOURCE")
|
||||
var requested = _runtime.submit_command(
|
||||
&"create_service_request",
|
||||
{
|
||||
&"request_id": &"meal_request_1",
|
||||
&"facility_id": _facility_id,
|
||||
&"guest_ids": [&"guest_1"],
|
||||
&"service_type": &"meal",
|
||||
&"options": {&"service_minutes": 3},
|
||||
},
|
||||
&"guest_1", _facility_id, 10, -1, &"s2.service.request"
|
||||
)
|
||||
_expect(requested.is_success(), "service request reserves its recipe input and enters the queue")
|
||||
_expect(_runtime.inventory_service.available_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 1, "queued service protects one ingredient from reuse")
|
||||
_clock.advance_one_minute()
|
||||
var active: Array = _runtime.facility_service.get_active_services(_facility_id)
|
||||
_expect(active.size() == 1, "facility queue fills an available service slot")
|
||||
var service_id: StringName = active[0].service_id
|
||||
_expect(active[0].price_snapshot == 35 and active[0].duration_minutes == 3, "service freezes quoted price and duration")
|
||||
|
||||
var mid_snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var mid_digest := SimulationDigest.compute(mid_snapshot)
|
||||
_clock.advance_minutes(3)
|
||||
var completed: ServiceInstance = _runtime.facility_service.get_service(service_id)
|
||||
_expect(completed != null and completed.state == ServiceInstance.State.COMPLETED, "three active minutes complete the service")
|
||||
_expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 1, "completion consumes exactly one reserved ingredient")
|
||||
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == 65, "guest pays the immutable quoted price")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4235, "service cash sweep increases player Gold")
|
||||
_expect(_state.get_resource(&"gold") == 4235, "UI Gold mirror follows the ledger transaction")
|
||||
_expect(_runtime.economy_service.validate_ledger()[&"ok"], "service sale remains double-entry balanced")
|
||||
var saw_completion := false
|
||||
for event in _runtime.domain_events.recent_events():
|
||||
if event.event_type == &"SERVICE_COMPLETED" and service_id in event.entity_ids:
|
||||
saw_completion = true
|
||||
_expect(saw_completion, "service completion is projected as a domain event")
|
||||
var final_digest: String = _runtime.latest_digest
|
||||
|
||||
_expect(_runtime.restore_snapshot(mid_snapshot), "mid-service snapshot restores all S2 services")
|
||||
_expect(_runtime.latest_digest == mid_digest, "mid-service restore reproduces the exact digest")
|
||||
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == 100, "restore rolls payment back to the pre-completion ledger")
|
||||
_clock.advance_minutes(3)
|
||||
_expect(_runtime.latest_digest == final_digest, "replaying completion reproduces inventory, ledger, events and IDs exactly")
|
||||
_expect(_runtime.economy_service.get_entries().filter(func(entry: LedgerEntry) -> bool: return entry.category == &"SERVICE_SALE").size() == 1, "replayed service sale is recorded exactly once")
|
||||
|
||||
var no_stock = _runtime.submit_command(
|
||||
&"create_service_request",
|
||||
{
|
||||
&"request_id": &"meal_request_too_many",
|
||||
&"facility_id": _facility_id,
|
||||
&"guest_ids": [&"guest_1"],
|
||||
&"options": {&"service_minutes": 20},
|
||||
},
|
||||
&"guest_1", _facility_id, 10, -1, &"s2.service.second"
|
||||
)
|
||||
_expect(no_stock.is_success(), "remaining delivered input can fund one more queued service")
|
||||
var before_rejected: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
var rejected = _runtime.submit_command(
|
||||
&"create_service_request",
|
||||
{
|
||||
&"request_id": &"meal_request_no_stock",
|
||||
&"facility_id": _facility_id,
|
||||
&"guest_ids": [&"guest_1"],
|
||||
},
|
||||
&"guest_1", _facility_id, 10, -1, &"s2.service.no_stock"
|
||||
)
|
||||
_expect(not rejected.is_success(), "service without available recipe input is rejected")
|
||||
_expect(_runtime.inventory_service.create_snapshot() == before_rejected, "rejected service request leaves reservations and stock unchanged")
|
||||
|
||||
|
||||
func _test_power_projection() -> void:
|
||||
_runtime.mana_network_service.set_source_output(_runtime.STARTER_MANA_SOURCE, 0)
|
||||
var minutes_to_boundary: int = 10 - (_clock.absolute_minute % 10)
|
||||
_clock.advance_minutes(minutes_to_boundary)
|
||||
var facility = _runtime.facility_service.get_instance(_facility_id)
|
||||
_expect(_runtime.mana_network_service.get_consumer_state(_facility_id) == ManaNetworkService.ConsumerState.NO_POWER, "Mana shortage shuts the cafeteria consumer down")
|
||||
_expect(facility.state == FacilityInstance.State.NO_POWER, "Mana consumer state projects into FacilityService")
|
||||
var interrupted_request_id := &"meal_request_too_many"
|
||||
var interrupted_services: Array = _runtime.facility_service.get_active_services(_facility_id)
|
||||
_expect(
|
||||
_runtime.facility_service.get_request(interrupted_request_id) != null \
|
||||
and interrupted_services.size() == 1 \
|
||||
and interrupted_services[0].state == ServiceInstance.State.PAUSED,
|
||||
"power loss safely pauses the active service without losing its request"
|
||||
)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2yqh462oc5t2
|
||||
Reference in New Issue
Block a user