286 lines
15 KiB
GDScript
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)
|