753 lines
34 KiB
GDScript
753 lines
34 KiB
GDScript
extends SceneTree
|
|
|
|
const FacilityDefinitionType = preload("res://scripts/data/facility_definition.gd")
|
|
const PlacementReportType = preload("res://scripts/construction/placement_report.gd")
|
|
const ConstructionJobType = preload("res://scripts/construction/construction_job.gd")
|
|
const ConstructionServiceType = preload("res://scripts/construction/construction_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_apply := 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 can_afford(cost: Dictionary) -> bool:
|
|
for resource_name: Variant in cost:
|
|
if get_resource(StringName(resource_name)) < int(cost[resource_name]):
|
|
return false
|
|
return true
|
|
|
|
func spend(cost: Dictionary) -> bool:
|
|
if reentrant_service != null and not reentrant_attempted:
|
|
reentrant_attempted = true
|
|
var nested: Variant = reentrant_service.start_construction(
|
|
_valid_definition_for_reentry(),
|
|
Vector3i(2, 2, 0)
|
|
)
|
|
reentrant_succeeded = nested.is_success()
|
|
if mutate_then_fail_spend:
|
|
resources[&"gold"] = int(resources[&"gold"]) - int(cost.get(&"gold", 0))
|
|
return false
|
|
if not can_afford(cost):
|
|
return false
|
|
var deltas: Dictionary = {}
|
|
for resource_name: Variant in cost:
|
|
deltas[StringName(resource_name)] = -int(cost[resource_name])
|
|
return apply_resource_deltas(deltas)
|
|
|
|
func apply_resource_deltas(deltas: Dictionary) -> bool:
|
|
if fail_apply:
|
|
return false
|
|
var next := resources.duplicate(true)
|
|
for resource_name: Variant in deltas:
|
|
var key := StringName(resource_name)
|
|
next[key] = int(next.get(key, 0)) + int(deltas[resource_name])
|
|
if int(next[key]) < 0:
|
|
return false
|
|
resources = next
|
|
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"].duplicate(true)
|
|
return true
|
|
|
|
func _valid_definition_for_reentry() -> Dictionary:
|
|
return {
|
|
&"id": &"nested_room",
|
|
&"footprint": Vector2i.ONE,
|
|
&"zone": FacilityDefinitionType.MapZone.ANY,
|
|
&"layer": FacilityDefinitionType.ViewLayer.FRONT,
|
|
&"construction_cost": {&"gold": 100, &"materials": 10},
|
|
&"build_minutes": 60,
|
|
&"weight_per_cell": 1,
|
|
}
|
|
|
|
|
|
class FakeWorld:
|
|
extends RefCounted
|
|
|
|
var min_x := 0
|
|
var max_x := 8
|
|
var min_y := -2
|
|
var max_y := 8
|
|
var occupancy: Dictionary = {}
|
|
var unsupported_cells: Array[Vector3i] = []
|
|
var fail_next_occupy := false
|
|
var fail_next_release := false
|
|
var fail_all_occupy := false
|
|
var excavation: Dictionary = {}
|
|
var forced_cells: Array[Vector3i] = []
|
|
|
|
func cells_for_footprint(
|
|
origin: Vector3i,
|
|
footprint: Vector2i,
|
|
rotation: int,
|
|
_layer: int
|
|
) -> Array[Vector3i]:
|
|
if not forced_cells.is_empty():
|
|
return forced_cells.duplicate()
|
|
var width := footprint.x
|
|
var height := footprint.y
|
|
if rotation % 180 != 0:
|
|
width = footprint.y
|
|
height = footprint.x
|
|
var cells: Array[Vector3i] = []
|
|
for y_offset in height:
|
|
for x_offset in width:
|
|
cells.append(origin + Vector3i(x_offset, y_offset, 0))
|
|
return cells
|
|
|
|
func is_in_bounds(cell: Vector3i) -> bool:
|
|
return cell.x >= min_x and cell.x <= max_x and cell.y >= min_y and cell.y <= max_y
|
|
|
|
func get_zone(cell: Vector3i) -> int:
|
|
if cell.y > 0:
|
|
return FacilityDefinitionType.MapZone.ABOVE_GROUND
|
|
if cell.y < 0:
|
|
return FacilityDefinitionType.MapZone.UNDERGROUND
|
|
return FacilityDefinitionType.MapZone.BOUNDARY
|
|
|
|
func get_occupant(cell: Vector3i) -> Variant:
|
|
return occupancy.get(cell, &"")
|
|
|
|
func is_excavated(cell: Vector3i) -> bool:
|
|
return bool(excavation.get(Vector2i(cell.x, cell.y), true))
|
|
|
|
func validate_support(_cells: Array[Vector3i], _weight_per_cell: int) -> Dictionary:
|
|
return {
|
|
&"ok": unsupported_cells.is_empty(),
|
|
&"unsupported_cells": unsupported_cells.duplicate(),
|
|
&"reason": "Support capacity is insufficient.",
|
|
}
|
|
|
|
func occupy(occupant_id: StringName, cells: Array[Vector3i], _kind: StringName) -> bool:
|
|
if fail_all_occupy:
|
|
return false
|
|
if fail_next_occupy:
|
|
fail_next_occupy = false
|
|
return false
|
|
for cell: Vector3i in cells:
|
|
if not String(occupancy.get(cell, &"")).is_empty():
|
|
return false
|
|
for cell: Vector3i in cells:
|
|
occupancy[cell] = occupant_id
|
|
return true
|
|
|
|
func release(occupant_id: StringName) -> bool:
|
|
if fail_next_release:
|
|
fail_next_release = false
|
|
return false
|
|
var cells: Array[Vector3i] = []
|
|
for cell: Vector3i in occupancy:
|
|
if occupancy[cell] == occupant_id:
|
|
cells.append(cell)
|
|
if cells.is_empty():
|
|
return false
|
|
for cell: Vector3i in cells:
|
|
occupancy.erase(cell)
|
|
return true
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S1: Construction Service")
|
|
_test_report_collects_every_reason()
|
|
_test_start_is_atomic()
|
|
_test_cancellation_refund()
|
|
_test_completion_and_demolition()
|
|
_test_completion_rollback_and_retry()
|
|
_test_completion_rollback_failure_quarantines()
|
|
_test_progress_replay_is_idempotent()
|
|
_test_deferred_completion_commit_and_reentry()
|
|
_test_deferred_failure_discard_and_restore()
|
|
_test_ordinary_advance_notifications_unchanged()
|
|
_test_snapshot_round_trip()
|
|
_test_snapshot_strictness_and_v1_migration()
|
|
_test_definition_type_spoof_and_reentry()
|
|
_test_failed_rollback_quarantines_service()
|
|
_test_real_world_service_integration()
|
|
_test_underground_requires_excavation()
|
|
_test_every_footprint_cell_must_match_zone()
|
|
_test_boundary_facility_anchor_rule()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_report_collects_every_reason() -> void:
|
|
var world := FakeWorld.new()
|
|
world.max_x = 2
|
|
var state := FakeResourceState.new()
|
|
state.resources = {&"gold": 0, &"materials": 0}
|
|
var definition := _definition(
|
|
&"invalid_location",
|
|
Vector2i(2, 1),
|
|
FacilityDefinitionType.MapZone.ABOVE_GROUND,
|
|
FacilityDefinitionType.ViewLayer.FRONT,
|
|
{&"gold": 100, &"materials": 10}
|
|
)
|
|
var origin := Vector3i(2, -1, FacilityDefinitionType.ViewLayer.BACK)
|
|
world.occupancy[origin] = &"existing"
|
|
world.unsupported_cells = [origin]
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var report = service.validate_placement(definition, origin)
|
|
|
|
_expect(not report.can_build, "invalid placement is rejected")
|
|
_expect(report.has_error(PlacementReportType.ERROR_OUT_OF_BOUNDS), "bounds error is reported")
|
|
_expect(report.has_error(PlacementReportType.ERROR_ZONE_MISMATCH), "zone error is reported")
|
|
_expect(report.has_error(PlacementReportType.ERROR_LAYER_MISMATCH), "layer error is reported")
|
|
_expect(report.has_error(PlacementReportType.ERROR_OVERLAP), "overlap error is reported")
|
|
_expect(report.has_error(PlacementReportType.ERROR_UNSUPPORTED), "support error is reported")
|
|
_expect(
|
|
report.has_error(PlacementReportType.ERROR_INSUFFICIENT_RESOURCES),
|
|
"cost error is reported"
|
|
)
|
|
_expect(report.reasons.size() == 6, "all independent placement failures are retained")
|
|
|
|
|
|
func _test_start_is_atomic() -> void:
|
|
var world := FakeWorld.new()
|
|
var state := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var definition := _valid_definition()
|
|
var resources_before := state.resources.duplicate(true)
|
|
world.fail_next_occupy = true
|
|
var failed = service.start_construction(definition, Vector3i(1, 1, 0))
|
|
_expect(not failed.is_success(), "failed world reservation rejects construction")
|
|
_expect(state.resources == resources_before, "failed reservation rolls resource payment back")
|
|
_expect(world.occupancy.is_empty(), "failed reservation leaves no occupied cell")
|
|
_expect(service.get_jobs().is_empty(), "failed reservation creates no job")
|
|
|
|
var started = service.start_construction(definition, Vector3i(1, 1, 0), 0, 4, 600)
|
|
_expect(started.is_success(), "valid construction starts")
|
|
var job = service.get_job(started.details[&"job_id"])
|
|
_expect(job != null and job.state == ConstructionJobType.State.PLANNED, "new job starts PLANNED")
|
|
_expect(state.resources == {&"gold": 900, &"materials": 90}, "full construction cost is paid once")
|
|
_expect(world.get_occupant(Vector3i(1, 1, 0)) == job.job_id, "job atomically reserves its cells")
|
|
|
|
|
|
func _test_cancellation_refund() -> void:
|
|
var world := FakeWorld.new()
|
|
var state := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var first = service.start_construction(_valid_definition(), Vector3i(1, 1, 0), 0, 4)
|
|
var first_job_id: StringName = first.details[&"job_id"]
|
|
var cancelled = service.cancel_job(first_job_id)
|
|
_expect(cancelled.is_success(), "zero-progress job cancels")
|
|
_expect(state.resources == {&"gold": 1_000, &"materials": 100}, "zero-progress cancellation refunds all")
|
|
_expect(world.occupancy.is_empty(), "cancellation releases reserved cells")
|
|
|
|
var second = service.start_construction(_valid_definition(), Vector3i(2, 2, 0), 0, 4)
|
|
var second_job_id: StringName = second.details[&"job_id"]
|
|
service.progress_job(second_job_id)
|
|
var job = service.get_job(second_job_id)
|
|
_expect(job.progress == 2_500 and job.state == ConstructionJobType.State.BUILDING, "one minute advances exact integer progress")
|
|
var partial = service.cancel_job(second_job_id)
|
|
_expect(partial.details[&"refund"] == {&"gold": 75, &"materials": 7}, "partial cancellation refunds the unbuilt ratio")
|
|
_expect(state.resources == {&"gold": 975, &"materials": 97}, "partial refund is applied once")
|
|
|
|
|
|
func _test_completion_and_demolition() -> void:
|
|
var world := FakeWorld.new()
|
|
var state := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var started = service.start_construction(_valid_definition(), Vector3i(3, 3, 0), 0, 2)
|
|
var job_id: StringName = started.details[&"job_id"]
|
|
var first_tick = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 601)
|
|
_expect(first_tick.is_success() and service.get_job(job_id).progress == 5_000, "first build minute reaches half progress")
|
|
var second_tick = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 602)
|
|
var job = service.get_job(job_id)
|
|
_expect(second_tick.is_success() and job.state == ConstructionJobType.State.COMPLETED, "final minute completes the job")
|
|
var instance_id: StringName = second_tick.details[&"instance_id"]
|
|
_expect(not service.get_facility(instance_id).is_empty(), "completion creates a facility instance")
|
|
_expect(world.get_occupant(Vector3i(3, 3, 0)) == instance_id, "completion replaces job reservation with facility occupancy")
|
|
|
|
var demolished = service.demolish_facility(instance_id)
|
|
_expect(demolished.is_success(), "completed facility can be demolished")
|
|
_expect(world.occupancy.is_empty(), "demolition releases facility cells")
|
|
_expect(state.resources == {&"gold": 900, &"materials": 92}, "demolition returns 25 percent of materials")
|
|
|
|
|
|
func _test_completion_rollback_and_retry() -> void:
|
|
var world := FakeWorld.new()
|
|
var service := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var started = service.start_construction(_valid_definition(), Vector3i(3, 3, 0), 0, 2)
|
|
var job_id: StringName = started.details[&"job_id"]
|
|
service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 10)
|
|
world.fail_next_occupy = true
|
|
var failed = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 11)
|
|
var job = service.get_job(job_id)
|
|
_expect(not failed.is_success() and failed.details[&"rolled_back"], "failed completion reports a verified world rollback")
|
|
_expect(job.state == ConstructionJobType.State.BLOCKED and job.progress == ConstructionJobType.MAX_PROGRESS, "interrupted completion retains all finished work")
|
|
_expect(world.get_occupant(Vector3i(3, 3, 0)) == job_id, "failed facility transfer restores the construction reservation")
|
|
var retry = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 12)
|
|
_expect(retry.is_success() and service.get_job(job_id).state == ConstructionJobType.State.COMPLETED, "a later minute deterministically retries the blocked completion")
|
|
|
|
|
|
func _test_completion_rollback_failure_quarantines() -> void:
|
|
var world := FakeWorld.new()
|
|
var service := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var job_id: StringName = service.start_construction(_valid_definition(), Vector3i(3, 3, 0), 0, 2).details[&"job_id"]
|
|
service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 30)
|
|
world.fail_all_occupy = true
|
|
var failed = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 31)
|
|
_expect(not failed.is_success() and failed.details[&"rollback_failed"], "completion exposes failure to restore its reservation")
|
|
_expect(service.is_quarantined(), "unrecoverable completion rollback quarantines construction mutations")
|
|
_expect(not service.cancel_job(job_id).is_success(), "quarantined completion state cannot be mutated further")
|
|
|
|
|
|
func _test_progress_replay_is_idempotent() -> void:
|
|
var world := FakeWorld.new()
|
|
var service := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var job_id: StringName = service.start_construction(_valid_definition(), Vector3i(1, 1, 0), 0, 4).details[&"job_id"]
|
|
service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 20)
|
|
var progress_once := service.get_job(job_id).progress
|
|
var replay = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 20)
|
|
_expect(replay.is_success() and replay.is_replay, "same-minute construction retry is an explicit replay")
|
|
_expect(service.get_job(job_id).progress == progress_once, "same-minute construction retry cannot add work twice")
|
|
|
|
|
|
func _test_deferred_completion_commit_and_reentry() -> void:
|
|
var world := FakeWorld.new()
|
|
var resources := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, resources)
|
|
var definition := _valid_definition()
|
|
var started = service.start_construction(definition, Vector3i(1, 1, 0), 0, 1)
|
|
var job_id: StringName = started.details[&"job_id"]
|
|
var before_advance: Dictionary = service.create_snapshot()
|
|
var order: Array[StringName] = []
|
|
var delivered_jobs: Array[Dictionary] = []
|
|
var delivered_facilities: Array[Dictionary] = []
|
|
var reentry: Dictionary = {}
|
|
var job_changed_callback := func(job: ConstructionJob) -> void:
|
|
order.append(&"job_changed")
|
|
delivered_jobs.append(job.create_snapshot())
|
|
job.assigned_builder_ids.append(&"listener_tamper")
|
|
var nested_advance: Array[CommandResult] = service.advance_one_minute(102)
|
|
reentry[&"advance_rejected"] = (
|
|
nested_advance.size() == 1 and not nested_advance[0].is_success()
|
|
)
|
|
reentry[&"commit_rejected"] = not service.commit_deferred_notifications()
|
|
reentry[&"discard_rejected"] = not service.discard_deferred_notifications()
|
|
reentry[&"snapshot_rejected"] = service.create_snapshot().is_empty()
|
|
reentry[&"restore_rejected"] = not service.restore_snapshot(before_advance)
|
|
reentry[&"mutation_rejected"] = not service.start_construction(
|
|
definition, Vector3i(5, 5, 0)
|
|
).is_success()
|
|
var facility_completed_callback := func(instance: Dictionary) -> void:
|
|
order.append(&"facility_completed")
|
|
delivered_facilities.append(instance.duplicate(true))
|
|
(instance[&"occupied_cells"] as Array).clear()
|
|
instance[&"definition_id"] = &"listener_tamper"
|
|
service.job_changed.connect(job_changed_callback)
|
|
service.facility_completed.connect(facility_completed_callback)
|
|
|
|
var advanced: Array[CommandResult] = service.advance_one_minute(101, {}, true)
|
|
_expect(
|
|
advanced.size() == 1 and advanced[0].is_success(),
|
|
"deferred advance completes construction state"
|
|
)
|
|
var instance_id := StringName(advanced[0].details.get(&"instance_id", &""))
|
|
_expect(
|
|
order.is_empty() and delivered_jobs.is_empty() and delivered_facilities.is_empty(),
|
|
"deferred completion emits no raw signal before commit"
|
|
)
|
|
_expect(service.has_deferred_notifications(), "deferred completion exposes a pending notification batch")
|
|
_expect(service.create_snapshot().is_empty(), "pending notifications fail closed for snapshot capture")
|
|
_expect(
|
|
not service.restore_snapshot(before_advance),
|
|
"pending notifications reject snapshot restore"
|
|
)
|
|
var minute_before_rejected_advance := service.current_minute
|
|
var pending_advance: Array[CommandResult] = service.advance_one_minute(102)
|
|
_expect(
|
|
pending_advance.size() == 1
|
|
and not pending_advance[0].is_success()
|
|
and pending_advance[0].details.get(&"code", &"") == &"DEFERRED_NOTIFICATIONS_PENDING"
|
|
and service.current_minute == minute_before_rejected_advance,
|
|
"a pending batch rejects another advance before changing its minute"
|
|
)
|
|
_expect(
|
|
not service.start_construction(definition, Vector3i(5, 5, 0)).is_success(),
|
|
"a pending batch rejects unrelated mutation until its disposition is chosen"
|
|
)
|
|
# get_job historically returns the live aggregate. Mutating it after staging
|
|
# proves the queued notification captured its own defensive point-in-time copy.
|
|
service.get_job(job_id).assigned_builder_ids.append(&"late_live_builder")
|
|
_expect(service.commit_deferred_notifications(), "deferred notifications commit explicitly")
|
|
_expect(
|
|
order == [&"job_changed", &"facility_completed"]
|
|
and delivered_jobs.size() == 1
|
|
and delivered_facilities.size() == 1,
|
|
"commit emits completion signals exactly once in Construction order"
|
|
)
|
|
_expect(
|
|
delivered_jobs[0][&"assigned_builder_ids"].is_empty()
|
|
and service.get_job(job_id).assigned_builder_ids == [&"late_live_builder"],
|
|
"queued and delivered ConstructionJob payloads are defensive copies"
|
|
)
|
|
var live_facility: Dictionary = service.get_facility(instance_id)
|
|
_expect(
|
|
live_facility.get(&"definition_id", &"") == definition.id
|
|
and live_facility.get(&"occupied_cells", []) == [Vector3i(1, 1, 0)],
|
|
"listener mutation of a deferred facility payload cannot alter live state"
|
|
)
|
|
_expect(
|
|
bool(reentry.get(&"advance_rejected", false))
|
|
and bool(reentry.get(&"commit_rejected", false))
|
|
and bool(reentry.get(&"discard_rejected", false))
|
|
and bool(reentry.get(&"snapshot_rejected", false))
|
|
and bool(reentry.get(&"restore_rejected", false))
|
|
and bool(reentry.get(&"mutation_rejected", false)),
|
|
"signal callbacks cannot reenter notification dispatch, restore, snapshot, or mutation"
|
|
)
|
|
var committed_order := order.duplicate()
|
|
_expect(
|
|
not service.has_deferred_notifications()
|
|
and service.commit_deferred_notifications()
|
|
and order == committed_order,
|
|
"an empty repeat commit succeeds without emitting twice"
|
|
)
|
|
service.job_changed.disconnect(job_changed_callback)
|
|
service.facility_completed.disconnect(facility_completed_callback)
|
|
|
|
|
|
func _test_deferred_failure_discard_and_restore() -> void:
|
|
var world := FakeWorld.new()
|
|
var service := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var started = service.start_construction(_valid_definition(), Vector3i(2, 2, 0), 0, 1)
|
|
var before_advance: Dictionary = service.create_snapshot()
|
|
var changed_count := [0]
|
|
var completed_count := [0]
|
|
var job_changed_callback := func(_job: ConstructionJob) -> void:
|
|
changed_count[0] += 1
|
|
var facility_completed_callback := func(_instance: Dictionary) -> void:
|
|
completed_count[0] += 1
|
|
service.job_changed.connect(job_changed_callback)
|
|
service.facility_completed.connect(facility_completed_callback)
|
|
world.fail_next_occupy = true
|
|
var advanced: Array[CommandResult] = service.advance_one_minute(201, {}, true)
|
|
_expect(
|
|
advanced.size() == 1 and not advanced[0].is_success(),
|
|
"a deferred completion retains its internal failure result"
|
|
)
|
|
_expect(
|
|
service.has_deferred_notifications()
|
|
and changed_count[0] == 0
|
|
and completed_count[0] == 0,
|
|
"notifications queued before an internal failure remain silent and pending"
|
|
)
|
|
_expect(
|
|
service.create_snapshot().is_empty()
|
|
and not service.restore_snapshot(before_advance),
|
|
"failed deferred advances still block snapshot and restore while pending"
|
|
)
|
|
_expect(service.discard_deferred_notifications(), "a failed deferred batch can be discarded")
|
|
_expect(
|
|
not service.has_deferred_notifications()
|
|
and service.discard_deferred_notifications()
|
|
and service.commit_deferred_notifications()
|
|
and changed_count[0] == 0
|
|
and completed_count[0] == 0,
|
|
"discard is replay-safe and never emits the rejected batch"
|
|
)
|
|
_expect(
|
|
service.restore_snapshot(before_advance)
|
|
and service.create_snapshot() == before_advance,
|
|
"discard permits the caller to restore Construction after failure"
|
|
)
|
|
service.job_changed.disconnect(job_changed_callback)
|
|
service.facility_completed.disconnect(facility_completed_callback)
|
|
|
|
|
|
func _test_ordinary_advance_notifications_unchanged() -> void:
|
|
var service := ConstructionServiceType.new(FakeWorld.new(), FakeResourceState.new())
|
|
var job_id: StringName = service.start_construction(
|
|
_valid_definition(), Vector3i(3, 3, 0), 0, 1
|
|
).details[&"job_id"]
|
|
var order: Array[StringName] = []
|
|
var delivered_jobs: Array[ConstructionJob] = []
|
|
var job_changed_callback := func(job: ConstructionJob) -> void:
|
|
order.append(&"job_changed")
|
|
delivered_jobs.append(job)
|
|
var facility_completed_callback := func(_instance: Dictionary) -> void:
|
|
order.append(&"facility_completed")
|
|
service.job_changed.connect(job_changed_callback)
|
|
service.facility_completed.connect(facility_completed_callback)
|
|
var advanced: Array[CommandResult] = service.advance_one_minute(301, {})
|
|
_expect(
|
|
advanced.size() == 1 and advanced[0].is_success(),
|
|
"the existing two-argument advance call still completes normally"
|
|
)
|
|
_expect(
|
|
order == [&"job_changed", &"facility_completed"]
|
|
and delivered_jobs.size() == 1,
|
|
"ordinary advance still emits both signals immediately in prior order"
|
|
)
|
|
_expect(
|
|
delivered_jobs[0] == service.get_job(job_id),
|
|
"ordinary job_changed retains its historical live-job payload"
|
|
)
|
|
_expect(
|
|
not service.has_deferred_notifications() and not service.create_snapshot().is_empty(),
|
|
"ordinary advance creates no ephemeral pending batch"
|
|
)
|
|
service.job_changed.disconnect(job_changed_callback)
|
|
service.facility_completed.disconnect(facility_completed_callback)
|
|
|
|
|
|
func _test_snapshot_round_trip() -> void:
|
|
var world := FakeWorld.new()
|
|
var state := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var started = service.start_construction(_valid_definition(), Vector3i(4, 4, 0), 0, 8, 700)
|
|
var job_id: StringName = started.details[&"job_id"]
|
|
service.assign_builders(job_id, [&"builder_b", &"builder_a"])
|
|
service.progress_job(job_id, 5_000, 701)
|
|
var snapshot := service.create_snapshot()
|
|
var restored := ConstructionServiceType.new(world, state)
|
|
_expect(restored.restore_snapshot(snapshot), "valid construction snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "snapshot restore preserves all construction state")
|
|
var before_invalid := restored.create_snapshot()
|
|
_expect(not restored.restore_snapshot({&"schema_version": 999}), "unsupported snapshot schema is rejected")
|
|
_expect(restored.create_snapshot() == before_invalid, "rejected restore does not mutate live state")
|
|
|
|
|
|
func _test_snapshot_strictness_and_v1_migration() -> void:
|
|
var world := FakeWorld.new()
|
|
var source := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var job_id: StringName = source.start_construction(_valid_definition(), Vector3i(4, 4, 0), 0, 8, 700).details[&"job_id"]
|
|
source.progress_job(job_id, 5_000, 701)
|
|
var snapshot := source.create_snapshot()
|
|
var restored := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var unknown := snapshot.duplicate(true)
|
|
unknown[&"unknown"] = 1
|
|
_expect(not restored.restore_snapshot(unknown), "current construction 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 construction schema rejects a string version")
|
|
var cost_spoof := snapshot.duplicate(true)
|
|
cost_spoof[&"jobs"][0][&"construction_cost"][&"gold"] = "100"
|
|
_expect(not restored.restore_snapshot(cost_spoof), "current construction schema rejects numeric-string costs")
|
|
var derived_tamper := snapshot.duplicate(true)
|
|
derived_tamper[&"jobs"][0][&"progress"] += 1
|
|
_expect(not restored.restore_snapshot(derived_tamper), "current construction schema rejects derived progress tampering")
|
|
var cell_tamper := snapshot.duplicate(true)
|
|
cell_tamper[&"jobs"][0][&"reserved_cells"] = [[5, 4, 0]]
|
|
_expect(not restored.restore_snapshot(cell_tamper), "construction restore rejects world reservation cross-link tampering")
|
|
var high_water_tamper := snapshot.duplicate(true)
|
|
high_water_tamper[&"next_job_sequence"] = 1
|
|
_expect(not restored.restore_snapshot(high_water_tamper), "construction restore rejects a sequence below the ID high-water mark")
|
|
var before := restored.create_snapshot()
|
|
_expect(before[&"jobs"].is_empty(), "rejected construction snapshots leave the target untouched")
|
|
|
|
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 construction snapshot migrates through its exact old shape")
|
|
_expect(restored.create_snapshot()[&"schema_version"] == source.SNAPSHOT_VERSION, "legacy construction state is re-emitted as strict v2")
|
|
_expect(restored.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 701).is_replay, "v1 migration conservatively prevents replaying prior work")
|
|
|
|
|
|
func _test_definition_type_spoof_and_reentry() -> void:
|
|
var world := FakeWorld.new()
|
|
var resources := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, resources)
|
|
var spoofed := {
|
|
&"id": &"spoofed",
|
|
&"footprint": Vector2i.ONE,
|
|
&"zone": FacilityDefinitionType.MapZone.ANY,
|
|
&"layer": FacilityDefinitionType.ViewLayer.FRONT,
|
|
&"construction_cost": {&"gold": "100", &"materials": 10},
|
|
&"build_minutes": 60,
|
|
}
|
|
_expect(not service.validate_placement(spoofed, Vector3i(1, 1, 0)).can_build, "numeric-string definition costs are rejected instead of normalized")
|
|
resources.reentrant_service = service
|
|
var started = service.start_construction(_valid_definition(), Vector3i(1, 1, 0))
|
|
_expect(started.is_success() and resources.reentrant_attempted, "resource callback attempts a nested construction command")
|
|
_expect(not resources.reentrant_succeeded and service.get_jobs().size() == 1, "reentrant construction mutation cannot create or charge a second job")
|
|
resources.reentrant_service = null
|
|
|
|
|
|
func _test_failed_rollback_quarantines_service() -> void:
|
|
var world := FakeWorld.new()
|
|
var resources := FakeResourceState.new()
|
|
resources.mutate_then_fail_spend = true
|
|
resources.fail_restore = true
|
|
var service := ConstructionServiceType.new(world, resources)
|
|
var failed = service.start_construction(_valid_definition(), Vector3i(1, 1, 0))
|
|
_expect(not failed.is_success() and failed.details[&"rollback_failed"], "failed construction payment reports an unrecoverable rollback")
|
|
_expect(service.is_quarantined(), "unrecoverable construction rollback quarantines the service")
|
|
_expect(not service.start_construction(_valid_definition(), Vector3i(2, 2, 0)).is_success(), "quarantined construction service refuses later mutations")
|
|
|
|
|
|
func _test_real_world_service_integration() -> void:
|
|
var world = WorldServiceType.new()
|
|
world.reset({&"min_x": 0, &"max_x": 5, &"min_y": 0, &"max_y": 5})
|
|
world.set_support(Vector3i(1, 1, 0), 0, true)
|
|
var state := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var definition := _definition(
|
|
&"rotated_room",
|
|
Vector2i(2, 1),
|
|
FacilityDefinitionType.MapZone.ABOVE_GROUND,
|
|
FacilityDefinitionType.ViewLayer.FRONT,
|
|
{&"gold": 100, &"materials": 10}
|
|
)
|
|
var expected_cells: Array[Vector3i] = [Vector3i(1, 1, 0), Vector3i(1, 2, 0)]
|
|
var report = service.validate_placement(definition, Vector3i(1, 1, 0), 90)
|
|
_expect(report.can_build, "real WorldService accepts enum-to-StringName zone matching")
|
|
_expect(report.cells == expected_cells, "90-degree placement swaps rectangular footprint axes")
|
|
var report_270 = service.validate_placement(definition, Vector3i(1, 1, 0), 270)
|
|
_expect(report_270.cells == expected_cells, "270-degree placement swaps rectangular footprint axes")
|
|
|
|
var started = service.start_construction(definition, Vector3i(1, 1, 0), 90, 4)
|
|
_expect(started.is_success(), "real WorldService occupancy signature starts construction")
|
|
var job_id: StringName = started.details[&"job_id"]
|
|
_expect(world.get_entity_cells(job_id) == expected_cells, "real WorldService stores the complete rotated reservation")
|
|
_expect(service.cancel_job(job_id).is_success(), "real WorldService release signature cancels construction")
|
|
_expect(world.get_entity_cells(job_id).is_empty(), "real WorldService release removes the reservation")
|
|
|
|
var restarted = service.start_construction(definition, Vector3i(1, 1, 0), 270, 1)
|
|
var restarted_job_id: StringName = restarted.details[&"job_id"]
|
|
var completed = service.progress_job(restarted_job_id, ConstructionJobType.NORMAL_WORK_RATE, 900)
|
|
_expect(completed.is_success(), "real WorldService completes a construction reservation")
|
|
var instance_id: StringName = completed.details[&"instance_id"]
|
|
_expect(world.get_entity_cells(instance_id) == expected_cells, "completion transfers real world occupancy to the facility")
|
|
_expect(service.demolish_facility(instance_id).is_success(), "real WorldService facility can be demolished")
|
|
_expect(world.get_entity_cells(instance_id).is_empty(), "real WorldService demolition releases facility occupancy")
|
|
|
|
|
|
func _test_underground_requires_excavation() -> void:
|
|
var world = WorldServiceType.new()
|
|
world.reset({&"min_x": 0, &"max_x": 3, &"min_y": -2, &"max_y": 0})
|
|
world.set_support(Vector3i(0, -1, 0), 0, true)
|
|
var state := FakeResourceState.new()
|
|
var service := ConstructionServiceType.new(world, state)
|
|
var definition := _definition(
|
|
&"underground_room",
|
|
Vector2i(2, 1),
|
|
FacilityDefinitionType.MapZone.UNDERGROUND,
|
|
FacilityDefinitionType.ViewLayer.FRONT,
|
|
{&"gold": 100, &"materials": 10}
|
|
)
|
|
var blocked = service.validate_placement(definition, Vector3i(0, -1, 0))
|
|
_expect(not blocked.can_build, "underground facility cannot occupy solid earth")
|
|
_expect(blocked.has_error(PlacementReportType.ERROR_NOT_EXCAVATED), "placement report identifies unexcavated footprint cells")
|
|
_expect(blocked.unexcavated_cells == [Vector3i(0, -1, 0), Vector3i(1, -1, 0)], "every blocked underground footprint cell is reported")
|
|
world.set_excavated(Vector3i(0, -1, 0), true)
|
|
var half_open = service.validate_placement(definition, Vector3i(0, -1, 0))
|
|
_expect(not half_open.can_build and half_open.unexcavated_cells == [Vector3i(1, -1, 0)], "partial excavation is still insufficient")
|
|
world.set_excavated(Vector3i(1, -1, 1), true)
|
|
var ready = service.validate_placement(definition, Vector3i(0, -1, 0))
|
|
_expect(ready.can_build, "fully excavated spatial cells permit underground construction on either layer")
|
|
|
|
|
|
func _test_every_footprint_cell_must_match_zone() -> void:
|
|
var world = WorldServiceType.new()
|
|
world.reset({&"min_x": 0, &"max_x": 2, &"min_y": 0, &"max_y": 2})
|
|
world.set_support(Vector3i(0, 0, 0), 0, true)
|
|
var service := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var definition := _definition(
|
|
&"above_ground_tower",
|
|
Vector2i(1, 2),
|
|
FacilityDefinitionType.MapZone.ABOVE_GROUND,
|
|
FacilityDefinitionType.ViewLayer.FRONT,
|
|
{&"gold": 100, &"materials": 10}
|
|
)
|
|
var crossing = service.validate_placement(definition, Vector3i(0, 0, 0))
|
|
_expect(not crossing.can_build, "a footprint cannot straddle the boundary and above-ground zones")
|
|
_expect(crossing.zone_mismatch_cells == [Vector3i(0, 0, 0)], "zone validation reports the exact mismatching footprint cell")
|
|
var valid = service.validate_placement(definition, Vector3i(0, 1, 0))
|
|
_expect(valid.can_build, "the same full footprint is valid when every cell matches its zone")
|
|
|
|
|
|
func _test_boundary_facility_anchor_rule() -> void:
|
|
var world := FakeWorld.new()
|
|
world.max_x = 8
|
|
var service := ConstructionServiceType.new(world, FakeResourceState.new())
|
|
var reception := _definition(
|
|
&"boundary_reception",
|
|
Vector2i(4, 2),
|
|
FacilityDefinitionType.MapZone.BOUNDARY,
|
|
FacilityDefinitionType.ViewLayer.FRONT,
|
|
{&"gold": 100, &"materials": 10}
|
|
)
|
|
var anchored = service.validate_placement(reception, Vector3i(0, 0, 0))
|
|
_expect(anchored.can_build, "boundary reception anchored at y=0 may extend into above-ground cells")
|
|
var above_anchor = service.validate_placement(reception, Vector3i(0, 1, 0))
|
|
_expect(not above_anchor.can_build and above_anchor.has_error(PlacementReportType.ERROR_ZONE_MISMATCH), "boundary reception rejects an above-ground y=1 anchor")
|
|
var below_anchor = service.validate_placement(reception, Vector3i(0, -1, 0))
|
|
_expect(not below_anchor.can_build and below_anchor.has_error(PlacementReportType.ERROR_ZONE_MISMATCH), "boundary reception rejects an underground y=-1 anchor")
|
|
world.forced_cells = [
|
|
Vector3i(0, -1, 0), Vector3i(1, -1, 0), Vector3i(2, -1, 0), Vector3i(3, -1, 0),
|
|
Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(2, 0, 0), Vector3i(3, 0, 0),
|
|
]
|
|
var underground_spill = service.validate_placement(reception, Vector3i(0, 0, 0))
|
|
_expect(not underground_spill.can_build, "boundary footprint may not spill below y=0 even with a valid anchor")
|
|
_expect(underground_spill.zone_mismatch_cells.has(Vector3i(0, -1, 0)), "boundary spill reports the exact underground cells")
|
|
|
|
|
|
func _definition(
|
|
id: StringName,
|
|
footprint: Vector2i,
|
|
zone: int,
|
|
layer: int,
|
|
cost: Dictionary
|
|
) -> FacilityDefinition:
|
|
var definition := FacilityDefinitionType.new()
|
|
definition.id = id
|
|
definition.display_name = String(id)
|
|
definition.footprint = footprint
|
|
definition.zone = zone
|
|
definition.layer = layer
|
|
definition.construction_cost = cost.duplicate(true)
|
|
return definition
|
|
|
|
|
|
func _valid_definition() -> FacilityDefinition:
|
|
return _definition(
|
|
&"test_room",
|
|
Vector2i(1, 1),
|
|
FacilityDefinitionType.MapZone.ANY,
|
|
FacilityDefinitionType.ViewLayer.FRONT,
|
|
{&"gold": 100, &"materials": 10}
|
|
)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|