Connect paid food purchasing, kitchen production, and porter deliveries
This commit is contained in:
@@ -379,6 +379,7 @@ func _test_v3_migration_and_v4_tamper_guards() -> void:
|
||||
var v3: Dictionary = _runtime.create_snapshot()
|
||||
var v3_runtime: Dictionary = v3[&"systems"][&"runtime"]
|
||||
v3_runtime[&"runtime_schema_version"] = 3
|
||||
v3_runtime.erase(&"kitchen_production")
|
||||
v3_runtime.erase(&"operations")
|
||||
v3_runtime.erase(&"catalog_manifest")
|
||||
v3_runtime.erase(&"progression")
|
||||
@@ -395,10 +396,10 @@ func _test_v3_migration_and_v4_tamper_guards() -> void:
|
||||
_expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 2, "v3 migration derives lifetime Guests from active plus persistent records")
|
||||
_expect(_runtime.get_mvp_guest_definition_ids().size() == 32, "v3 migration registers the current 32 catalog definitions")
|
||||
var migrated: Dictionary = _runtime.create_snapshot()
|
||||
_expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6, "v3 migration emits canonical Runtime schema v6")
|
||||
_expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 7, "v3 migration emits canonical Runtime schema v7")
|
||||
var digest := SimulationDigest.compute(migrated)
|
||||
_expect(_runtime.restore_snapshot(migrated), "canonical v5 snapshot restores")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v5 restore is exact")
|
||||
_expect(_runtime.restore_snapshot(migrated), "canonical v7 snapshot restores")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v7 restore is exact")
|
||||
|
||||
var progressed_legacy: Dictionary = v3.duplicate(true)
|
||||
progressed_legacy[&"game_state"][&"world_tree_rank"] = 2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
extends SceneTree
|
||||
|
||||
const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY
|
||||
const RUNTIME_SNAPSHOT_VERSION := 6
|
||||
const RUNTIME_SNAPSHOT_VERSION := 7
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
@@ -15,7 +15,7 @@ func _run() -> void:
|
||||
print("MVP: production Operations acceptance")
|
||||
_test_waiting_choice_and_command_exactly_once()
|
||||
_test_twenty_four_hour_actual_tick_smoke()
|
||||
_test_full_partial_payroll_bankruptcy_and_v6_tamper()
|
||||
_test_full_partial_payroll_bankruptcy_and_v7_tamper()
|
||||
_test_fourteen_day_boundary_snapshot_determinism()
|
||||
await process_frame
|
||||
if _failures == 0:
|
||||
@@ -39,7 +39,7 @@ func _new_fixture(seed: int, enable_operations := true) -> Dictionary:
|
||||
int(initial_snapshot[&"systems"][&"runtime"].get(
|
||||
&"runtime_schema_version", -1
|
||||
)) == RUNTIME_SNAPSHOT_VERSION,
|
||||
"fixture emits Runtime snapshot schema v6"
|
||||
"fixture emits Runtime snapshot schema v7"
|
||||
)
|
||||
if enable_operations:
|
||||
var enabled: Dictionary = runtime.enable_production_operations()
|
||||
@@ -100,6 +100,7 @@ func _advance_operations_boundary_slice(fixture: Dictionary, amount: int) -> int
|
||||
func _align_operations_fixture_clocks(runtime: Variant, minute: int) -> void:
|
||||
runtime.inventory_service.current_minute = minute
|
||||
runtime.facility_service.current_minute = minute
|
||||
runtime.kitchen_production.current_minute = minute
|
||||
runtime.guest_service.current_minute = minute
|
||||
runtime.staff_service.current_minute = minute
|
||||
runtime.authority_service.current_minute = minute
|
||||
@@ -207,7 +208,7 @@ func _test_waiting_choice_and_command_exactly_once() -> void:
|
||||
var command_snapshot: Dictionary = runtime.create_snapshot()
|
||||
_expect(
|
||||
runtime.restore_snapshot(command_snapshot),
|
||||
"command history survives an exact v6 snapshot round trip"
|
||||
"command history survives an exact v7 snapshot round trip"
|
||||
)
|
||||
var restored_retry: CommandResult = runtime.submit_command(
|
||||
&"change_resources",
|
||||
@@ -247,12 +248,12 @@ func _test_twenty_four_hour_actual_tick_smoke() -> void:
|
||||
_expect(
|
||||
runtime.restore_snapshot(snapshot)
|
||||
and runtime.latest_digest == SimulationDigest.compute(snapshot),
|
||||
"24-hour actual-tick v6 snapshot round trip is exact"
|
||||
"24-hour actual-tick v7 snapshot round trip is exact"
|
||||
)
|
||||
_free_fixture(fixture)
|
||||
|
||||
|
||||
func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void:
|
||||
func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void:
|
||||
var fixture := _new_fixture(73_002, false)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
@@ -339,7 +340,7 @@ func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void:
|
||||
var terminal_digest := SimulationDigest.compute(terminal_snapshot)
|
||||
_expect(
|
||||
runtime.restore_snapshot(terminal_snapshot),
|
||||
"BANKRUPT terminal v6 snapshot restores without clearing terminal state"
|
||||
"BANKRUPT terminal v7 snapshot restores without clearing terminal state"
|
||||
)
|
||||
clock.advance_minutes(180)
|
||||
_expect(
|
||||
@@ -352,32 +353,32 @@ func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void:
|
||||
"BANKRUPT cannot be acknowledged away"
|
||||
)
|
||||
|
||||
_assert_v6_tamper_rejected(
|
||||
_assert_v7_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99,
|
||||
"v6 rejects an out-of-range Operations action-point total"
|
||||
"v7 rejects an out-of-range Operations action-point total"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
_assert_v7_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"next_command_id"] = int(
|
||||
tampered[&"systems"][&"runtime"][&"next_command_id"]
|
||||
) + 1,
|
||||
"v6 rejects command high-water gaps"
|
||||
"v7 rejects command high-water gaps"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
_assert_v7_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
var accepted: Array = tampered[&"systems"][&"runtime"][&"command_queue"][&"accepted"]
|
||||
if not accepted.is_empty():
|
||||
accepted[0][&"idempotency_key"] = &"tampered.accepted.key",
|
||||
"v6 rejects accepted-command/idempotency cross-link tampering"
|
||||
"v7 rejects accepted-command/idempotency cross-link tampering"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
_assert_v7_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
@@ -385,26 +386,26 @@ func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void:
|
||||
summaries[0][&"category_totals"][&"wages"] = int(
|
||||
summaries[0][&"category_totals"][&"wages"]
|
||||
) + 1,
|
||||
"v6 rejects DaySummary/payroll wage reconciliation tampering"
|
||||
"v7 rejects DaySummary/payroll wage reconciliation tampering"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
_assert_v7_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"unexpected_v6_field"] = true,
|
||||
"v6 rejects unknown Runtime fields"
|
||||
tampered[&"systems"][&"runtime"][&"unexpected_v7_field"] = true,
|
||||
"v7 rejects unknown Runtime fields"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
_assert_v7_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true,
|
||||
"v6 rejects unknown Coordinator fields"
|
||||
"v7 rejects unknown Coordinator fields"
|
||||
)
|
||||
_free_fixture(fixture)
|
||||
|
||||
|
||||
func _assert_v6_tamper_rejected(
|
||||
func _assert_v7_tamper_rejected(
|
||||
runtime: Variant,
|
||||
canonical: Dictionary,
|
||||
mutator: Callable,
|
||||
@@ -453,7 +454,7 @@ func _test_fourteen_day_boundary_snapshot_determinism() -> void:
|
||||
var resumed_clock: Variant = resumed[&"clock"]
|
||||
var resumed_runtime: Variant = resumed[&"runtime"]
|
||||
var restored: bool = resumed_runtime.restore_snapshot(midpoint)
|
||||
_expect(restored, "fresh Runtime restores the enabled v6 midpoint snapshot")
|
||||
_expect(restored, "fresh Runtime restores the enabled v7 midpoint snapshot")
|
||||
var resumed_advanced := 0
|
||||
if restored:
|
||||
resumed_advanced = _advance_operations_boundary_slice(
|
||||
|
||||
@@ -57,7 +57,7 @@ func _test_disabled_compatibility_and_enable() -> void:
|
||||
_expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests")
|
||||
var disabled_snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var disabled_runtime: Dictionary = disabled_snapshot[&"systems"][&"runtime"]
|
||||
_expect(int(disabled_runtime[&"runtime_schema_version"]) == 6, "Runtime snapshot advances to schema v6")
|
||||
_expect(int(disabled_runtime[&"runtime_schema_version"]) == 7, "Runtime snapshot advances to schema v7")
|
||||
_expect(
|
||||
disabled_runtime[&"operations"] == {
|
||||
&"wrapper_version": 1,
|
||||
@@ -360,6 +360,7 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void:
|
||||
)
|
||||
var downgraded: Dictionary = canonical.duplicate(true)
|
||||
downgraded[&"systems"][&"runtime"][&"runtime_schema_version"] = 4
|
||||
downgraded[&"systems"][&"runtime"].erase(&"kitchen_production")
|
||||
_expect(
|
||||
_rejects_atomically(downgraded),
|
||||
"relabelled enabled v5 cannot discard Operations history through v4 migration"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
extends SceneTree
|
||||
|
||||
const RUNTIME_SNAPSHOT_VERSION := 6
|
||||
const RUNTIME_SNAPSHOT_VERSION := 7
|
||||
const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [
|
||||
&"excavation",
|
||||
&"spatial_subslots",
|
||||
@@ -61,7 +61,7 @@ func _run() -> void:
|
||||
_test_proxy_completion_projection_failure_is_atomic()
|
||||
_test_vertical_preflight_rejection()
|
||||
_test_demolition_projection_atomicity()
|
||||
_test_v6_snapshot_strictness()
|
||||
_test_current_snapshot_strictness()
|
||||
_test_v5_proxy_migration_determinism()
|
||||
_test_rank_expansion_preserves_route_union()
|
||||
_finish()
|
||||
@@ -112,12 +112,12 @@ func _test_runtime_surface(fixture: Dictionary) -> bool:
|
||||
ready = ready and service != null
|
||||
var snapshot: Dictionary = runtime.create_snapshot()
|
||||
var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"]
|
||||
var v6 := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION
|
||||
_expect(v6, "Runtime emits infrastructure snapshot v6")
|
||||
ready = ready and v6
|
||||
var current_version := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION
|
||||
_expect(current_version, "Runtime emits schema v7 with infrastructure state")
|
||||
ready = ready and current_version
|
||||
for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS:
|
||||
var valid_dictionary := runtime_data.get(key, null) is Dictionary
|
||||
_expect(valid_dictionary, "v6 stores strict %s state" % key)
|
||||
_expect(valid_dictionary, "current Runtime stores strict %s state" % key)
|
||||
ready = ready and valid_dictionary
|
||||
if not ready:
|
||||
print(" PENDING: Runtime v6 infrastructure surface is not connected yet; behavioral cases skipped.")
|
||||
@@ -290,7 +290,7 @@ func _test_excavation_tick_pause_resume_and_completion() -> void:
|
||||
_expect(
|
||||
runtime.restore_snapshot(paused_snapshot)
|
||||
and runtime.create_snapshot() == paused_snapshot,
|
||||
"paused excavation survives an exact Runtime v6 save/restore"
|
||||
"paused excavation survives an exact current Runtime save/restore"
|
||||
)
|
||||
_expect(
|
||||
runtime.excavation_service.get_job(excavation_job_id).get(&"state", &"")
|
||||
@@ -731,7 +731,7 @@ func _test_demolition_projection_atomicity() -> void:
|
||||
_free_fixture(fixture)
|
||||
|
||||
|
||||
func _test_v6_snapshot_strictness() -> void:
|
||||
func _test_current_snapshot_strictness() -> void:
|
||||
var fixture := _new_fixture(81_060)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_promote_rank_two(fixture, &"infra.snapshot.rank"), "snapshot fixture reaches Rank 2")
|
||||
@@ -750,10 +750,10 @@ func _test_v6_snapshot_strictness() -> void:
|
||||
_expect(built[&"ok"], "snapshot fixture stores one completed projection")
|
||||
var snapshot: Dictionary = runtime.create_snapshot()
|
||||
var expected_digest := SimulationDigest.compute(snapshot)
|
||||
_expect(runtime.restore_snapshot(snapshot), "valid Runtime v6 snapshot restores")
|
||||
_expect(runtime.restore_snapshot(snapshot), "valid Runtime v7 snapshot restores")
|
||||
_expect(
|
||||
runtime.create_snapshot() == snapshot and runtime.latest_digest == expected_digest,
|
||||
"Runtime v6 round trip is exact"
|
||||
"Runtime v7 round trip is exact"
|
||||
)
|
||||
|
||||
_assert_runtime_restore_rejected(
|
||||
@@ -761,7 +761,7 @@ func _test_v6_snapshot_strictness() -> void:
|
||||
snapshot,
|
||||
func(candidate: Dictionary) -> void:
|
||||
candidate[&"systems"][&"runtime"][&"unknown_infrastructure"] = {},
|
||||
"unknown v6 Runtime field"
|
||||
"unknown v7 Runtime field"
|
||||
)
|
||||
for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS:
|
||||
_assert_runtime_restore_rejected(
|
||||
@@ -837,6 +837,7 @@ func _test_v5_proxy_migration_determinism() -> void:
|
||||
var legacy_runtime: Dictionary = legacy[&"systems"][&"runtime"]
|
||||
var stripped_v6_events := _strip_v6_infrastructure_domain_history(legacy_runtime)
|
||||
legacy_runtime[&"runtime_schema_version"] = 5
|
||||
legacy_runtime.erase(&"kitchen_production")
|
||||
for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS:
|
||||
legacy_runtime.erase(key)
|
||||
legacy_runtime[&"path"] = baseline[&"runtime"].path_service.create_snapshot()
|
||||
@@ -854,8 +855,8 @@ func _test_v5_proxy_migration_determinism() -> void:
|
||||
_expect(first_runtime.restore_snapshot(legacy), "v5 proxy snapshot migrates through Runtime restore")
|
||||
var first_migrated: Dictionary = first_runtime.create_snapshot()
|
||||
_expect(
|
||||
int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6,
|
||||
"v5 migration is immediately re-emitted as v6"
|
||||
int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 7,
|
||||
"v5 migration is immediately re-emitted as v7"
|
||||
)
|
||||
_expect(
|
||||
first_runtime.facility_infrastructure_projector.has_projection(instance_id)
|
||||
@@ -1056,6 +1057,8 @@ func _strip_v6_infrastructure_domain_history(runtime_data: Dictionary) -> int:
|
||||
|
||||
|
||||
func _legacy_v5_infrastructure_shape_is_clean(runtime_data: Dictionary) -> bool:
|
||||
if runtime_data.has(&"kitchen_production"):
|
||||
return false
|
||||
for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS:
|
||||
if runtime_data.has(key) or runtime_data.has(String(key)):
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
extends SceneTree
|
||||
|
||||
const Catalog = preload("res://scripts/supply/supply_catalog.gd")
|
||||
const KitchenService = preload("res://scripts/supply/kitchen_production_service.gd")
|
||||
|
||||
class RejectingOutputInventory:
|
||||
extends InventoryService
|
||||
|
||||
var reject_output := true
|
||||
|
||||
func add_stack(owner: StringName, item: StringName, quantity: int, quality: int = 100,
|
||||
expires_at: int = -1, created_at: int = -1, unit_weight: int = 1, stack_id: StringName = &"") -> Dictionary:
|
||||
if reject_output and item == &"ingredient/basic_meal":
|
||||
return {&"ok": false, &"code": &"OUTPUT_REJECTED"}
|
||||
return super.add_stack(owner, item, quantity, quality, expires_at, created_at, unit_weight, stack_id)
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _registry: DefinitionRegistry
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("P1: independent kitchen production and supply catalogue")
|
||||
_registry = DefinitionRegistry.new()
|
||||
_expect(_registry.rebuild()[&"valid"], "facility definitions load")
|
||||
_test_catalog()
|
||||
_test_batch_and_target()
|
||||
_test_interruptions()
|
||||
_test_expiry_and_retry()
|
||||
_test_capacity()
|
||||
_test_output_failure_rollback()
|
||||
_test_snapshot_round_trip()
|
||||
_test_snapshot_rejections()
|
||||
_test_completed_batch_counter_guard()
|
||||
_test_crosslinks_and_removal()
|
||||
_test_plan_removal()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _fixture(inventory: InventoryService = null) -> Dictionary:
|
||||
if inventory == null:
|
||||
inventory = InventoryService.new()
|
||||
var facilities := FacilityService.new()
|
||||
var result := facilities.create_instance({
|
||||
&"instance_id": &"kitchen.one", &"definition_id": &"kitchen",
|
||||
&"local_inventory_id": &"kitchen.stock", &"staff_min": 1, &"staff_max": 3,
|
||||
&"active_staff_ids": [&"cook"], &"base_quality": 55,
|
||||
})
|
||||
_expect(result.is_success(), "test kitchen registers")
|
||||
var kitchen := KitchenService.new()
|
||||
kitchen.configure(inventory, facilities, _registry, 0)
|
||||
return {&"inventory": inventory, &"facilities": facilities, &"service": kitchen}
|
||||
|
||||
|
||||
func _test_catalog() -> void:
|
||||
var raw := Catalog.get_item(Catalog.RAW_FOOD)
|
||||
_expect(raw[&"unit_price"] == 3 and raw[&"unit_weight"] == 1 and raw[&"shelf_life_minutes"] == 1440, "raw food has explicit P1 price, weight, and expiry")
|
||||
_expect(raw[&"purchasable"], "raw food can be purchased")
|
||||
var meal := Catalog.get_item(Catalog.BASIC_MEAL)
|
||||
_expect(not meal[&"purchasable"] and meal[&"shelf_life_minutes"] == 720, "meals are produced and expire after 720 minutes")
|
||||
raw[&"unit_price"] = 999
|
||||
_expect(Catalog.get_item(Catalog.RAW_FOOD)[&"unit_price"] == 3, "catalogue callers cannot mutate global prices")
|
||||
_expect(Catalog.get_item(&"unknown").is_empty(), "unknown items have no implicit free purchase")
|
||||
_expect(Catalog.get_purchase_items().size() == 1, "only raw food is offered for purchase")
|
||||
_expect(Catalog.storage_capacity(&"warehouse") == 100 and Catalog.storage_capacity(&"kitchen") == 40 and Catalog.storage_capacity(&"cafeteria") == 24, "owner storage capacities are distinct from service capacities")
|
||||
_expect(Catalog.storage_capacity(&"unknown") == 0, "unsupported facilities have no supply storage")
|
||||
var recipe := Catalog.get_recipe()
|
||||
_expect(recipe[&"input_quantity"] == 4 and recipe[&"output_quantity"] == 4 and recipe[&"duration_minutes"] == 15, "recipe preserves existing facility tuning")
|
||||
|
||||
|
||||
func _test_batch_and_target() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"DISABLED", "kitchen waits for a player production plan")
|
||||
_expect(not kitchen.set_plan(&"missing", true, 8)[&"ok"], "unknown facility plan is rejected")
|
||||
for target: int in [0, 3, 5, 17]:
|
||||
_expect(not kitchen.set_plan(&"kitchen.one", true, target)[&"ok"], "non-batch target %d is rejected" % target)
|
||||
_expect(kitchen.set_plan(&"kitchen.one", true, 8)[&"ok"], "ordinary target enables production")
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"NO_INPUT", "missing inputs are visible")
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 12, 90, 2000, 0)
|
||||
_expect(kitchen.advance_one_minute(1)[&"events"][0][&"type"] == &"BATCH_STARTED", "production starts without guest or wallet")
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 12 and inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 8, "start reserves without consuming")
|
||||
_tick(kitchen, 14)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 14, "14 operating minutes do not complete 15-minute recipe")
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 0, "recipe output is not available prematurely")
|
||||
var finished := kitchen.advance_one_minute(16)
|
||||
_expect(finished[&"events"][0][&"type"] == &"BATCH_COMPLETED", "15 operating minutes complete batch")
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 8 and inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "exact input consumption and output quantity")
|
||||
for stack: Dictionary in inventory.get_stacks(&"kitchen.stock"):
|
||||
if stack[&"item_id"] == Catalog.BASIC_MEAL:
|
||||
_expect(stack[&"expires_at"] == 736 and stack[&"created_at"] == 16 and stack[&"quality"] == 55, "output captures completion time, recipe quality, and shelf life")
|
||||
var snapshot := kitchen.create_snapshot()
|
||||
_expect(kitchen.advance_one_minute(16)[&"replay"] and kitchen.create_snapshot() == snapshot, "duplicate minute cannot duplicate output")
|
||||
_expect(not kitchen.advance_one_minute(18)[&"ok"] and kitchen.create_snapshot() == snapshot, "time jumps cannot invent operating time")
|
||||
_tick(kitchen, 16)
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 8, "second batch reaches target")
|
||||
_tick(kitchen, 3)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"TARGET_REACHED" and inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 4, "target prevents speculative overproduction")
|
||||
_expect(kitchen.validate_crosslinks(), "completed batches leave no orphan active holds")
|
||||
_expect(inventory.validate_invariants()[&"ok"], "normal production preserves inventory invariants")
|
||||
|
||||
|
||||
func _test_interruptions() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
var facilities: FacilityService = f[&"facilities"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 6)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 5, "partial progress exists before interruption")
|
||||
var cases := [
|
||||
{&"off": {&"active_staff_ids": []}, &"on": {&"active_staff_ids": [&"cook"]}, &"state": &"NO_STAFF"},
|
||||
{&"off": {&"power_available": false}, &"on": {&"power_available": true}, &"state": &"NO_POWER"},
|
||||
{&"off": {&"access_available": false}, &"on": {&"access_available": true}, &"state": &"NO_ACCESS"},
|
||||
{&"off": {&"operator_open": false}, &"on": {&"operator_open": true}, &"state": &"CLOSED"},
|
||||
{&"off": {&"durability": 0}, &"on": {&"durability": 10000}, &"state": &"BROKEN"},
|
||||
]
|
||||
for item: Dictionary in cases:
|
||||
facilities.set_operational_status(&"kitchen.one", item[&"off"], kitchen.current_minute)
|
||||
_tick(kitchen, 3)
|
||||
var report := kitchen.get_report(&"kitchen.one")
|
||||
_expect(report[&"state"] == item[&"state"] and report[&"progress_minutes"] == 5, "%s pauses without erasing work" % item[&"state"])
|
||||
_expect(inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 0, "interruption retains the batch's materials")
|
||||
facilities.set_operational_status(&"kitchen.one", item[&"on"], kitchen.current_minute)
|
||||
kitchen.set_plan(&"kitchen.one", false, 4)
|
||||
_tick(kitchen, 2)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 5, "player pause also retains progress")
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 10)
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "resuming earns only missing operating minutes")
|
||||
|
||||
|
||||
func _test_expiry_and_retry() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 2, 100, 5, 0)
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 6, 100, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 2)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"active_batch"][&"reservation_ids"].size() == 2, "FEFO can reserve a batch across two stacks")
|
||||
kitchen.set_plan(&"kitchen.one", false, 4)
|
||||
_tick(kitchen, 2)
|
||||
var failed := kitchen.advance_one_minute(5)
|
||||
_expect(failed[&"events"][0][&"type"] == &"BATCH_FAILED", "input expiry fails a batch even while player paused it")
|
||||
_expect(inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD, 5) == 6, "failure releases the still-fresh part of a split reservation")
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 0, "expired food cannot become fresh meals")
|
||||
_expect(kitchen.validate_crosslinks(), "expired batch has no orphan holds")
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_expect(kitchen.advance_one_minute(6)[&"events"][0][&"type"] == &"BATCH_STARTED", "fresh input can retry on the following minute")
|
||||
_tick(kitchen, 15)
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "retry completes using fresh ingredients")
|
||||
|
||||
|
||||
func _test_capacity() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0)
|
||||
inventory.add_stack(&"kitchen.stock", &"other", 40)
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 16)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"OUTPUT_FULL", "overfull storage waits after cooking")
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 15, "output wait retains completed work")
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 4 and inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 0, "full output never consumes input")
|
||||
_tick(kitchen, 2)
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 4, "repeated output waits are non-destructive")
|
||||
var removal := inventory.reserve_item(&"kitchen.stock", &"other", 4, &"test", &"remove")
|
||||
inventory.consume_reservations(removal[&"reservation_ids"])
|
||||
_tick(kitchen, 1)
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4 and Catalog.inventory_weight(inventory, &"kitchen.stock") == 40, "a same-weight recipe can finish at exact storage capacity")
|
||||
|
||||
|
||||
func _test_snapshot_round_trip() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 8, 90, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 8)
|
||||
_tick(kitchen, 8)
|
||||
var copy := _fixture()
|
||||
var restored_inventory: InventoryService = copy[&"inventory"]
|
||||
var restored: KitchenProductionService = copy[&"service"]
|
||||
_expect(restored_inventory.restore_snapshot(inventory.create_snapshot()), "inventory round trip restores reserved input")
|
||||
_expect(restored.restore_snapshot(kitchen.create_snapshot()), "production round trip restores against matching inventory")
|
||||
_expect(restored.create_snapshot() == kitchen.create_snapshot() and restored.validate_crosslinks(), "plan and batch round trip exactly")
|
||||
_tick(kitchen, 24)
|
||||
_tick(restored, 24)
|
||||
_expect(restored.create_snapshot() == kitchen.create_snapshot(), "restored production follows identical completion times")
|
||||
_expect(restored_inventory.create_snapshot() == inventory.create_snapshot(), "restore cannot duplicate or lose ingredients and meals")
|
||||
var public_report := restored.get_report(&"kitchen.one")
|
||||
public_report[&"target_stock"] = 16
|
||||
var public_plans := restored.get_plans()
|
||||
public_plans[0][&"enabled"] = false
|
||||
_expect(restored.get_plan(&"kitchen.one")[&"enabled"] and restored.get_plan(&"kitchen.one")[&"target_stock"] == 8, "reports are defensive copies")
|
||||
|
||||
|
||||
func _test_output_failure_rollback() -> void:
|
||||
var inventory := RejectingOutputInventory.new()
|
||||
var f := _fixture(inventory)
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 1)
|
||||
var reserved_inventory := inventory.create_snapshot()
|
||||
_tick(kitchen, 15)
|
||||
_expect(inventory.create_snapshot() == reserved_inventory, "failed output insert rolls input consumption back atomically")
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 15 and kitchen.validate_crosslinks(), "output failure keeps completed work and a valid input hold")
|
||||
inventory.reject_output = false
|
||||
_tick(kitchen, 1)
|
||||
_expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 0 and inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "recovered output inserts exactly one batch")
|
||||
_expect(kitchen.validate_crosslinks() and inventory.validate_invariants()[&"ok"], "fault recovery preserves both service invariants")
|
||||
|
||||
|
||||
func _test_snapshot_rejections() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 8)
|
||||
_tick(kitchen, 4)
|
||||
var original := kitchen.create_snapshot()
|
||||
var inventory_before := inventory.create_snapshot()
|
||||
var mutations: Array[Dictionary] = []
|
||||
for pair: Array in [[&"version", 2], [&"current_minute", -1], [&"current_minute", 4.0], [&"next_batch_sequence", 1], [&"plans", {}]]:
|
||||
var malformed := original.duplicate(true)
|
||||
malformed[pair[0]] = pair[1]
|
||||
mutations.append(malformed)
|
||||
for pair: Array in [[&"enabled", 1], [&"target_stock", 5], [&"target_stock", "8"], [&"facility_id", "missing"]]:
|
||||
var malformed := original.duplicate(true)
|
||||
malformed[&"plans"][0][pair[0]] = pair[1]
|
||||
mutations.append(malformed)
|
||||
for pair: Array in [[&"progress_minutes", 16], [&"progress_minutes", 4], [&"started_at", -1], [&"quality", 101], [&"batch_id", "kitchen.batch:00000000"], [&"inventory_id", "other"], [&"reservation_ids", []], [&"reservation_ids", ["missing"]]]:
|
||||
var malformed := original.duplicate(true)
|
||||
malformed[&"active_batches"][0][pair[0]] = pair[1]
|
||||
mutations.append(malformed)
|
||||
var duplicate_plan := original.duplicate(true)
|
||||
duplicate_plan[&"plans"].append(duplicate_plan[&"plans"][0].duplicate(true))
|
||||
mutations.append(duplicate_plan)
|
||||
var duplicate_batch := original.duplicate(true)
|
||||
duplicate_batch[&"active_batches"].append(duplicate_batch[&"active_batches"][0].duplicate(true))
|
||||
mutations.append(duplicate_batch)
|
||||
var duplicate_reservation := original.duplicate(true)
|
||||
duplicate_reservation[&"active_batches"][0][&"reservation_ids"].append(duplicate_reservation[&"active_batches"][0][&"reservation_ids"][0])
|
||||
mutations.append(duplicate_reservation)
|
||||
var missing_batch := original.duplicate(true)
|
||||
missing_batch[&"active_batches"] = []
|
||||
mutations.append(missing_batch)
|
||||
var unknown_field := original.duplicate(true)
|
||||
unknown_field[&"arbitrary"] = true
|
||||
mutations.append(unknown_field)
|
||||
for i: int in range(mutations.size()):
|
||||
_expect(not kitchen.restore_snapshot(mutations[i]) and not kitchen.last_restore_error.is_empty(), "malformed snapshot %d is rejected" % i)
|
||||
_expect(kitchen.create_snapshot() == original and inventory.create_snapshot() == inventory_before, "snapshot rejection %d leaves both services intact" % i)
|
||||
|
||||
|
||||
func _test_crosslinks_and_removal() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
var facilities: FacilityService = f[&"facilities"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 8, 100, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 8)
|
||||
_tick(kitchen, 2)
|
||||
var saved := kitchen.create_snapshot()
|
||||
var id := StringName(saved[&"active_batches"][0][&"reservation_ids"][0])
|
||||
var reservation := inventory.get_reservation(id)
|
||||
reservation.requester_id = &"other.batch"
|
||||
_expect(not kitchen.validate_crosslinks() and not kitchen.restore_snapshot(saved), "a different requester cannot steal a production reservation")
|
||||
reservation.requester_id = StringName(saved[&"active_batches"][0][&"batch_id"])
|
||||
reservation.quantity = 3
|
||||
_expect(not kitchen.validate_crosslinks(), "partial ingredient allocation is rejected")
|
||||
reservation.quantity = 4
|
||||
var extra := inventory.reserve_item(&"kitchen.stock", Catalog.RAW_FOOD, 1, reservation.requester_id, Catalog.PRODUCTION_PURPOSE, 2)
|
||||
_expect(not kitchen.validate_crosslinks() and not kitchen.restore_snapshot(saved), "orphan production reservations are rejected")
|
||||
inventory.release_reservation(extra[&"reservation_ids"][0], &"", 2)
|
||||
_expect(kitchen.validate_crosslinks(), "removing orphan restores valid links")
|
||||
facilities.unregister_instance(&"kitchen.one")
|
||||
var removed := kitchen.advance_one_minute(3)
|
||||
_expect(removed[&"events"][0][&"reason"] == &"FACILITY_REMOVED", "removed kitchen fails its batch")
|
||||
_expect(kitchen.get_plans().is_empty() and inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD, 3) == 8, "removal releases input holds and retires the plan")
|
||||
_expect(kitchen.validate_crosslinks(), "facility removal leaves no active production orphan")
|
||||
|
||||
|
||||
func _test_plan_removal() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
var facilities: FacilityService = f[&"facilities"]
|
||||
_expect(kitchen.remove_plan(&"missing")[&"ok"], "removing an absent plan is idempotent")
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"PLAN_ENABLED", "enabled plan cannot be forgotten")
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0)
|
||||
_tick(kitchen, 1)
|
||||
kitchen.set_plan(&"kitchen.one", false, 4)
|
||||
_expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"ACTIVE_BATCH", "paused batch prevents plan removal")
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 15)
|
||||
kitchen.set_plan(&"kitchen.one", false, 4)
|
||||
facilities.unregister_instance(&"kitchen.one")
|
||||
_expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"REMOVED", "disabled idle plan can be removed after facility demolition")
|
||||
_expect(kitchen.get_plans().is_empty(), "removed plan is omitted from saved state")
|
||||
_expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"NO_PLAN", "repeated removal does not recreate a plan")
|
||||
|
||||
|
||||
func _test_completed_batch_counter_guard() -> void:
|
||||
var f := _fixture()
|
||||
var kitchen: KitchenProductionService = f[&"service"]
|
||||
var inventory: InventoryService = f[&"inventory"]
|
||||
inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 8, 100, 1000, 0)
|
||||
kitchen.set_plan(&"kitchen.one", true, 4)
|
||||
_tick(kitchen, 16)
|
||||
var canonical := kitchen.create_snapshot()
|
||||
_expect(canonical[&"active_batches"].is_empty() and canonical[&"next_batch_sequence"] == 2, "completed batch retains its next sequence without an active batch")
|
||||
var inventory_before := inventory.create_snapshot()
|
||||
var reused := canonical.duplicate(true)
|
||||
reused[&"next_batch_sequence"] = 1
|
||||
_expect(not kitchen.restore_snapshot(reused), "closed input reservations forbid rewinding the batch counter")
|
||||
_expect(kitchen.create_snapshot() == canonical and inventory.create_snapshot() == inventory_before, "counter rejection leaves service and inventory unchanged")
|
||||
kitchen.set_plan(&"kitchen.one", false, 4)
|
||||
kitchen.remove_plan(&"kitchen.one")
|
||||
var without_plan := kitchen.create_snapshot()
|
||||
without_plan[&"next_batch_sequence"] = 1
|
||||
_expect(not kitchen.restore_snapshot(without_plan), "removed plans cannot erase durable batch identity evidence")
|
||||
_expect(kitchen.validate_crosslinks(), "terminal reservations remain valid after plan removal")
|
||||
kitchen.set_plan(&"kitchen.one", true, 8)
|
||||
_tick(kitchen, 1)
|
||||
_expect(kitchen.get_report(&"kitchen.one")[&"active_batch"][&"batch_id"] == "kitchen.batch:00000002", "the next actual batch uses a fresh identity")
|
||||
|
||||
|
||||
func _tick(kitchen: KitchenProductionService, count: int) -> void:
|
||||
for ignored: int in range(count):
|
||||
var result := kitchen.advance_one_minute(kitchen.current_minute + 1)
|
||||
if not result[&"ok"]:
|
||||
_expect(false, "minute advance succeeds: %s" % result)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpmobtia1rvj5
|
||||
@@ -1,6 +1,7 @@
|
||||
extends SceneTree
|
||||
|
||||
const POST_V2_RUNTIME_KEYS := [
|
||||
&"kitchen_production",
|
||||
&"movement_credits",
|
||||
&"catalog_manifest",
|
||||
&"progression",
|
||||
@@ -48,7 +49,7 @@ func _run() -> void:
|
||||
_test_occupied_cell_graph_rebuild_is_rejected()
|
||||
_test_stuck_guest_and_staff_recovery()
|
||||
_test_event_recovery_path_transaction()
|
||||
_test_v6_snapshot_crosslinks_and_legacy_v2()
|
||||
_test_current_snapshot_crosslinks_and_legacy_v2()
|
||||
_test_dispatcher_request_ownership_crosslinks()
|
||||
_test_twenty_four_hour_projection_integrity()
|
||||
_cleanup()
|
||||
@@ -292,7 +293,7 @@ func _test_event_recovery_path_transaction() -> void:
|
||||
)
|
||||
|
||||
|
||||
func _test_v6_snapshot_crosslinks_and_legacy_v2() -> void:
|
||||
func _test_current_snapshot_crosslinks_and_legacy_v2() -> void:
|
||||
_reset_runtime(8_106)
|
||||
var origin := Vector3i(-3, 0, 0)
|
||||
_runtime.path_service.configure_cell_traffic(origin, 1, 0, false)
|
||||
@@ -301,11 +302,11 @@ func _test_v6_snapshot_crosslinks_and_legacy_v2() -> void:
|
||||
_clock.advance_one_minute()
|
||||
var snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"]
|
||||
_expect(int(runtime_data[&"runtime_schema_version"]) == 6, "Runtime snapshot advertises schema v6")
|
||||
_expect(int(runtime_data[&"runtime_schema_version"]) == 7, "Runtime snapshot advertises schema v7")
|
||||
_expect(not runtime_data[&"movement_credits"].is_empty(), "mid-move snapshot persists fixed-point movement credits")
|
||||
var digest := SimulationDigest.compute(snapshot)
|
||||
_expect(_runtime.restore_snapshot(snapshot), "valid mid-move v6 snapshot restores")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v6 save/load is exact")
|
||||
_expect(_runtime.restore_snapshot(snapshot), "valid mid-move v7 snapshot restores")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v7 save/load is exact")
|
||||
|
||||
var corruptions: Array[Dictionary] = []
|
||||
var bad_actor := snapshot.duplicate(true)
|
||||
@@ -349,16 +350,16 @@ func _test_v6_snapshot_crosslinks_and_legacy_v2() -> void:
|
||||
traffic[&"temporary_block"] = true
|
||||
_expect(
|
||||
_is_true_runtime_v2_shape(legacy_data),
|
||||
"legacy fixture removes every v3-v6 aggregate and dedicated history instead of relabelling v6"
|
||||
"legacy fixture removes every v3-v7 aggregate and dedicated history instead of relabelling v7"
|
||||
)
|
||||
_expect(_runtime.restore_snapshot(legacy_v2), "legacy Runtime v2 deterministically rebuilds Path actors and credits")
|
||||
var migrated: Dictionary = _runtime.create_snapshot()
|
||||
_expect(
|
||||
int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6
|
||||
int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 7
|
||||
and migrated[&"systems"][&"runtime"][&"path"][&"actor_cells"].size() == 2
|
||||
and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2
|
||||
and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]),
|
||||
"legacy migration emits canonical v6 ownership while preserving blocked traffic state"
|
||||
"legacy migration emits canonical v7 ownership while preserving blocked traffic state"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -793,6 +793,7 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void:
|
||||
_reset_runtime(7_606, 9, 17)
|
||||
var legacy: Dictionary = _runtime.create_snapshot()
|
||||
legacy[&"systems"][&"runtime"].erase(&"runtime_schema_version")
|
||||
legacy[&"systems"][&"runtime"].erase(&"kitchen_production")
|
||||
legacy[&"systems"][&"runtime"].erase(&"operations")
|
||||
legacy[&"systems"][&"runtime"].erase(&"defense")
|
||||
legacy[&"systems"][&"runtime"][&"rng_streams"].erase("defense")
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
extends SceneTree
|
||||
|
||||
## A real inn from the default 5,000 G / 50 materials. No inserted guests,
|
||||
## inventory, employees, completed construction, or free resource commands.
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _ids: Dictionary = {}
|
||||
var _saw_loaded := false
|
||||
var _saw_movement := false
|
||||
var _saw_batch := false
|
||||
var _save_checked := false
|
||||
var _last_porter_cell := Vector3i.ZERO
|
||||
var _save_path := "user://tests/normal_supply_%d/cargo" % OS.get_process_id()
|
||||
var _main: Control
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_runtime.initialize(_clock, _state, 424242)
|
||||
_clock.set_paused(true)
|
||||
root.size = Vector2i(1440, 810)
|
||||
_main = load("res://scenes/main/main.tscn").instantiate() as Control
|
||||
_main.set("manual_save_path", "user://tests/supply_normal_%d/manual" % OS.get_process_id())
|
||||
root.add_child(_main)
|
||||
await process_frame
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
_expect(_state.get_resource(&"gold") == 5000 and _state.get_resource(&"materials") == 50, "ordinary starting resources")
|
||||
for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)], [&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
if not _command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}):
|
||||
_finish()
|
||||
return
|
||||
await _advance(180)
|
||||
_command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)})
|
||||
_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 1, 0)})
|
||||
await _advance(60)
|
||||
_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 2, 0)})
|
||||
await _advance(10)
|
||||
for x: int in [-5, -6]:
|
||||
_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(x, 2, 0)})
|
||||
await _advance(10)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
_ids[facility.definition_id] = facility.instance_id
|
||||
for assignment: Array in [[&"staff_akane", &"reception"], [&"staff_gina", &"warehouse"], [&"staff_chika", &"kitchen"], [&"staff_iris", &"cafeteria"], [&"staff_juno", &"cafeteria"]]:
|
||||
if not _command(&"hire_staff_candidate", {&"candidate_id": assignment[0]}) or not _command(&"set_staff_work_assignment", {&"staff_id": assignment[0], &"facility_id": _ids[assignment[1]], &"template_id": &"DAY"}):
|
||||
_finish()
|
||||
return
|
||||
_expect(_state.get_resource(&"materials") == 4, "all buildings and the staff door fit within the starting material budget")
|
||||
var warehouse: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"warehouse"])
|
||||
var cash_before: int = _state.get_resource(&"gold")
|
||||
_command(&"purchase_supplies", {&"warehouse_id": warehouse.instance_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
|
||||
_expect(_state.get_resource(&"gold") == cash_before - 120 and _runtime.inventory_service.total_quantity(warehouse.local_inventory_id, SupplyCatalog.RAW_FOOD) == 40, "40 raw food costs exactly 120 G and arrives only in the warehouse")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8})
|
||||
await _advance(120)
|
||||
_expect(_saw_movement and _saw_loaded, "an employed porter walks the real route and carries purchased food")
|
||||
_expect(_saw_batch, "the kitchen starts autonomous production without a guest request")
|
||||
_expect(_save_checked, "a mid-delivery disk save restores ownership and replays exactly")
|
||||
_expect(_completed_deliveries(SupplyCatalog.RAW_FOOD) > 0 and _completed_deliveries(SupplyCatalog.BASIC_MEAL) > 0, "both warehouse-to-kitchen and kitchen-to-cafeteria deliveries complete")
|
||||
_expect(_sales(&"cafeteria") > 0, "natural guests pay for meals made from purchased food")
|
||||
await _capture()
|
||||
if OS.get_environment("THE_TREE_SUPPLY_PREVIEW") == "1":
|
||||
_start_preview()
|
||||
return
|
||||
if OS.get_environment("THE_TREE_SUPPLY_CAPTURE_ONLY") == "1":
|
||||
_finish()
|
||||
return
|
||||
for day: int in range(1, 4):
|
||||
await _advance(360 + 1440 * day - _clock.absolute_minute)
|
||||
var summaries: Array = _runtime.operations_coordinator.get_day_summaries()
|
||||
_expect(summaries.size() == day and bool(summaries[-1].get(&"balanced", false)), "business day %d closes with a reconciled ledger" % day)
|
||||
_expect(_state.get_resource(&"gold") >= 0, "day %d retains cash after real payroll and purchases" % day)
|
||||
if day < 3:
|
||||
_command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
|
||||
_expect(_sales(&"standard_room") > 0, "the same inn earns lodging revenue")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "three-day supply history remains restorable")
|
||||
var evidence := {"checks": _checks, "failures": _failures, "meal_sales": _sales(&"cafeteria"), "lodging_sales": _sales(&"standard_room"), "raw_deliveries": _completed_deliveries(SupplyCatalog.RAW_FOOD), "meal_deliveries": _completed_deliveries(SupplyCatalog.BASIC_MEAL), "cash": _state.get_resource(&"gold"), "closes": _runtime.operations_coordinator.get_day_summaries(), "supply": _runtime.get_supply_management_report()}
|
||||
var evidence_path := ProjectSettings.globalize_path("res://artifacts/supply-chain/normal-play-report.json")
|
||||
DirAccess.make_dir_recursive_absolute(evidence_path.get_base_dir())
|
||||
var file := FileAccess.open(evidence_path, FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_string(JSON.stringify(evidence, "\t"))
|
||||
print("NORMAL_SUPPLY_REPORT ", JSON.stringify({"cash": evidence["cash"], "meal_sales": evidence["meal_sales"], "raw_deliveries": evidence["raw_deliveries"], "meal_deliveries": evidence["meal_deliveries"]}))
|
||||
_finish()
|
||||
|
||||
|
||||
func _advance(minutes: int) -> void:
|
||||
for index: int in maxi(minutes, 0):
|
||||
var before: int = _clock.absolute_minute
|
||||
_clock.advance_minutes(1)
|
||||
if _clock.absolute_minute != before + 1:
|
||||
_expect(false, "clock must progress: %s" % _runtime.get_operations_report())
|
||||
return
|
||||
var porter: StaffRuntime = _runtime.staff_service.get_staff(&"staff_gina")
|
||||
if porter != null:
|
||||
_saw_movement = _saw_movement or porter.current_cell != _last_porter_cell
|
||||
_last_porter_cell = porter.current_cell
|
||||
if not porter.carried_inventory_id.is_empty():
|
||||
_saw_loaded = true
|
||||
if not _save_checked:
|
||||
_check_loaded_save()
|
||||
if _ids.has(&"kitchen"):
|
||||
_saw_batch = _saw_batch or not _runtime.kitchen_production.get_report(_ids[&"kitchen"]).get(&"active_batch", {}).is_empty()
|
||||
if index % 120 == 119:
|
||||
print("SUPPLY_PROGRESS ", _clock.formatted_time(), " meals=", _sales(&"cafeteria"))
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_loaded_save() -> void:
|
||||
_save_checked = true
|
||||
var saved: Dictionary = _runtime.save_to_path(_save_path)
|
||||
_expect(bool(saved.get(&"ok", false)), "mid-delivery save writes to an isolated slot")
|
||||
var before: Dictionary = _runtime.create_snapshot()
|
||||
var loaded: Dictionary = _runtime.load_from_path(_save_path)
|
||||
_expect(bool(loaded.get(&"ok", false)), "mid-delivery disk restore: %s" % loaded)
|
||||
var normalized: Dictionary = _runtime.create_snapshot()
|
||||
_clock.advance_minutes(5)
|
||||
var expected: Dictionary = _runtime.create_snapshot()
|
||||
_expect(_runtime.restore_snapshot(normalized), "mid-delivery checkpoint rewinds")
|
||||
_clock.advance_minutes(5)
|
||||
_expect(_runtime.create_snapshot() == expected, "mid-delivery replay preserves exact inventory, tasks, positions, and ledger")
|
||||
_expect(_runtime.restore_snapshot(before), "resume the observed minute after replay verification")
|
||||
|
||||
|
||||
func _completed_deliveries(item: StringName) -> int:
|
||||
var count := 0
|
||||
for request: LogisticsRequest in _runtime.logistics_requests.values():
|
||||
if String(request.request_id).begins_with("supply.") and request.state == LogisticsRequest.State.COMPLETED and request.item_requirements.has(item):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _sales(definition_id: StringName) -> int:
|
||||
var total := 0
|
||||
for entry: LedgerEntry in _runtime.economy_service.get_entries():
|
||||
if entry.category == &"SERVICE_SALE" and entry.source_entity_id == _ids.get(definition_id, &"missing"):
|
||||
total += entry.amount
|
||||
return total
|
||||
|
||||
|
||||
func _command(type: StringName, payload: Dictionary) -> bool:
|
||||
var result: CommandResult = _runtime.submit_command(type, payload)
|
||||
_expect(result.is_success(), "%s %s: %s" % [type, payload, result.message])
|
||||
return result.is_success()
|
||||
|
||||
|
||||
func _capture() -> void:
|
||||
if OS.get_environment("THE_TREE_SUPPLY_CAPTURE") != "1":
|
||||
return
|
||||
await process_frame
|
||||
_main.call("_activate_management_mode", &"product")
|
||||
var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
|
||||
for tab: StringName in [&"purchase", &"kitchen", &"delivery"]:
|
||||
panel.select_tab(tab)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var path := ProjectSettings.globalize_path("res://artifacts/supply-chain/%s.png" % tab)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
root.get_texture().get_image().save_png(path)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error("FAIL: " + message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
SaveService.delete_save(_save_path)
|
||||
print("Normal supply chain: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _start_preview() -> void:
|
||||
if _failures > 0:
|
||||
_finish()
|
||||
return
|
||||
SaveService.delete_save(_save_path)
|
||||
root.title = "The Tree · 仕入れと厨房の試遊(保存なし)"
|
||||
for node: Node in _main.find_children("*", "Button", true, false):
|
||||
var button := node as Button
|
||||
for connection: Dictionary in button.pressed.get_connections():
|
||||
var callback: Callable = connection[&"callable"]
|
||||
if callback.get_method() in [&"_save_manual", &"_load_manual"]:
|
||||
button.disabled = true
|
||||
button.tooltip_text = "この試遊は保存しません。"
|
||||
_main.call("_activate_management_mode", &"product")
|
||||
var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
|
||||
panel.select_tab(&"kitchen")
|
||||
_main.call("_set_status", "通常予算から育てた宿の試遊です。Spaceで再開し、商品タブで仕入れ・調理・配送を確認できます(保存なし)。", true, true)
|
||||
_clock.set_paused(true)
|
||||
print("SUPPLY_PREVIEW_READY checks=%d failures=0" % _checks)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb4vjqk7okwwa
|
||||
@@ -0,0 +1,315 @@
|
||||
extends SceneTree
|
||||
|
||||
## Real construction, hired porter and purchased stock; failure injection is
|
||||
## limited to route closures, capacity pressure and expired cargo.
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _source: StringName
|
||||
var _destination: StringName
|
||||
var _loaded: Dictionary
|
||||
var _reserved: Dictionary
|
||||
const REQUEST := &"supply.bridge_test"
|
||||
const PORTER := &"staff_gina"
|
||||
|
||||
|
||||
class LateFailInventory extends InventoryService:
|
||||
var fail_delivery := true
|
||||
|
||||
func transfer_available(source_inventory_id: StringName, destination_inventory_id: StringName,
|
||||
item_id: StringName, quantity: int, operation_id: StringName = &"", absolute_minute: int = -1) -> Dictionary:
|
||||
var result := super.transfer_available(source_inventory_id, destination_inventory_id, item_id, quantity, operation_id, absolute_minute)
|
||||
if fail_delivery and String(source_inventory_id).begins_with("cargo:") and result[&"ok"]:
|
||||
return {&"ok": false, &"code": &"INJECTED_LATE_FAILURE"}
|
||||
return result
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("Supply bridge: real movement, cargo conservation and recovery")
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_expect(_runtime.initialize(_clock, _state, 653322)[&"valid"], "Runtime initializes")
|
||||
_clock.set_paused(true)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
_expect(_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}), "build %s" % row[0])
|
||||
_clock.advance_minutes(180)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse": _source = facility.instance_id
|
||||
if facility.definition_id == &"kitchen": _destination = facility.instance_id
|
||||
if _source.is_empty() or _destination.is_empty():
|
||||
_expect(false, "normal construction completes")
|
||||
_finish()
|
||||
return
|
||||
_expect(_command(&"hire_staff_candidate", {&"candidate_id": PORTER}), "hire the catalogue porter")
|
||||
_expect(_command(&"set_staff_work_assignment", {&"staff_id": PORTER, &"facility_id": _source, &"template_id": &"DAY"}), "assign porter to a repeating real shift")
|
||||
_expect(_command(&"purchase_supplies", {&"warehouse_id": _source, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}), "purchase the source stock through the ledger")
|
||||
_expect(_command(&"create_logistics_request", {&"request_id": REQUEST, &"source_inventory_id": _inventory(_source), &"destination_inventory_id": _inventory(_destination), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}), "create the reserved supply request")
|
||||
_expect(_quantity(_inventory(_destination)) == 0, "request creation does not deliver anything")
|
||||
_test_manual_dispatch_boundary()
|
||||
_reserved = _runtime.create_snapshot()
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().assigned_carrier_id == PORTER and _worker().current_cell.distance_to(Vector3i.ZERO) <= 1.0, "dispatcher selects the actual employee without teleporting")
|
||||
var start := _worker().current_cell
|
||||
_wait_for(LogisticsRequest.State.DELIVERING, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_cell != start, "porter walks to the source before pickup")
|
||||
if _request().state != LogisticsRequest.State.DELIVERING:
|
||||
_finish()
|
||||
return
|
||||
_expect(_quantity(_inventory(_source)) == 0 and _quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0, "pickup moves all eight units into owned cargo only")
|
||||
_expect(_crosslinks(), "loaded Request, Task, reservations and cargo agree")
|
||||
_loaded = _runtime.create_snapshot()
|
||||
_expect(_runtime.restore_snapshot(_loaded), "mid-delivery Runtime snapshot restores")
|
||||
_test_ownership_and_replay()
|
||||
_test_capacity_wait()
|
||||
_test_route_recovery()
|
||||
_test_unavailable_and_expiry()
|
||||
_test_restore_guards()
|
||||
_test_pickup_retry()
|
||||
_test_unavailable_before_pickup()
|
||||
_test_partial_expiry_return()
|
||||
_test_shift_boundary()
|
||||
_test_late_delivery_failure()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_ownership_and_replay() -> void:
|
||||
var owned := _owned_snapshot()
|
||||
var manual: CommandResult = _runtime.submit_command(&"advance_logistics_request", {&"request_id": REQUEST})
|
||||
_expect(not manual.is_success() and manual.details.get(&"reason") == &"SUPPLY_DRIVER_REQUIRED", "external advance cannot bypass real arrival")
|
||||
_expect(_owned_snapshot() == owned, "rejected manual advance leaves inventory, Task and carrier ownership unchanged")
|
||||
_expect(_runtime.restore_snapshot(_loaded), "rewind the rejected command audit before deterministic replay")
|
||||
var task_id := _worker().current_task_id
|
||||
_expect(not _runtime.staff_service.cancel_task(task_id).is_success(), "ordinary cancellation cannot orphan loaded cargo")
|
||||
_expect(not _runtime.staff_service.remove_staff(PORTER).is_success(), "loaded porter cannot be removed")
|
||||
_expect(not _runtime.staff_service.retire_staff(PORTER).is_success() and not _worker().is_retiring, "retirement rejects atomically while hauling")
|
||||
_expect(not _runtime.staff_service.progress_task(task_id, 1, _clock.absolute_minute + 2).is_success(), "generic Task progress cannot bypass delivery")
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8, "arrival transfers the purchased stock into kitchen storage")
|
||||
_expect(_quantity(_cargo()) == 0 and _worker().current_task_id.is_empty() and _worker().carried_inventory_id.is_empty(), "completion clears cargo and employee ownership together")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "completion releases exact dispatcher weight")
|
||||
var completed: Dictionary = _runtime.create_snapshot()
|
||||
var end_minute: int = _clock.absolute_minute
|
||||
_expect(_runtime.restore_snapshot(_loaded), "rewind to a loaded employee")
|
||||
_clock.advance_minutes(end_minute - _clock.absolute_minute)
|
||||
_expect(_runtime.create_snapshot() == completed, "loaded save replay produces identical inventory, Tasks, positions and accounts")
|
||||
|
||||
|
||||
func _test_manual_dispatch_boundary() -> void:
|
||||
var owned := _owned_snapshot()
|
||||
var manual: CommandResult = _runtime.submit_command(&"dispatch_logistics", {&"carriers": {&"synthetic_porter": {&"capacity": 100, &"current_load": 0, &"path_minutes": 0, &"role": &"logistics"}}})
|
||||
_expect(manual.is_success() and manual.details[&"assignments"].is_empty(), "external synthetic carrier cannot claim a managed supply request")
|
||||
_expect(_owned_snapshot() == owned, "manual dispatch does not change reserved stock, Staff Task or request ownership")
|
||||
|
||||
|
||||
func _owned_snapshot() -> Dictionary:
|
||||
return {&"inventory": _runtime.inventory_service.create_snapshot(), &"staff": _runtime.staff_service.create_snapshot(), &"dispatcher": _runtime.logistics_dispatcher.create_snapshot(), &"request": _request().create_snapshot()}
|
||||
|
||||
|
||||
func _test_capacity_wait() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore capacity scenario")
|
||||
_runtime.inventory_service.add_stack(_inventory(_destination), SupplyCatalog.BASIC_MEAL, 40, 100, -1, _clock.absolute_minute)
|
||||
_clock.advance_minutes(30)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _request().state_reason == "DESTINATION_FULL", "capacity becoming full after pickup pauses delivery")
|
||||
_expect(_quantity(_cargo()) == 8 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 8, "full destination retains all cargo and weight ownership")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "waiting loaded cargo is restorable")
|
||||
_runtime.inventory_service.transfer_available(_inventory(_destination), &"test.capacity_sink", SupplyCatalog.BASIC_MEAL, 40, &"", _clock.absolute_minute)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 10)
|
||||
_expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "freeing storage resumes once without losing or duplicating stock")
|
||||
|
||||
|
||||
func _test_route_recovery() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore route scenario")
|
||||
var target := _task().target_cell
|
||||
_runtime.path_service.set_cell_temporary_block(target, true)
|
||||
_clock.advance_minutes(40)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _quantity(_cargo()) == 8, "blocked delivery route never releases carried stock")
|
||||
_expect(_worker().current_task_id == _task().task_id and _crosslinks(), "stuck recovery keeps employee and Task linked")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "route-blocked cargo saves and restores")
|
||||
_runtime.path_service.set_cell_temporary_block(target, false)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8, "restoring the real route resumes delivery")
|
||||
|
||||
|
||||
func _test_unavailable_and_expiry() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore unavailable scenario")
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
var cell := _worker().current_cell
|
||||
_clock.advance_minutes(3)
|
||||
_expect(_worker().current_cell == cell and _quantity(_cargo()) == 8, "incapacitated porter does not move or lose cargo")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "incapacitated cargo owner is restorable")
|
||||
_worker().health = 100
|
||||
_worker().transition_to(StaffRuntime.State.MOVING_TO_TASK, &"TEST_RECOVERED", _clock.absolute_minute)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED, "employee recovery resumes the retained delivery")
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore spoilage scenario")
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
var inventory_snapshot: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
for owner: Dictionary in inventory_snapshot[&"inventories"]:
|
||||
if owner[&"owner_inventory_id"] == String(_cargo()):
|
||||
for stack: Dictionary in owner[&"stacks"]:
|
||||
stack[&"expires_at"] = _clock.absolute_minute + 1
|
||||
_expect(_runtime.inventory_service.restore_snapshot(inventory_snapshot), "inject a cargo expiry boundary")
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().state == LogisticsRequest.State.CANCELLED and _quantity(_cargo()) == 0, "expired cargo enters inventory waste and closes the impossible delivery")
|
||||
_expect(_worker().state == StaffRuntime.State.INCAPACITATED and _worker().current_task_id.is_empty(), "spoilage cleanup preserves incapacitation while releasing the Task")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "spoilage releases weight without a ghost assignment")
|
||||
|
||||
|
||||
func _test_restore_guards() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore crosslink scenario")
|
||||
var stable: Dictionary = _runtime.create_snapshot()
|
||||
var bad := stable.duplicate(true)
|
||||
for task: Dictionary in bad[&"systems"][&"runtime"][&"staff"][&"tasks"]:
|
||||
if task[&"task_type"] == String(SupplyLogisticsBridge.DELIVERY_TYPE):
|
||||
task[&"target_cell"] = [99, 99, 1]
|
||||
_expect(not _runtime.restore_snapshot(bad) and _runtime.create_snapshot() == stable, "restore rejects a remote delivery target atomically")
|
||||
var worker := _worker()
|
||||
worker.carried_inventory_id = &"cargo:somebody_else"
|
||||
_expect(not _crosslinks(), "crosslink validator rejects stolen cargo ownership")
|
||||
worker.carried_inventory_id = _cargo()
|
||||
var task := _task()
|
||||
var saved_type := task.task_type
|
||||
task.task_type = SupplyLogisticsBridge.PICKUP_TYPE
|
||||
_expect(not _crosslinks(), "crosslink validator rejects pickup Task paired with loaded request")
|
||||
task.task_type = saved_type
|
||||
_expect(_crosslinks(), "valid ownership survives rejected candidates")
|
||||
|
||||
|
||||
func _test_pickup_retry() -> void:
|
||||
_expect(_runtime.restore_snapshot(_reserved), "restore before pickup dispatch")
|
||||
_clock.advance_minutes(1)
|
||||
var old_task := _worker().current_task_id
|
||||
var stopped: Dictionary = _runtime.supply_logistics.handle_path_stuck(old_task, _clock.absolute_minute)
|
||||
_expect(stopped[&"ok"] and _request().state == LogisticsRequest.State.BLOCKED, "unloaded route failure blocks the request and cancels the approach")
|
||||
_expect(_runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8 and _worker().current_task_id.is_empty(), "failed approach releases source reservation and employee together")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "failed approach retains no dispatcher load")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "blocked unloaded request restores")
|
||||
_wait_for(LogisticsRequest.State.DELIVERING, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_task_id != old_task, "retries reserve stock again and create a fresh attempt Task")
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "retry delivers the same stock exactly once")
|
||||
|
||||
|
||||
func _test_unavailable_before_pickup() -> void:
|
||||
_expect(_runtime.restore_snapshot(_reserved), "restore unloaded health failure")
|
||||
_clock.advance_minutes(1)
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().state == LogisticsRequest.State.BLOCKED and _worker().current_task_id.is_empty(), "incapacitation before pickup releases approach ownership")
|
||||
_expect(_worker().state == StaffRuntime.State.INCAPACITATED and _runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8, "unloaded health failure preserves health state and releases reserved food")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()) and _crosslinks(), "unloaded health failure saves without expired reservation links")
|
||||
|
||||
|
||||
func _test_partial_expiry_return() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore partial-spoilage scenario")
|
||||
var inventory: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
for owner: Dictionary in inventory[&"inventories"]:
|
||||
if owner[&"owner_inventory_id"] == String(_cargo()):
|
||||
owner[&"stacks"][0][&"quantity"] = 4
|
||||
_expect(_runtime.inventory_service.restore_snapshot(inventory), "split loaded cargo into two expiry lots")
|
||||
_runtime.inventory_service.add_stack(_cargo(), SupplyCatalog.RAW_FOOD, 4, 100, _clock.absolute_minute + 1, _clock.absolute_minute)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _task().task_type == SupplyLogisticsBridge.RETURN_TYPE and _quantity(_cargo()) == 4, "partial expiry retargets the surviving cargo to its source")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "returning cargo saves its destination in the existing Task")
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_crosslinks(), "unavailability reason cannot erase return destination")
|
||||
_worker().health = 100
|
||||
_worker().transition_to(StaffRuntime.State.MOVING_TO_TASK, &"RECOVERED", _clock.absolute_minute)
|
||||
_wait_for(LogisticsRequest.State.CANCELLED, 40)
|
||||
_expect(_request().state_reason == "CARGO_EXPIRED_RETURNED" and _quantity(_inventory(_source)) == 4 and _quantity(_inventory(_destination)) == 0, "return unloads only unspoiled stock at the original warehouse")
|
||||
_expect(_quantity(_cargo()) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "partial-expiry return releases all cargo ownership")
|
||||
|
||||
|
||||
func _test_shift_boundary() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore end-of-shift scenario")
|
||||
var target := _task().target_cell
|
||||
_runtime.path_service.set_cell_temporary_block(target, true)
|
||||
_clock.advance_minutes(1081 - _clock.absolute_minute)
|
||||
_expect(_worker().is_overtime and not _worker().current_task_id.is_empty() and _quantity(_cargo()) == 8, "18:00 shift end retains loaded non-interruptible work")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "overtime delivery is restorable")
|
||||
_runtime.path_service.set_cell_temporary_block(target, false)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_worker().state == StaffRuntime.State.OFF_DUTY and _worker().current_task_id.is_empty() and _quantity(_inventory(_destination)) == 8, "employee ends the shift after safe unloading")
|
||||
|
||||
|
||||
func _test_late_delivery_failure() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore delivery transaction failure")
|
||||
var fault := LateFailInventory.new()
|
||||
_expect(fault.restore_snapshot(_runtime.inventory_service.create_snapshot()), "install failure after inventory transfer has already mutated")
|
||||
_runtime.inventory_service = fault
|
||||
_clock.advance_minutes(30)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _request().state_reason == "DELIVERY_RETRY", "late inventory failure rolls the request back to owned delivery")
|
||||
_expect(_quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 8, "failed delivery reverses all physical transfer and dispatcher changes")
|
||||
_expect(_crosslinks() and not _worker().current_task_id.is_empty(), "failed delivery preserves the employee and Task pair")
|
||||
fault.fail_delivery = false
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 10)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "clearing transient failure retries the same operation exactly once")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "transaction retry leaves a restorable Runtime")
|
||||
|
||||
|
||||
func _wait_for(state: int, minutes: int) -> void:
|
||||
for index: int in minutes:
|
||||
if _request().state == state: return
|
||||
_clock.advance_minutes(1)
|
||||
|
||||
|
||||
func _command(type: StringName, payload: Dictionary) -> bool:
|
||||
var result: CommandResult = _runtime.submit_command(type, payload)
|
||||
if not result.is_success(): print(result.message, result.details)
|
||||
return result.is_success()
|
||||
|
||||
|
||||
func _inventory(id: StringName) -> StringName:
|
||||
return _runtime.facility_service.get_instance(id).local_inventory_id
|
||||
|
||||
|
||||
func _request() -> LogisticsRequest:
|
||||
return _runtime.logistics_requests[REQUEST]
|
||||
|
||||
|
||||
func _worker() -> StaffRuntime:
|
||||
return _runtime.staff_service.get_staff(PORTER)
|
||||
|
||||
|
||||
func _task() -> StaffTask:
|
||||
return _runtime.staff_service.get_task(_worker().current_task_id)
|
||||
|
||||
|
||||
func _cargo() -> StringName:
|
||||
return StringName("cargo:%s" % PORTER)
|
||||
|
||||
|
||||
func _quantity(inventory_id: StringName) -> int:
|
||||
return _runtime.inventory_service.total_quantity(inventory_id, SupplyCatalog.RAW_FOOD)
|
||||
|
||||
|
||||
func _crosslinks() -> bool:
|
||||
return SupplyLogisticsBridge.validate_crosslinks(_runtime.logistics_requests, _runtime.staff_service, _runtime.inventory_service, _runtime.facility_service)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("%s: %d checks / %d failures" % ["PASS" if _failures == 0 else "FAIL", _checks, _failures])
|
||||
quit(_failures)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbj5dk0uqj0ay
|
||||
@@ -0,0 +1,337 @@
|
||||
extends SceneTree
|
||||
|
||||
## Runtime-level purchasing and replenishment. All physical stock is purchased
|
||||
## and cooked normally; failure fixtures cover payment, closed routes and expiry.
|
||||
class RejectingPaymentEconomy:
|
||||
extends EconomyService
|
||||
|
||||
func transfer(_from: StringName, _to: StringName, _amount: int,
|
||||
_minute: int = -1, _category: StringName = DEFAULT_CATEGORY,
|
||||
_source: StringName = &"", _key: StringName = &"", _memo: StringName = &"",
|
||||
_related: StringName = &"", _notify := true) -> CommandResult:
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Injected payment refusal")
|
||||
|
||||
|
||||
const PORTER := &"staff_gina"
|
||||
const COOK := &"staff_chika"
|
||||
const RETURN_REQUEST := &"supply.management_return"
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _completed_groups := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _ids: Dictionary = {}
|
||||
var _built: Dictionary = {}
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_expect(_runtime.initialize(_clock, _state, 553221)[&"valid"], "Runtime initializes")
|
||||
_clock.set_paused(true)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
for row: Array in [
|
||||
[&"warehouse", Vector3i(-8, 0, 1)], [ &"kitchen", Vector3i(2, 0, 1)],
|
||||
[&"cafeteria", Vector3i(2, 0, 0)], [&"staff_door", Vector3i(0, 0, 1)],
|
||||
]:
|
||||
_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]})
|
||||
_clock.advance_minutes(180)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
_ids[facility.definition_id] = facility.instance_id
|
||||
var completed := 0
|
||||
for job: ConstructionJob in _runtime.construction_service.get_jobs():
|
||||
if job.state == ConstructionJob.State.COMPLETED:
|
||||
completed += 1
|
||||
_expect(_ids.has(&"warehouse") and _ids.has(&"kitchen") and _ids.has(&"cafeteria") and completed == 4,
|
||||
"normal starting budget completes three supply facilities and the staff door")
|
||||
if not _ids.has(&"warehouse") or not _ids.has(&"kitchen") or not _ids.has(&"cafeteria"):
|
||||
_finish()
|
||||
return
|
||||
_built = _runtime.create_snapshot()
|
||||
_test_paid_purchase_and_rejection()
|
||||
_test_disabled_kitchen_still_delivers()
|
||||
_test_multiple_sources_choose_connected_nearest()
|
||||
_test_returning_cargo_reserves_actual_space()
|
||||
_expect(_completed_groups == 4, "all four regression scenarios reach their final assertions")
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_paid_purchase_and_rejection() -> void:
|
||||
var cash: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var quote: Dictionary = _runtime.preview_supply_purchase(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, 8)
|
||||
_expect(bool(quote[&"ok"]), "unassigned completed warehouse accepts a paid purchase")
|
||||
var result := _purchase(8)
|
||||
_expect(result.is_success() and _quantity(&"warehouse", SupplyCatalog.RAW_FOOD) == 8,
|
||||
"successful purchase creates exactly eight physical units")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash - int(quote[&"total_cost"])
|
||||
and _state.get_resource(&"gold") == cash - int(quote[&"total_cost"]),
|
||||
"ledger and player cash both reflect the exact purchase price")
|
||||
var entries: Array = _runtime.economy_service.get_transaction_entries(StringName(result.details.get(&"transaction_id", &"")))
|
||||
_expect(entries.size() == 1 and entries[0].amount == int(quote[&"total_cost"])
|
||||
and entries[0].source_entity_id == _ids[&"warehouse"], "purchase records one expense attributed to its warehouse")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 0}, &"INVALID_QUANTITY")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 100}, &"STORAGE_FULL")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1000}, &"INSUFFICIENT_FUNDS")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 1}, &"ITEM_NOT_PURCHASABLE")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"kitchen"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"NOT_WAREHOUSE")
|
||||
_runtime.facility_service.set_operational_status(_ids[&"warehouse"], {&"operator_open": false}, _clock.absolute_minute)
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"FACILITY_CLOSED")
|
||||
_runtime.facility_service.set_operational_status(_ids[&"warehouse"], {&"operator_open": true}, _clock.absolute_minute)
|
||||
# Exercise the rollback after stock has been inserted, rather than only
|
||||
# testing quote failures that reject before the first inventory write.
|
||||
var original_economy: EconomyService = _runtime.economy_service
|
||||
var rejecting := RejectingPaymentEconomy.new()
|
||||
_expect(rejecting.restore_snapshot(original_economy.create_snapshot()), "payment-failure fixture retains all real balances")
|
||||
_runtime.economy_service = rejecting
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"PAYMENT_FAILED")
|
||||
_runtime.economy_service = original_economy
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _test_disabled_kitchen_still_delivers() -> void:
|
||||
_expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for production")
|
||||
_hire_and_assign(PORTER, &"warehouse")
|
||||
_hire_and_assign(COOK, &"kitchen")
|
||||
_expect(_purchase(8).is_success(), "purchase ingredients for real production")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 4})
|
||||
for minute: int in 100:
|
||||
if _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) >= 4:
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) == 4, "paid food is transported and cooked into one real batch")
|
||||
if _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) != 4:
|
||||
return
|
||||
_expect(_meal_requests().is_empty(), "production finishes before the following logistics tick creates a meal request")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": false, &"target_stock": 4})
|
||||
# Preserve the legacy compatibility boundary: a kitchen with no explicit
|
||||
# production plan must not suddenly become an automatic source.
|
||||
_expect(_runtime.kitchen_production.remove_plan(_ids[&"kitchen"])[&"ok"], "completed disabled kitchen can model a legacy unconfigured source")
|
||||
_runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute)
|
||||
_expect(_meal_requests().is_empty(), "unconfigured kitchen stock does not trigger unsolicited automatic delivery")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": false, &"target_stock": 4})
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_meal_requests().size() == 1 and not _runtime.kitchen_production.get_plan(_ids[&"kitchen"])[&"enabled"],
|
||||
"an explicitly disabled production plan still dispatches its completed meals")
|
||||
_expect(_quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 0
|
||||
and _runtime.inventory_service.available_quantity(_inventory(&"kitchen"), SupplyCatalog.BASIC_MEAL) == 0,
|
||||
"meal request reserves stock without instantly moving it to the dining room")
|
||||
_runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute)
|
||||
_runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute)
|
||||
_expect(_meal_requests().size() == 1, "repeated replenishment cannot duplicate the already reserved meal shipment")
|
||||
for minute: int in 100:
|
||||
if _quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 4:
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 4
|
||||
and _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) == 0,
|
||||
"real porter completes the outgoing shipment while kitchen production remains off")
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _test_multiple_sources_choose_connected_nearest() -> void:
|
||||
_expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for competing suppliers")
|
||||
_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(6, 0, 1)})
|
||||
_clock.advance_minutes(180)
|
||||
var near_id: StringName = &""
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse" and facility.instance_id != _ids[&"warehouse"]:
|
||||
near_id = facility.instance_id
|
||||
_expect(not near_id.is_empty(), "starting budget also completes a second, closer warehouse")
|
||||
if near_id.is_empty():
|
||||
return
|
||||
_expect(_purchase(8).is_success(), "older distant supplier holds eight paid units")
|
||||
_command(&"purchase_supplies", {&"warehouse_id": near_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8})
|
||||
var near: FacilityInstance = _runtime.facility_service.get_instance(near_id)
|
||||
var kitchen: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"kitchen"])
|
||||
var near_route: Dictionary = _runtime.supply_logistics.find_facility_access_path(Vector3i(6, 0, 1), kitchen)
|
||||
var far_route: Dictionary = _runtime.supply_logistics.find_facility_access_path(Vector3i(-5, 0, 1), kitchen)
|
||||
_expect(bool(near_route[&"reachable"]) and bool(far_route[&"reachable"])
|
||||
and int(near_route[&"steps"]) < int(far_route[&"steps"])
|
||||
and String(near_id) > String(_ids[&"warehouse"]),
|
||||
"both actual routes connect, and the shorter route belongs to the later warehouse ID")
|
||||
var stocked: Dictionary = _runtime.create_snapshot()
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8})
|
||||
_clock.advance_minutes(1)
|
||||
var requests := _raw_requests_for_kitchen()
|
||||
_expect(requests.size() == 1 and int(requests[0].item_requirements[SupplyCatalog.RAW_FOOD]) == 4,
|
||||
"two stocked suppliers create only one four-unit shipment in the first logistics tick")
|
||||
_expect(not requests.is_empty() and requests[0].source_inventory_id == near.local_inventory_id,
|
||||
"connected nearby supplier wins over the older, farther warehouse")
|
||||
_expect(_runtime.inventory_service.available_quantity(near.local_inventory_id, SupplyCatalog.RAW_FOOD) == 4
|
||||
and _runtime.inventory_service.available_quantity(_inventory(&"warehouse"), SupplyCatalog.RAW_FOOD) == 8
|
||||
and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 4,
|
||||
"exactly four units are reserved once and counted once as incoming")
|
||||
_clock.advance_minutes(1)
|
||||
requests = _raw_requests_for_kitchen()
|
||||
_expect(requests.size() == 2 and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 8,
|
||||
"next tick adds at most one batch to cover the remaining four-unit deficit")
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_raw_requests_for_kitchen().size() == 2
|
||||
and _runtime.inventory_service.available_quantity(_inventory(&"warehouse"), SupplyCatalog.RAW_FOOD) == 8,
|
||||
"incoming stock at target stops extra requests and leaves other supplier stock untouched")
|
||||
# Geometric proximity must not beat an actual connection. Closing every
|
||||
# access cell of the near warehouse leaves the far route into the kitchen.
|
||||
_expect(_runtime.restore_snapshot(stocked), "rewind to the two unreserved suppliers")
|
||||
near = _runtime.facility_service.get_instance(near_id)
|
||||
kitchen = _runtime.facility_service.get_instance(_ids[&"kitchen"])
|
||||
for cell: Vector3i in near.occupied_cells:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
||||
if _runtime.path_service.has_cell(cell + delta):
|
||||
_runtime.path_service.set_cell_temporary_block(cell + delta, true)
|
||||
far_route = _runtime.supply_logistics.find_facility_access_path(Vector3i(-5, 0, 1), kitchen)
|
||||
near_route = _runtime.supply_logistics.find_facility_access_path(Vector3i(6, 0, 1), kitchen)
|
||||
_expect(bool(far_route[&"reachable"]) and not bool(near_route[&"reachable"]),
|
||||
"route closure really disconnects the nearby supplier while preserving the far route")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8})
|
||||
_clock.advance_minutes(1)
|
||||
requests = _raw_requests_for_kitchen()
|
||||
_expect(requests.size() == 1 and requests[0].source_inventory_id == _inventory(&"warehouse")
|
||||
and int(requests[0].item_requirements[SupplyCatalog.RAW_FOOD]) == 4,
|
||||
"unreachable near stock cannot steal the single shipment from a connected supplier")
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _test_returning_cargo_reserves_actual_space() -> void:
|
||||
_expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for returning cargo")
|
||||
_hire_and_assign(PORTER, &"warehouse")
|
||||
_expect(_purchase(2).is_success() and _purchase(6).is_success(), "two paid stacks provide an eight-unit shipment")
|
||||
_command(&"create_logistics_request", {
|
||||
&"request_id": RETURN_REQUEST, &"source_inventory_id": _inventory(&"warehouse"),
|
||||
&"destination_inventory_id": _inventory(&"kitchen"), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8},
|
||||
})
|
||||
var request: LogisticsRequest = _runtime.logistics_requests[RETURN_REQUEST]
|
||||
for minute: int in 60:
|
||||
if request.state == LogisticsRequest.State.DELIVERING:
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
var cargo_id := StringName("cargo:%s" % PORTER)
|
||||
_expect(request.state == LogisticsRequest.State.DELIVERING
|
||||
and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 8,
|
||||
"actual pickup puts purchased goods in the employee's cargo")
|
||||
if request.state != LogisticsRequest.State.DELIVERING:
|
||||
return
|
||||
var inventory_snapshot: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
var injected := false
|
||||
for inventory: Dictionary in inventory_snapshot[&"inventories"]:
|
||||
if inventory[&"owner_inventory_id"] != String(cargo_id):
|
||||
continue
|
||||
for stack: Dictionary in inventory[&"stacks"]:
|
||||
if int(stack[&"quantity"]) == 2:
|
||||
stack[&"expires_at"] = _clock.absolute_minute + 1
|
||||
injected = true
|
||||
_expect(injected, "the original two-unit paid stack remains identifiable inside real cargo")
|
||||
_expect(_runtime.inventory_service.restore_snapshot(inventory_snapshot), "inject expiry of only the two-unit paid stack")
|
||||
_clock.advance_minutes(1)
|
||||
_expect(request.state == LogisticsRequest.State.DELIVERING and request.state_reason == "RETURNING_EXPIRED_CARGO"
|
||||
and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 6,
|
||||
"partial expiry starts a real return of the six surviving units")
|
||||
if request.state != LogisticsRequest.State.DELIVERING or request.state_reason != "RETURNING_EXPIRED_CARGO":
|
||||
return
|
||||
for reason: String in ["RETURNING_EXPIRED_CARGO", "EXPIRED_CARGO_RETURN_BLOCKED", "CARRIER_UNAVAILABLE"]:
|
||||
# Both bridge states retain the same real cargo. Capacity accounting
|
||||
# must follow that cargo even if the return route becomes blocked.
|
||||
request.state_reason = reason
|
||||
var quote: Dictionary = _runtime.preview_supply_purchase(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, 94)
|
||||
_expect(bool(quote[&"ok"]) and int(quote[&"max_quantity"]) == 94,
|
||||
"%s reserves six actual returning units, not the original eight" % reason)
|
||||
_expect(_incoming(&"warehouse", SupplyCatalog.RAW_FOOD) == 6 and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 0,
|
||||
"%s counts returning goods only at the true receiving warehouse" % reason)
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 95}, &"STORAGE_FULL")
|
||||
request.state_reason = "RETURNING_EXPIRED_CARGO"
|
||||
_expect(_purchase(94).is_success(), "buyer may use the exact free space left after reserving the return")
|
||||
for minute: int in 30:
|
||||
if request.is_terminal():
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
_expect(request.state == LogisticsRequest.State.CANCELLED and request.state_reason == "CARGO_EXPIRED_RETURNED",
|
||||
"capacity-aware purchase leaves enough room for the real return to complete")
|
||||
_expect(_quantity(&"warehouse", SupplyCatalog.RAW_FOOD) == 100
|
||||
and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 0
|
||||
and _incoming(&"warehouse", SupplyCatalog.RAW_FOOD) == 0,
|
||||
"return fills warehouse exactly once and clears incoming cargo")
|
||||
_expect(_runtime.inventory_service.validate_invariants()[&"ok"]
|
||||
and SupplyLogisticsBridge.validate_crosslinks(_runtime.logistics_requests, _runtime.staff_service, _runtime.inventory_service, _runtime.facility_service),
|
||||
"completed return preserves inventory and carrier ownership invariants")
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _assert_purchase_rejected(payload: Dictionary, reason: StringName) -> void:
|
||||
var inventory_before: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
var economy_before: Dictionary = _runtime.economy_service.create_snapshot()
|
||||
var gold_before: int = _state.get_resource(&"gold")
|
||||
var result: CommandResult = _runtime.submit_command(&"purchase_supplies", payload)
|
||||
_expect(not result.is_success() and result.details.get(&"reason") == reason, "purchase rejects %s" % reason)
|
||||
_expect(_runtime.inventory_service.create_snapshot() == inventory_before
|
||||
and _runtime.economy_service.create_snapshot() == economy_before and _state.get_resource(&"gold") == gold_before,
|
||||
"%s leaves full inventory, ledger and player cash unchanged" % reason)
|
||||
|
||||
|
||||
func _command(type: StringName, payload: Dictionary) -> CommandResult:
|
||||
var result: CommandResult = _runtime.submit_command(type, payload)
|
||||
_expect(result.is_success(), "%s succeeds: %s" % [type, result.details])
|
||||
return result
|
||||
|
||||
|
||||
func _purchase(quantity: int) -> CommandResult:
|
||||
return _runtime.submit_command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": quantity})
|
||||
|
||||
|
||||
func _hire_and_assign(staff_id: StringName, facility: StringName) -> void:
|
||||
_command(&"hire_staff_candidate", {&"candidate_id": staff_id})
|
||||
_command(&"set_staff_work_assignment", {&"staff_id": staff_id, &"facility_id": _ids[facility], &"template_id": &"DAY"})
|
||||
|
||||
|
||||
func _inventory(definition: StringName) -> StringName:
|
||||
return _runtime.facility_service.get_instance(_ids[definition]).local_inventory_id
|
||||
|
||||
|
||||
func _quantity(definition: StringName, item: StringName) -> int:
|
||||
return _runtime.inventory_service.total_quantity(_inventory(definition), item)
|
||||
|
||||
|
||||
func _incoming(definition: StringName, item: StringName) -> int:
|
||||
var report: Dictionary = _runtime.get_supply_management_report()
|
||||
var group := &"warehouses" if definition == &"warehouse" else &"kitchens"
|
||||
for row: Dictionary in report[group]:
|
||||
if StringName(row[&"facility_id"]) != _ids[definition]:
|
||||
continue
|
||||
for stock: Dictionary in row[&"stock"]:
|
||||
if StringName(stock[&"item_id"]) == item:
|
||||
return int(stock[&"in_transit"])
|
||||
return -1
|
||||
|
||||
|
||||
func _meal_requests() -> Array[LogisticsRequest]:
|
||||
var requests: Array[LogisticsRequest] = []
|
||||
for request: LogisticsRequest in _runtime.logistics_requests.values():
|
||||
if request.item_requirements.has(SupplyCatalog.BASIC_MEAL):
|
||||
requests.append(request)
|
||||
return requests
|
||||
|
||||
|
||||
func _raw_requests_for_kitchen() -> Array[LogisticsRequest]:
|
||||
var requests: Array[LogisticsRequest] = []
|
||||
for request: LogisticsRequest in _runtime.logistics_requests.values():
|
||||
if request.destination_inventory_id == _inventory(&"kitchen") and request.item_requirements.has(SupplyCatalog.RAW_FOOD):
|
||||
requests.append(request)
|
||||
return requests
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("Supply management service: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmfdmeee0ev61
|
||||
@@ -0,0 +1,92 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("P1: Runtime v7 supply save migration")
|
||||
var source := _fixture(7301)
|
||||
var first := _fixture(7302)
|
||||
var second := _fixture(7303)
|
||||
var source_runtime: Variant = source[&"runtime"]
|
||||
var first_runtime: Variant = first[&"runtime"]
|
||||
var second_runtime: Variant = second[&"runtime"]
|
||||
source[&"clock"].advance_minutes(3)
|
||||
var canonical: Dictionary = source_runtime.create_snapshot()
|
||||
var canonical_data: Dictionary = canonical[&"systems"][&"runtime"]
|
||||
_expect(canonical_data[&"runtime_schema_version"] == 7, "current Runtime emits schema v7")
|
||||
_expect(canonical_data[&"kitchen_production"][&"current_minute"] == source[&"clock"].absolute_minute, "production clock follows Runtime clock without existing kitchens")
|
||||
var legacy := canonical.duplicate(true)
|
||||
var legacy_data: Dictionary = legacy[&"systems"][&"runtime"]
|
||||
legacy_data[&"runtime_schema_version"] = 6
|
||||
legacy_data.erase(&"kitchen_production")
|
||||
_expect(legacy_data.has(&"facility_infrastructure") and legacy_data.has(&"route_projection"), "v6 fixture retains infrastructure introduced in v6")
|
||||
_expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v7")
|
||||
var migrated: Dictionary = first_runtime.create_snapshot()
|
||||
_expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 7, "v6 migration re-emits current schema")
|
||||
var kitchen: Dictionary = migrated[&"systems"][&"runtime"][&"kitchen_production"]
|
||||
_expect(kitchen[&"plans"].is_empty() and kitchen[&"active_batches"].is_empty(), "legacy migration does not infer kitchen production plans or batches")
|
||||
_expect(kitchen[&"current_minute"] == source[&"clock"].absolute_minute, "legacy production clock binds to the restored Runtime minute")
|
||||
_expect(migrated == canonical, "v6 migration preserves non-supply state exactly")
|
||||
_expect(second_runtime.restore_snapshot(legacy) and second_runtime.create_snapshot() == migrated, "v6 migration is deterministic across Runtime instances")
|
||||
_expect(first_runtime.restore_snapshot(migrated) and first_runtime.create_snapshot() == migrated, "canonical v7 round trip is exact")
|
||||
var missing_supply := canonical.duplicate(true)
|
||||
missing_supply[&"systems"][&"runtime"].erase(&"kitchen_production")
|
||||
_expect(_rejects_atomically(first_runtime, missing_supply), "v7 cannot omit its supply aggregate")
|
||||
var relabelled := canonical.duplicate(true)
|
||||
relabelled[&"systems"][&"runtime"][&"runtime_schema_version"] = 6
|
||||
_expect(_rejects_atomically(first_runtime, relabelled), "relabelled v7 with retained kitchen state cannot masquerade as v6")
|
||||
var bad_clock := canonical.duplicate(true)
|
||||
bad_clock[&"systems"][&"runtime"][&"kitchen_production"][&"current_minute"] -= 1
|
||||
_expect(_rejects_atomically(first_runtime, bad_clock), "production and Runtime clock disagreement is rejected")
|
||||
var bad_version := canonical.duplicate(true)
|
||||
bad_version[&"systems"][&"runtime"][&"kitchen_production"][&"version"] = 2
|
||||
_expect(_rejects_atomically(first_runtime, bad_version), "unknown nested production schema is rejected")
|
||||
var stock: Dictionary = source_runtime.inventory_service.add_stack(&"supplier.legacy", SupplyCatalog.RAW_FOOD, 4)
|
||||
_expect(stock[&"ok"], "orphan migration fixture has raw food")
|
||||
var hold: Dictionary = source_runtime.inventory_service.reserve_item(
|
||||
&"supplier.legacy", SupplyCatalog.RAW_FOOD, 4,
|
||||
&"kitchen.batch:00000001", SupplyCatalog.PRODUCTION_PURPOSE, source[&"clock"].absolute_minute
|
||||
)
|
||||
_expect(hold[&"ok"], "orphan fixture creates a production reservation")
|
||||
var orphaned: Dictionary = source_runtime.create_snapshot()
|
||||
orphaned[&"systems"][&"runtime"][&"runtime_schema_version"] = 6
|
||||
orphaned[&"systems"][&"runtime"].erase(&"kitchen_production")
|
||||
_expect(_rejects_atomically(first_runtime, orphaned), "legacy label cannot hide input holds for a discarded production batch")
|
||||
for fixture: Dictionary in [source, first, second]:
|
||||
fixture[&"runtime"].free()
|
||||
fixture[&"clock"].free()
|
||||
fixture[&"state"].free()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _fixture(seed: int) -> Dictionary:
|
||||
var clock: Variant = load("res://autoload/game_clock.gd").new()
|
||||
var state: Variant = load("res://autoload/game_state.gd").new()
|
||||
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
_expect(runtime.initialize(clock, state, seed)[&"valid"], "migration fixture initializes")
|
||||
clock.set_paused(true)
|
||||
return {&"clock": clock, &"state": state, &"runtime": runtime}
|
||||
|
||||
|
||||
func _rejects_atomically(runtime: Variant, candidate: Dictionary) -> bool:
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
return not runtime.restore_snapshot(candidate) and runtime.create_snapshot() == before
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://t3p5aqdc4g5h
|
||||
@@ -0,0 +1,242 @@
|
||||
extends SceneTree
|
||||
|
||||
## Normal build/purchase commands: UI drafts, money/stock reconciliation, queued
|
||||
## purchases and an isolated save slot. No free inventory is inserted.
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _main: Control
|
||||
var _panel: SupplyManagementPanel
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _warehouse: StringName = &""
|
||||
var _kitchens: Array[StringName] = []
|
||||
var _item: StringName = &""
|
||||
var _save_path := "user://tests/supply_ui_%d/queued_purchase" % OS.get_process_id()
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_clock = root.get_node("GameClock")
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
_runtime.initialize(_clock, state, 824242)
|
||||
_clock.set_paused(true)
|
||||
root.size = Vector2i(1280, 720)
|
||||
_main = load("res://scenes/main/main.tscn").instantiate() as Control
|
||||
_main.set("manual_save_path", "user://tests/supply_ui_%d/manual" % OS.get_process_id())
|
||||
root.add_child(_main)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
await process_frame
|
||||
await process_frame
|
||||
_panel = _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
|
||||
_expect(_panel != null, "normal scene includes supply management")
|
||||
if _panel == null:
|
||||
_finish()
|
||||
return
|
||||
(_main.get("navigation_buttons")[&"product"] as Button).pressed.emit()
|
||||
_expect(_panel.visible and _panel.purchase_button.disabled, "product opens a safe empty state before any warehouse exists")
|
||||
_expect("倉庫" in _panel.warehouse_stock_label.text, "empty purchase page explains the warehouse prerequisite")
|
||||
for request: Array in [
|
||||
[&"warehouse", Vector3i(-8, 0, 1)],
|
||||
[&"kitchen", Vector3i(0, 0, 1)],
|
||||
[&"kitchen", Vector3i(6, 0, 1)],
|
||||
]:
|
||||
var before: int = _runtime.construction_service.get_jobs().size()
|
||||
_main.call("_select_build_definition", request[0])
|
||||
_main.call("_on_map_cell_clicked", request[1])
|
||||
_expect(_runtime.construction_service.get_jobs().size() == before + 1, "normal UI builds %s" % request[0])
|
||||
_clock.advance_minutes(180)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse":
|
||||
_warehouse = facility.instance_id
|
||||
elif facility.definition_id == &"kitchen":
|
||||
_kitchens.append(facility.instance_id)
|
||||
_expect(not _warehouse.is_empty() and _kitchens.size() == 2, "paid construction produces a warehouse and two kitchens")
|
||||
if _warehouse.is_empty() or _kitchens.size() != 2:
|
||||
_finish()
|
||||
return
|
||||
_main.call("_activate_management_mode", &"product")
|
||||
_panel.refresh()
|
||||
_item = _panel.selected_item_id
|
||||
_expect(not _item.is_empty(), "Runtime purchase catalog supplies a real item")
|
||||
_test_purchase_and_failure()
|
||||
_test_kitchen_drafts()
|
||||
_test_saved_pending_purchase()
|
||||
await _test_navigation_and_layout()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_purchase_and_failure() -> void:
|
||||
_choose(_panel.warehouse_option, _warehouse)
|
||||
_panel.quantity_spin.value = 4
|
||||
var quote: Dictionary = _runtime.preview_supply_purchase(_warehouse, _item, 4)
|
||||
_expect(bool(quote.get(&"ok", false)), "Runtime quotes a paid purchase for the completed warehouse")
|
||||
var balance_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var stock_before := _stock()
|
||||
_panel.purchase_button.pressed.emit()
|
||||
_expect(_stock() == stock_before + 4, "purchase button credits the warehouse's actual inventory")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == balance_before - int(quote.get(&"total_cost", -1)),
|
||||
"purchase pays exactly the authoritative quoted cost")
|
||||
_expect("仕入れました" in _panel.result_label.text, "successful purchase is visible in Japanese")
|
||||
# A deliberately oversized draft must fail without reducing cash or stock.
|
||||
_panel.quantity_spin.allow_greater = true
|
||||
_panel.quantity_spin.value = int(quote.get(&"max_quantity", 100)) + 100
|
||||
var invalid_quantity := int(_panel.quantity_spin.value)
|
||||
var balance_after: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var stock_after := _stock()
|
||||
_expect(_panel.purchase_button.disabled, "invalid quoted quantity disables purchase before submission")
|
||||
_panel.submit_purchase()
|
||||
_expect(_stock() == stock_after and _runtime.economy_service.get_balance(&"PLAYER_CASH") == balance_after,
|
||||
"rejected purchase never partially spends money or inserts stock")
|
||||
_expect(int(_panel.quantity_spin.value) == invalid_quantity and not _panel.result_label.text.is_empty(),
|
||||
"rejected purchase preserves the quantity and an actionable result")
|
||||
_panel.quantity_spin.allow_greater = false
|
||||
_panel.quantity_spin.value = 2
|
||||
|
||||
|
||||
func _test_kitchen_drafts() -> void:
|
||||
_panel.select_tab(&"kitchen")
|
||||
_choose(_panel.kitchen_option, _kitchens[0])
|
||||
_panel.production_enabled.button_pressed = true
|
||||
_panel.target_spin.value = 4
|
||||
_panel.select_tab(&"purchase")
|
||||
_clock.advance_minutes(1)
|
||||
_panel.select_tab(&"kitchen")
|
||||
_expect(_panel.production_enabled.button_pressed and int(_panel.target_spin.value) == 4,
|
||||
"time updates and tab changes preserve an unsaved kitchen draft")
|
||||
_clock.set_paused(false)
|
||||
_panel.production_button.pressed.emit()
|
||||
_expect(_panel.production_button.disabled, "queued kitchen setting disables another submission for that kitchen")
|
||||
_choose(_panel.kitchen_option, _kitchens[1])
|
||||
_panel.production_enabled.button_pressed = true
|
||||
_panel.target_spin.value = 8
|
||||
_clock.advance_minutes(1)
|
||||
_clock.set_paused(true)
|
||||
_expect(_panel.selected_kitchen_id == _kitchens[1] and int(_panel.target_spin.value) == 8
|
||||
and _panel.production_enabled.button_pressed, "another kitchen's completion preserves the selected kitchen's draft")
|
||||
var configured := _kitchen_row(_kitchens[0])
|
||||
_expect(bool(configured.get(&"enabled", false)) and int(configured.get(&"target_stock", -1)) == 4,
|
||||
"saved kitchen settings reach the authoritative Runtime model")
|
||||
_panel.production_button.pressed.emit()
|
||||
var second := _kitchen_row(_kitchens[1])
|
||||
_expect(bool(second.get(&"enabled", false)) and int(second.get(&"target_stock", -1)) == 8,
|
||||
"second kitchen applies its own saved target")
|
||||
_expect("職員" in _panel.kitchen_status_label.text or "経路" in _panel.kitchen_status_label.text,
|
||||
"unready kitchen explains its authoritative staffing or route blocker in Japanese")
|
||||
_panel.production_enabled.button_pressed = false
|
||||
_panel.production_button.pressed.emit()
|
||||
_expect(not bool(_kitchen_row(_kitchens[1]).get(&"enabled", true)), "automatic production can be stopped through the same controls")
|
||||
|
||||
|
||||
func _test_saved_pending_purchase() -> void:
|
||||
_panel.select_tab(&"purchase")
|
||||
_panel.quantity_spin.value = 2
|
||||
var stock_before := _stock()
|
||||
var balance_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var quote: Dictionary = _runtime.preview_supply_purchase(_warehouse, _item, 2)
|
||||
_clock.set_paused(false)
|
||||
_panel.purchase_button.pressed.emit()
|
||||
var pending_before: int = _runtime.command_queue.create_snapshot().get("pending", []).size()
|
||||
_panel.submit_purchase()
|
||||
_expect(pending_before == 1 and _runtime.command_queue.create_snapshot().get("pending", []).size() == 1,
|
||||
"repeated purchase cannot enqueue the same draft twice")
|
||||
var saved: Dictionary = _runtime.save_to_path(_save_path)
|
||||
_expect(bool(saved.get(&"ok", false)), "queued purchase saves to an isolated test slot")
|
||||
_clock.advance_minutes(1)
|
||||
var loaded: Dictionary = _runtime.load_from_path(_save_path)
|
||||
_expect(bool(loaded.get(&"ok", false)), "pending purchase reloads successfully")
|
||||
_panel.refresh()
|
||||
_expect(_panel.purchase_button.disabled, "restored pending purchase cannot be resubmitted")
|
||||
_clock.advance_minutes(1)
|
||||
_clock.set_paused(true)
|
||||
_panel.refresh()
|
||||
_expect(_stock() == stock_before + 2 and _runtime.economy_service.get_balance(&"PLAYER_CASH") == balance_before - int(quote.get(&"total_cost", -1)),
|
||||
"restored purchase credits and charges exactly once")
|
||||
_expect(not _panel.purchase_button.disabled, "completed restored command releases purchase controls")
|
||||
var digest: String = _runtime.latest_digest
|
||||
_panel.refresh()
|
||||
_panel.select_tab(&"delivery")
|
||||
_expect(_runtime.latest_digest == digest, "displaying stock and deliveries never mutates simulation")
|
||||
_expect(not (_panel.get_report().get(&"deliveries", []) as Array).is_empty(),
|
||||
"real warehouse stock creates visible kitchen replenishment requests")
|
||||
_expect("倉庫" in _panel.delivery_label.text and "厨房" in _panel.delivery_label.text,
|
||||
"delivery page resolves source and destination to identifiable facilities")
|
||||
_expect("運搬員" in _panel.delivery_label.text and "経路" in _panel.delivery_label.text,
|
||||
"waiting delivery explains the actual porter or route shortage in Japanese")
|
||||
|
||||
|
||||
func _test_navigation_and_layout() -> void:
|
||||
_main.call("_activate_management_mode", &"ledger")
|
||||
_expect(not _panel.visible and (_main.get("operating_ledger_panel") as Control).visible, "ledger replaces supply controls")
|
||||
_main.call("_unhandled_key_input", _key(KEY_H))
|
||||
_expect(not _panel.visible and (_main.get("staff_candidate_panel") as Control).visible, "H returns to the hiring candidates")
|
||||
_main.call("_activate_management_mode", &"product")
|
||||
await process_frame
|
||||
await process_frame
|
||||
var side := _main.find_child("SidePanel", true, false) as Control
|
||||
var guide := _main.find_child("InnOpeningGuide", true, false) as Control
|
||||
_expect(not side.get_global_rect().intersects(guide.get_global_rect()), "1280x720 keeps supply inspector clear of opening guidance")
|
||||
_expect(_panel.get_global_rect().position.x >= side.get_global_rect().position.x
|
||||
and _panel.get_global_rect().end.x <= side.get_global_rect().end.x,
|
||||
"supply tabs and inputs fit inside the existing sidebar width")
|
||||
if OS.get_environment("THE_TREE_SUPPLY_UI_CAPTURE") == "1":
|
||||
for tab: StringName in [&"purchase", &"kitchen", &"delivery"]:
|
||||
_panel.select_tab(tab)
|
||||
if tab == &"kitchen":
|
||||
_choose(_panel.kitchen_option, _kitchens[0])
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var captured := root.get_texture().get_image()
|
||||
captured.save_png("/tmp/the-tree-supply-%s.png" % tab)
|
||||
if tab == &"purchase":
|
||||
captured.save_png("/tmp/the-tree-supply-management.png")
|
||||
_main.call("_unhandled_key_input", _key(KEY_ESCAPE))
|
||||
_expect(not _panel.visible and not bool(_main.get("_management_panel_open")), "Escape closes supply management and restores the map")
|
||||
|
||||
|
||||
func _stock() -> int:
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(_warehouse)
|
||||
return _runtime.inventory_service.total_quantity(facility.local_inventory_id, _item)
|
||||
|
||||
|
||||
func _kitchen_row(id: StringName) -> Dictionary:
|
||||
for row: Dictionary in _runtime.get_supply_management_report().get(&"kitchens", []):
|
||||
if StringName(row.get(&"facility_id", &"")) == id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _choose(picker: OptionButton, id: StringName) -> void:
|
||||
for index: int in picker.item_count:
|
||||
if StringName(picker.get_item_metadata(index)) == id:
|
||||
picker.select(index)
|
||||
picker.item_selected.emit(index)
|
||||
return
|
||||
_expect(false, "requested facility appears in the relevant supply dropdown: %s" % id)
|
||||
|
||||
|
||||
func _key(code: Key) -> InputEventKey:
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = code
|
||||
event.pressed = true
|
||||
return event
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
SaveService.delete_save(_save_path)
|
||||
if is_instance_valid(_main):
|
||||
_main.queue_free()
|
||||
print("Supply management UI: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://f077idu4qluv
|
||||
Reference in New Issue
Block a user