Files
TheTree_mock/tests/s1/test_excavation_service.gd

286 lines
15 KiB
GDScript

extends SceneTree
const ExcavationServiceType = preload("res://scripts/construction/excavation_service.gd")
const WorldServiceType = preload("res://scripts/world/world_service.gd")
var _checks := 0
var _failures := 0
class FakeResourceState:
extends RefCounted
var resources := {&"gold": 1_000, &"materials": 100}
var mutate_then_fail_spend := false
var fail_restore := false
var reentrant_service: Variant
var reentrant_attempted := false
var reentrant_succeeded := false
func get_resource(resource_name: StringName) -> int:
return int(resources.get(resource_name, 0))
func spend(cost: Dictionary) -> bool:
if reentrant_service != null and not reentrant_attempted:
reentrant_attempted = true
var nested: Variant = reentrant_service.start_excavation(Vector3i(1, -1, 0), 1)
reentrant_succeeded = nested.is_success()
if mutate_then_fail_spend:
resources[&"gold"] = int(resources[&"gold"]) - int(cost.get(&"gold", 0))
return false
for resource_name: Variant in cost:
if get_resource(StringName(resource_name)) < int(cost[resource_name]):
return false
for resource_name: Variant in cost:
var key := StringName(resource_name)
resources[key] = int(resources[key]) - int(cost[resource_name])
return true
func apply_resource_deltas(deltas: Dictionary) -> bool:
var candidate := resources.duplicate(true)
for resource_name: Variant in deltas:
var key := StringName(resource_name)
candidate[key] = int(candidate.get(key, 0)) + int(deltas[resource_name])
if int(candidate[key]) < 0:
return false
resources = candidate
return true
func create_snapshot() -> Dictionary:
return {&"resources": resources.duplicate(true)}
func restore_snapshot(snapshot: Dictionary) -> bool:
if fail_restore:
return false
if not snapshot.get(&"resources", null) is Dictionary:
return false
resources = (snapshot[&"resources"] as Dictionary).duplicate(true)
return true
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("S1: Excavation Service")
_test_rules_adjacency_and_two_layer_unlock()
_test_disconnected_island_is_rejected()
_test_pause_resume_preserves_progress()
_test_hard_width_requires_support()
_test_hard_cluster_is_not_a_four_cell_span()
_test_progress_replay_is_idempotent()
_test_snapshot_round_trip_and_atomic_rejection()
_test_snapshot_schema_strictness_and_v1_migration()
_test_reentry_and_failed_rollback_quarantine()
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _test_rules_adjacency_and_two_layer_unlock() -> void:
var world := _world(0, 3, -3, 0)
var resources := FakeResourceState.new()
var service := ExcavationServiceType.new(world, resources)
_expect(service.get_rule(0) == {&"gold": 20, &"materials": 1, &"minutes": 30}, "hardness 0 uses the canonical cost and time")
_expect(service.get_rule(1) == {&"gold": 50, &"materials": 1, &"minutes": 60}, "hardness 1 uses the canonical cost and time")
_expect(service.get_rule(2) == {&"gold": 100, &"materials": 2, &"minutes": 120}, "hardness 2 uses the canonical cost and time")
_expect(service.get_rule(3) == {&"gold": 200, &"materials": 4, &"minutes": 240}, "hardness 3 uses the canonical cost and time")
_expect(service.set_hardness(Vector2i(0, -1), 1), "underground hardness can be assigned before work")
var report := service.validate_excavation(Vector3i(0, -1, 1))
_expect(report[&"ok"] and report[&"entrance_connected"], "first underground cell connects to the boundary entrance")
_expect(report[&"cost"] == {&"gold": 50, &"materials": 1} and report[&"build_minutes"] == 60, "validation exposes exact hardness tuning")
var started := service.start_excavation(Vector3i(0, -1, 0), 360)
_expect(started.is_success(), "valid connected excavation starts")
_expect(resources.resources == {&"gold": 950, &"materials": 99}, "excavation payment is charged exactly once")
var job_id: StringName = started.details[&"job_id"]
for minute: int in range(360, 420):
service.progress_job(job_id, service.NORMAL_WORK_RATE, minute)
_expect(world.is_excavated(Vector3i(0, -1, 0)), "completed excavation unlocks FRONT")
_expect(world.is_excavated(Vector3i(0, -1, 1)), "the same completed excavation unlocks BACK")
_expect(service.get_job(job_id)[&"state"] == service.STATE_COMPLETED, "completed job becomes terminal")
_expect(not service.validate_excavation(Vector3i(0, -1, 0))[&"ok"], "an excavated cell cannot be charged twice")
_expect(service.validate_invariants()[&"ok"], "completed excavation retains valid invariants")
func _test_disconnected_island_is_rejected() -> void:
var world := _world(0, 4, -4, 0)
var resources := FakeResourceState.new()
var service := ExcavationServiceType.new(world, resources)
world.set_excavated(Vector3i(4, -4, 0), true)
var report := service.validate_excavation(Vector3i(3, -4, 0))
_expect(not report[&"ok"], "an island next to an excavated cell is still rejected")
_expect(report[&"errors"].has(service.ERROR_NO_ENTRANCE_CONNECTION), "island rejection identifies the missing entrance connection")
_expect(resources.resources == {&"gold": 1_000, &"materials": 100}, "failed validation changes no resources")
var no_neighbor := service.validate_excavation(Vector3i(1, -3, 0))
_expect(no_neighbor[&"errors"].has(service.ERROR_NO_EXCAVATED_NEIGHBOR), "a cell without any excavated neighbor has a distinct reason")
func _test_pause_resume_preserves_progress() -> void:
var world := _world(0, 2, -2, 0)
var resources := FakeResourceState.new()
var service := ExcavationServiceType.new(world, resources)
var started := service.start_excavation(Vector3i(0, -1, 0), 100)
var job_id: StringName = started.details[&"job_id"]
for minute: int in range(100, 110):
service.progress_job(job_id, service.NORMAL_WORK_RATE, minute)
var progress_before := int(service.get_job(job_id)[&"progress"])
_expect(progress_before > 0 and progress_before < service.MAX_PROGRESS, "work accumulates as deterministic fixed-point progress")
_expect(service.pause_job(job_id).is_success(), "active excavation can be paused")
service.advance_one_minute(111)
_expect(service.get_job(job_id)[&"progress"] == progress_before, "paused time does not discard or advance progress")
_expect(service.resume_job(job_id).is_success(), "connected paused excavation can resume")
for minute: int in range(112, 132):
service.progress_job(job_id, service.NORMAL_WORK_RATE, minute)
_expect(service.get_job(job_id)[&"state"] == service.STATE_COMPLETED, "resumed work completes from retained progress")
_expect(service.pause_job(job_id).details[&"code"] == &"TERMINAL_STATE", "completed excavation cannot return to a mutable state")
func _test_hard_width_requires_support() -> void:
var world := _world(0, 4, -1, 0)
var resources := FakeResourceState.new()
var service := ExcavationServiceType.new(world, resources)
for x: int in range(4):
_expect(service.set_hardness(Vector2i(x, -1), 2), "hard cell %d is configured" % x)
for x: int in range(3):
world.set_excavated(Vector3i(x, -1, 0), true)
var unsupported := service.validate_excavation(Vector3i(3, -1, 0))
_expect(unsupported[&"support_required"], "four connected hardness-2 cells trigger support")
_expect(unsupported[&"errors"].has(service.ERROR_SUPPORT_REQUIRED), "unsupported hard span cannot start")
world.set_support(Vector3i(2, -1, 1), 1)
var supported := service.validate_excavation(Vector3i(3, -1, 0))
_expect(supported[&"ok"] and supported[&"support_required"], "nearby support makes the same hard span legal")
_expect(service.start_excavation(Vector3i(3, -1, 1)).is_success(), "supported hard excavation can reserve its spatial cell")
_expect(not service.start_excavation(Vector3i(3, -1, 0)).is_success(), "FRONT and BACK cannot reserve the same spatial excavation twice")
func _test_hard_cluster_is_not_a_four_cell_span() -> void:
var world := _world(0, 1, -2, 0)
var service := ExcavationServiceType.new(world, FakeResourceState.new())
for position: Vector2i in [Vector2i(0, -1), Vector2i(1, -1), Vector2i(0, -2), Vector2i(1, -2)]:
service.set_hardness(position, 2)
world.set_excavated(Vector3i(0, -1, 0), true)
world.set_excavated(Vector3i(1, -1, 0), true)
world.set_excavated(Vector3i(0, -2, 0), true)
var report := service.validate_excavation(Vector3i(1, -2, 0))
_expect(report[&"ok"], "a connected 2x2 hard cluster remains excavatable without support")
_expect(not report[&"support_required"], "support is based on four collinear cells, not component cardinality")
func _test_progress_replay_is_idempotent() -> void:
var world := _world(0, 2, -2, 0)
var service := ExcavationServiceType.new(world, FakeResourceState.new())
var started := service.start_excavation(Vector3i(0, -1, 0), 5)
var job_id: StringName = started.details[&"job_id"]
var first := service.progress_job(job_id, service.NORMAL_WORK_RATE, 10)
var progress_once := int(service.get_job(job_id)[&"progress"])
var duplicate := service.progress_job(job_id, service.NORMAL_WORK_RATE, 10)
_expect(first.is_success() and duplicate.is_success() and duplicate.is_replay, "same-minute excavation retry is an explicit replay")
_expect(service.get_job(job_id)[&"progress"] == progress_once, "same-minute retry cannot add work twice")
var snapshot := service.create_snapshot()
var restored := ExcavationServiceType.new(world, FakeResourceState.new())
_expect(restored.restore_snapshot(snapshot), "last processed excavation minute survives snapshot restore")
var restored_retry := restored.progress_job(job_id, service.NORMAL_WORK_RATE, 10)
_expect(restored_retry.is_replay and restored.get_job(job_id)[&"progress"] == progress_once, "restored service also rejects the already-applied minute")
func _test_snapshot_round_trip_and_atomic_rejection() -> void:
var world := _world(0, 3, -2, 0)
var resources := FakeResourceState.new()
var source := ExcavationServiceType.new(world, resources)
source.set_hardness(Vector2i(0, -1), 1)
var completed_id: StringName = source.start_excavation(Vector3i(0, -1, 0), 10).details[&"job_id"]
for minute: int in range(10, 70):
source.progress_job(completed_id, source.NORMAL_WORK_RATE, minute)
source.set_hardness(Vector2i(0, -2), 3)
var active_id: StringName = source.start_excavation(Vector3i(0, -2, 0), 70).details[&"job_id"]
for minute: int in range(70, 75):
source.progress_job(active_id, source.NORMAL_WORK_RATE, minute)
var snapshot := source.create_snapshot()
var restored := ExcavationServiceType.new(world, resources)
_expect(restored.restore_snapshot(snapshot), "valid excavation snapshot restores against the same world")
_expect(restored.create_snapshot() == snapshot, "excavation snapshot round trip is exact")
_expect(restored.validate_invariants()[&"ok"], "restored active and completed jobs satisfy cross-links")
var before := restored.create_snapshot()
var corrupt := snapshot.duplicate(true)
corrupt[&"jobs"][1][&"progress"] = 9_999
_expect(not restored.restore_snapshot(corrupt), "derived progress tampering is rejected")
_expect(restored.create_snapshot() == before, "failed excavation restore preserves the live state atomically")
var duplicate := snapshot.duplicate(true)
duplicate[&"jobs"].append(duplicate[&"jobs"][1].duplicate(true))
_expect(not restored.restore_snapshot(duplicate), "duplicate job identity is rejected")
_expect(restored.create_snapshot() == before, "all rejected snapshot shapes remain non-mutating")
func _test_snapshot_schema_strictness_and_v1_migration() -> void:
var world := _world(0, 2, -2, 0)
var source := ExcavationServiceType.new(world, FakeResourceState.new())
var job_id: StringName = source.start_excavation(Vector3i(0, -1, 0), 10).details[&"job_id"]
source.progress_job(job_id, source.NORMAL_WORK_RATE, 11)
var snapshot := source.create_snapshot()
var restored := ExcavationServiceType.new(world, FakeResourceState.new())
var unknown := snapshot.duplicate(true)
unknown[&"unknown"] = 1
_expect(not restored.restore_snapshot(unknown), "current excavation schema rejects unknown top-level keys")
var type_spoof := snapshot.duplicate(true)
type_spoof[&"schema_version"] = "2"
_expect(not restored.restore_snapshot(type_spoof), "current excavation schema rejects a string version")
var cost_spoof := snapshot.duplicate(true)
cost_spoof[&"jobs"][0][&"cost"][&"gold"] = "20"
_expect(not restored.restore_snapshot(cost_spoof), "current excavation schema rejects numeric-string costs")
var derived_tamper := snapshot.duplicate(true)
derived_tamper[&"jobs"][0][&"build_minutes"] = 31
_expect(not restored.restore_snapshot(derived_tamper), "current excavation schema rejects canonical-rule tampering")
var legacy := snapshot.duplicate(true)
legacy[&"schema_version"] = source.LEGACY_SNAPSHOT_VERSION
legacy.erase(&"quarantined")
legacy.erase(&"quarantine_reason")
for saved_job: Dictionary in legacy[&"jobs"]:
saved_job.erase(&"last_progress_at")
_expect(restored.restore_snapshot(legacy), "legacy v1 excavation snapshot migrates through its exact old shape")
_expect(restored.create_snapshot()[&"schema_version"] == source.SNAPSHOT_VERSION, "legacy excavation state is re-emitted as strict v2")
_expect(restored.progress_job(job_id, source.NORMAL_WORK_RATE, 11).is_replay, "v1 migration conservatively prevents replaying prior progress")
func _test_reentry_and_failed_rollback_quarantine() -> void:
var world := _world(0, 2, -2, 0)
var reentrant_resources := FakeResourceState.new()
var service := ExcavationServiceType.new(world, reentrant_resources)
reentrant_resources.reentrant_service = service
var started := service.start_excavation(Vector3i(0, -1, 0), 1)
_expect(started.is_success() and reentrant_resources.reentrant_attempted, "resource callback attempts a nested excavation command")
_expect(not reentrant_resources.reentrant_succeeded and service.get_jobs().size() == 1, "reentrant excavation mutation is rejected before a second charge or reservation")
reentrant_resources.reentrant_service = null
var broken_resources := FakeResourceState.new()
broken_resources.mutate_then_fail_spend = true
broken_resources.fail_restore = true
var broken := ExcavationServiceType.new(_world(0, 1, -1, 0), broken_resources)
var failed := broken.start_excavation(Vector3i(0, -1, 0), 2)
_expect(not failed.is_success() and failed.details[&"rollback_failed"], "failed excavation payment reports an unrecoverable rollback")
_expect(broken.is_quarantined(), "unrecoverable excavation rollback quarantines the service")
_expect(not broken.start_excavation(Vector3i(1, -1, 0), 3).is_success(), "quarantined excavation service refuses later mutations")
func _world(min_x: int, max_x: int, min_y: int, max_y: int) -> WorldService:
var world := WorldServiceType.new()
world.reset({
&"min_x": min_x,
&"max_x": max_x,
&"min_y": min_y,
&"max_y": max_y,
})
return world
func _expect(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" ✓ %s" % label)
return
_failures += 1
push_error(" ✗ %s" % label)