P14-02: Add buildable Mana warp (A07)
2x2 FRONT warp at 3000G/30 materials/360 min, 15G/day and 1 Mana upkeep. Every completed warp pair is linked by one path connector, so crossing is a single step; a 3-Mana fare per rider is charged once in the step's tick. Warps close while either end is unpowered, the reserve is empty or below one fare, and blocked arrivals offer no step. Saves validate the derived links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7e6e540837
commit
0cfaec1626
@@ -238,6 +238,8 @@ test-transport:
|
||||
$(GODOT) --headless --path . --script res://tests/transport/test_transport_density_migration.gd
|
||||
$(GODOT) --headless --path . --script res://tests/transport/test_transport_boundaries.gd
|
||||
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_transport.gd
|
||||
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- writer
|
||||
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- reader
|
||||
|
||||
capture-transport:
|
||||
THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd
|
||||
|
||||
@@ -30,6 +30,7 @@ const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12
|
||||
const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11
|
||||
const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd")
|
||||
const ManaReserveCycle = preload("res://scripts/mana/mana_reserve_cycle.gd")
|
||||
const WarpService = preload("res://scripts/transport/warp_service.gd")
|
||||
const CleaningBridge = preload("res://scripts/environment/facility_cleaning_bridge.gd")
|
||||
const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd")
|
||||
const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
|
||||
@@ -1257,6 +1258,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
|
||||
if not _validate_elevator_crosslinks(restored_elevators, restored_path, restored_construction,
|
||||
restored_guests, restored_staff, restored_runtime_domain_minute):
|
||||
return false
|
||||
if not WarpService.connectors_match(restored_path, restored_construction.get_facilities()):
|
||||
return false
|
||||
for facility: FacilityInstance in restored_facilities.get_instances():
|
||||
for staff_id: StringName in facility.active_staff_ids:
|
||||
if not restored_elevators.get_actor_trip(_staff_path_actor_id(staff_id)).is_empty() and not CleaningBridge.is_deployed(restored_staff.get_staff(staff_id), facility, restored_staff):
|
||||
@@ -7844,7 +7847,8 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult:
|
||||
demolished.command_id = command.command_id
|
||||
var transport_ok := false
|
||||
if demolished.is_success():
|
||||
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) and _prune_legacy_stairs_and_movement()
|
||||
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) \
|
||||
and bool(_synchronize_warps().get(&"ok", false)) and _prune_legacy_stairs_and_movement()
|
||||
if not transport_ok or not demolished.is_success() \
|
||||
or not _validate_runtime_path_crosslinks(path_service, guest_service, staff_service, _movement_credits, _runtime_event_minute()) \
|
||||
or facility_infrastructure_projector.has_projection(instance_id) \
|
||||
@@ -8359,6 +8363,11 @@ func _apply_completed_facility_internal(instance: Dictionary, absolute_minute: i
|
||||
var transport := _synchronize_elevators(absolute_minute)
|
||||
if not bool(transport.get(&"ok", false)):
|
||||
return {&"ok": false, &"code": &"ELEVATOR_SYNC_FAILED", &"reason": elevators.last_error, &"details": transport}
|
||||
if definition.id == WarpService.DEFINITION_ID:
|
||||
_register_facility_mana_consumer(instance)
|
||||
var warp_sync := _synchronize_warps()
|
||||
if not bool(warp_sync.get(&"ok", false)):
|
||||
return {&"ok": false, &"code": &"WARP_SYNC_FAILED", &"reason": String(warp_sync.get(&"reason", "")), &"details": warp_sync}
|
||||
if not _prune_legacy_stairs_and_movement():
|
||||
return {&"ok": false, &"code": &"LEGACY_STAIR_PRUNE_FAILED", &"reason": "Stair topology could not be updated."}
|
||||
if not _publish_domain_event(SimulationDomainEvent.new(
|
||||
@@ -8976,7 +8985,8 @@ func _advance_guest_path_travel(
|
||||
actor_id, guest.current_cell, absolute_minute
|
||||
)
|
||||
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
|
||||
if accumulated < required_credit:
|
||||
var guest_warp := _warp_step_connector(path[0], path[1], [])
|
||||
if accumulated < required_credit or not _warp_fare_affordable(guest_warp, 1):
|
||||
_record_blocked_actor_wait(
|
||||
actor_id,
|
||||
guest.guest_id,
|
||||
@@ -9000,6 +9010,7 @@ func _advance_guest_path_travel(
|
||||
guest.recovery_kind = previous_recovery_kind
|
||||
_report_path_integration_failure(actor_id, guest.guest_id, &"guest", previous_cell)
|
||||
return
|
||||
_charge_warp_fare(guest_warp, [actor_id], previous_cell, guest.current_cell)
|
||||
var remaining_credit := accumulated - required_credit
|
||||
_movement_credits[actor_id] = {
|
||||
&"credit": remaining_credit,
|
||||
@@ -9102,7 +9113,9 @@ func _advance_staff_task_travel(
|
||||
actor_id, staff.current_cell, absolute_minute
|
||||
)
|
||||
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
|
||||
if accumulated < required_credit:
|
||||
var staff_warp := _warp_step_connector(path[0], path[1], [&"staff"])
|
||||
var warp_riders := 2 if task.task_type == &"rescue_transport" else 1
|
||||
if accumulated < required_credit or not _warp_fare_affordable(staff_warp, warp_riders):
|
||||
_record_blocked_actor_wait(
|
||||
actor_id,
|
||||
staff.staff_id,
|
||||
@@ -9126,6 +9139,9 @@ func _advance_staff_task_travel(
|
||||
return
|
||||
patient.current_cell = staff.current_cell
|
||||
_record_expected_actor_wait(_staff_path_actor_id(patient.staff_id), patient.current_cell, absolute_minute)
|
||||
_charge_warp_fare(staff_warp, [actor_id, _staff_path_actor_id(patient.staff_id)], previous_cell, staff.current_cell)
|
||||
else:
|
||||
_charge_warp_fare(staff_warp, [actor_id], previous_cell, staff.current_cell)
|
||||
var remaining_credit := accumulated - required_credit
|
||||
_movement_credits[actor_id] = {
|
||||
&"credit": remaining_credit,
|
||||
@@ -10802,6 +10818,11 @@ func _advance_mana(absolute_minute: int) -> void:
|
||||
preload("res://scripts/mana/mana_power_projection.gd").apply(
|
||||
facility_service, mana_network_service, int(_state.get_resource(&"mana")), absolute_minute
|
||||
)
|
||||
var warp_sync := _synchronize_warps()
|
||||
if not bool(warp_sync.get(&"ok", false)):
|
||||
_latch_infrastructure_failure(&"WARP_SYNC_FAILED", absolute_minute,
|
||||
"Warp connectors could not follow the current power state.", warp_sync)
|
||||
return
|
||||
for change: Dictionary in report.get(&"state_changes", []):
|
||||
var consumer_id := StringName(change.get(&"consumer_id", ""))
|
||||
_publish_domain_event(SimulationDomainEvent.new(
|
||||
@@ -12821,7 +12842,8 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute:
|
||||
var required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"])
|
||||
var credit := _movement_credit_after_one_minute(actor_id, staff.current_cell, minute)
|
||||
credit = mini(credit, required_credit + MOVEMENT_CREDIT_SCALE - 1)
|
||||
if credit < required_credit:
|
||||
var commute_warp := _warp_step_connector(path[0], path[1], [&"staff"])
|
||||
if credit < required_credit or not _warp_fare_affordable(commute_warp, 1):
|
||||
_record_expected_actor_wait(actor_id, staff.current_cell, minute)
|
||||
_movement_credits[actor_id] = {&"credit": credit, &"updated_at": minute}
|
||||
return {&"ok": true, &"moved": false}
|
||||
@@ -12830,6 +12852,7 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute:
|
||||
_report_path_integration_failure(actor_id, staff_id, &"staff", previous)
|
||||
return {&"ok": false}
|
||||
staff.current_cell = path[1]
|
||||
_charge_warp_fare(commute_warp, [actor_id], previous, staff.current_cell)
|
||||
_movement_credits[actor_id] = {&"credit": credit - required_credit, &"updated_at": minute}
|
||||
_publish_actor_event(&"STAFF_MOVED", &"staff_commute", [staff_id, actor_id], {
|
||||
&"path_actor_id": actor_id, &"from_cell": previous, &"cell": staff.current_cell,
|
||||
@@ -12913,6 +12936,42 @@ func _execute_set_elevator_operation(command: SimulationCommand) -> CommandResul
|
||||
return CommandResult.succeeded(command.command_id, {&"shaft_id": shaft_id, &"operator_open": open_value})
|
||||
|
||||
|
||||
## A07: warp connectors exist for every completed warp pair and are usable only
|
||||
## while both warps are powered and the reserve can pay one fare.
|
||||
func _synchronize_warps() -> Dictionary:
|
||||
var available := {}
|
||||
var reserve_ok := int(_state.get_resource(&"mana")) > 0
|
||||
for warp: Dictionary in WarpService.warps_from_facilities(construction_service.get_facilities()):
|
||||
var state := mana_network_service.get_consumer_state(warp[&"instance_id"])
|
||||
available[warp[&"instance_id"]] = reserve_ok and state in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED]
|
||||
return WarpService.synchronize(path_service, construction_service.get_facilities(), available,
|
||||
int(_state.get_resource(&"mana")) >= WarpService.MANA_PER_TRIP)
|
||||
|
||||
|
||||
func _warp_step_connector(from: Vector3i, to: Vector3i, access: Array[StringName]) -> StringName:
|
||||
var connector_id := path_service.get_step_connector(from, to, access)
|
||||
return connector_id if WarpService.is_warp_connector(connector_id) else &""
|
||||
|
||||
|
||||
func _warp_fare_affordable(connector_id: StringName, riders: int) -> bool:
|
||||
return connector_id.is_empty() or int(_state.get_resource(&"mana")) >= WarpService.MANA_PER_TRIP * riders
|
||||
|
||||
|
||||
## Charged once, in the same tick as the committed step; affordability was
|
||||
## checked before the step, so the deduction cannot fail here.
|
||||
func _charge_warp_fare(connector_id: StringName, actor_ids: Array, from: Vector3i, to: Vector3i) -> void:
|
||||
if connector_id.is_empty():
|
||||
return
|
||||
var fare := WarpService.MANA_PER_TRIP * actor_ids.size()
|
||||
_state.apply_resource_deltas({&"mana": -fare})
|
||||
var subjects: Array[StringName] = [connector_id]
|
||||
for actor_id: Variant in actor_ids:
|
||||
subjects.append(StringName(actor_id))
|
||||
_publish_actor_event(&"WARP_TRAVELED", &"warp", subjects, {
|
||||
&"connector_id": connector_id, &"from_cell": from, &"cell": to,
|
||||
&"riders": actor_ids.size(), &"mana": fare})
|
||||
|
||||
|
||||
func _synchronize_elevators(minute: int) -> Dictionary:
|
||||
return elevators.synchronize(ElevatorService.configurations_from_facilities(
|
||||
construction_service.get_facilities(), definition_registry), minute)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
id = &"mana_warp"
|
||||
display_name = "マナワープ"
|
||||
description = "建設済みの全ワープと瞬時につながる表層の転移陣。客と接客中の従業員が1回3マナで移動する。"
|
||||
zone = 0
|
||||
layer = 0
|
||||
footprint = Vector2i(2, 2)
|
||||
construction_cost = {&"gold": 3000, &"materials": 30}
|
||||
build_minutes = 360
|
||||
weight_per_cell = 3
|
||||
pass_through = true
|
||||
maintenance_gold = 15
|
||||
maintenance_mana = 1
|
||||
power_priority = 80
|
||||
staff_min = 0
|
||||
staff_max = 0
|
||||
capacity = 4
|
||||
queue_capacity = 0
|
||||
base_service_minutes = 1
|
||||
base_quality = 50
|
||||
base_price = 0
|
||||
tags = Array[StringName]([&"guest_access", &"movement", &"schema_facility_proxy", &"warp"])
|
||||
@@ -41,6 +41,13 @@
|
||||
"origin": "user_reply_in_conversation",
|
||||
"approved_at_jst": "2026-10-19",
|
||||
"reference": "execution/mana-proposal/PROPOSAL.md"
|
||||
},
|
||||
{
|
||||
"id": "A07",
|
||||
"title": "マナワープの初期値(推薦案)",
|
||||
"decision": "2x2・表層。建設3000G・資材30、工期360分、維持費15G/日、維持マナ1/10分。建設済みワープは全て相互接続。利用者は客と接客中の従業員。1人1回3マナを出発時に一回だけ差し引く。残量不足・停電・到着先不可のときは経路から除外し、費用と移動を発生させない。途中保存・再開で費用と移動を二重反映しない。",
|
||||
"origin": "user_reply_in_conversation",
|
||||
"approved_at_jst": "2026-10-20"
|
||||
}
|
||||
],
|
||||
"pending": [
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "all6_confirmed_decisions",
|
||||
"name": "all7_confirmed_decisions",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
@@ -238,7 +238,7 @@
|
||||
"build_atomic_requirements.py": "6d6ea9e4216372dbd0f9fed3f885a4481b21a3f539a26f64cf98a6830af984c5",
|
||||
"build_facility_value_contracts.py": "35027b23ce75ce9e4e0cdfc5132fd2eb771ba9f598d97750d7ecf9ecb257f769",
|
||||
"current_evidence_links.json": "db1837bcc8497201134297e312f4b266a297102285307ec3075d4e23632e26c2",
|
||||
"decisions.json": "7771ef179b73f061363685a218f7b0d300847fc5881ce0c8b108ca62b2076dfe",
|
||||
"decisions.json": "b0e4483e453703d0d5587ba340a2ab7f61cb95b34bd9cd1f1a411b1b8f52c866",
|
||||
"facility_value_contracts.json": "447388ba03a29bfac25f2bcd068665aedbb07ba7641ce86f2db93d44f4c9f326",
|
||||
"facility_value_contracts_validation.json": "bf490a9ca585c48fc318e76c4a069ef244827d828d46e6415399f7710590dd16",
|
||||
"performance_audit.json": "0b630b15a392c3e0366f1362bfe4ddacb968f915f54549f82e4d6fa315384762",
|
||||
@@ -251,7 +251,7 @@
|
||||
"source_atomic_coverage.json": "2ab7b9e6a2536ef248d4f7ccd695bf75a082b47018aca7a5a1b6b706fe1907bc",
|
||||
"validate_atomic_requirements.py": "5b2d0b7d72cb124830fbb0a12d1434e6db698148cc5263d33fd5bd7c2953a1e0",
|
||||
"validate_facility_value_contracts.py": "91640e2a76cd6cc9186f7b85c0bf379b24b6cac0d48962815235ee628e7ae43e",
|
||||
"validate_plan.py": "e31316aff6c814d1e1660a97b91cc0c0016d2b722c4d4092a71859a85bd0e65d",
|
||||
"validate_plan.py": "13d6c95ec17e87215cefc3a94f398b10dd9b63516cd0039bc6e77c3940998d84",
|
||||
"validator_update_checks.json": "f997d0f3de2cea29550840b1d5cb23647d64f81329de46758551e2b6c8fecc27",
|
||||
"work_packages.json": "d2549dae70c8240cef62c5447d570d67789a46f8b944422b5155d326aab13984"
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ def main():
|
||||
pending = {d["id"] for d in decisions["pending"]}
|
||||
check("all10_pending_decision_groups", pending == {f"D{i:02d}" for i in range(1, 11)})
|
||||
check("known_decision_references", all(set(p["decisions"]) <= pending for p in packages))
|
||||
check("all6_confirmed_decisions", {d["id"] for d in decisions["confirmed"]} == {f"A{i:02d}" for i in range(1, 7)})
|
||||
check("all7_confirmed_decisions", {d["id"] for d in decisions["confirmed"]} == {f"A{i:02d}" for i in range(1, 8)})
|
||||
check("confirmed_decision_references_exist", all((PLAN / d["reference"]).is_file() for d in decisions["confirmed"] if "reference" in d))
|
||||
state_counts = dict(collections.Counter(p["state"] for p in packages))
|
||||
check("valid_package_states", all(p["state"] in states for p in packages))
|
||||
|
||||
@@ -47,6 +47,7 @@ const MVP_FACILITY_IDS: Array[StringName] = [
|
||||
&"service_elevator",
|
||||
&"guest_stairs",
|
||||
&"service_stairs",
|
||||
&"mana_warp",
|
||||
&"staff_door",
|
||||
&"guest_corridor",
|
||||
&"service_corridor",
|
||||
|
||||
@@ -59,11 +59,13 @@ const RESCUE_FACILITY_PATHS: Array[String] = ["res://data/facilities/rescue/infi
|
||||
const INFRASTRUCTURE_FACILITY_IDS: Array[StringName] = [
|
||||
&"guest_stairs",
|
||||
&"service_stairs",
|
||||
&"mana_warp",
|
||||
]
|
||||
|
||||
const INFRASTRUCTURE_FACILITY_PATHS: Array[String] = [
|
||||
"res://data/facilities/infrastructure/guest_stairs.tres",
|
||||
"res://data/facilities/infrastructure/service_stairs.tres",
|
||||
"res://data/facilities/infrastructure/mana_warp.tres",
|
||||
]
|
||||
|
||||
## Optional post-MVP expansion definitions. They live below a subdirectory so
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
extends RefCounted
|
||||
|
||||
## A07 Mana warp: every completed warp is linked to every other warp by one
|
||||
## path connector, so crossing is a single instant step. Connectors are derived
|
||||
## from construction state; their operational state follows the reserve and the
|
||||
## warps' own power. Fares are charged by the Runtime when an actor steps.
|
||||
const PathType := preload("res://scripts/world/path_service.gd")
|
||||
const DEFINITION_ID := &"mana_warp"
|
||||
const PREFIX := "warp.connector:"
|
||||
const MANA_PER_TRIP := 3
|
||||
const PATH_COST := 400
|
||||
|
||||
|
||||
static func is_warp_connector(connector_id: StringName) -> bool:
|
||||
return String(connector_id).begins_with(PREFIX)
|
||||
|
||||
|
||||
## Completed warps sorted by instance id: [{instance_id, origin}].
|
||||
static func warps_from_facilities(facilities: Array) -> Array[Dictionary]:
|
||||
var warps: Array[Dictionary] = []
|
||||
for value: Variant in facilities:
|
||||
if not value is Dictionary:
|
||||
continue
|
||||
var facility: Dictionary = value
|
||||
if StringName(str(facility.get(&"definition_id", ""))) != DEFINITION_ID:
|
||||
continue
|
||||
var origin: Variant = facility.get(&"origin")
|
||||
if not origin is Vector3i:
|
||||
continue
|
||||
warps.append({&"instance_id": StringName(str(facility.get(&"instance_id", ""))), &"origin": origin})
|
||||
warps.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return String(a[&"instance_id"]) < String(b[&"instance_id"]))
|
||||
return warps
|
||||
|
||||
|
||||
static func expected_connectors(facilities: Array) -> Array[Dictionary]:
|
||||
var warps := warps_from_facilities(facilities)
|
||||
var result: Array[Dictionary] = []
|
||||
for i: int in warps.size():
|
||||
for j: int in range(i + 1, warps.size()):
|
||||
result.append({
|
||||
&"id": StringName("%s%s|%s" % [PREFIX, warps[i][&"instance_id"], warps[j][&"instance_id"]]),
|
||||
&"from": warps[i][&"origin"],
|
||||
&"to": warps[j][&"origin"],
|
||||
&"endpoints": [warps[i][&"instance_id"], warps[j][&"instance_id"]],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
## Rebuilds warp connectors from construction state and applies availability.
|
||||
## available: instance_id -> bool. Atomic: on failure the path is restored. The
|
||||
## rollback snapshot is taken only when a change is actually needed.
|
||||
static func synchronize(path: PathService, facilities: Array, available: Dictionary, fare_affordable: bool) -> Dictionary:
|
||||
var expected := expected_connectors(facilities)
|
||||
var expected_ids: Dictionary = {}
|
||||
for connector: Dictionary in expected:
|
||||
expected_ids[connector[&"id"]] = true
|
||||
var before: Variant = null
|
||||
for connector_id: StringName in path.get_connector_ids_with_prefix(PREFIX):
|
||||
if expected_ids.has(connector_id):
|
||||
continue
|
||||
if before == null:
|
||||
before = path.create_snapshot()
|
||||
if not path.remove_connector(connector_id):
|
||||
path.restore_snapshot(before)
|
||||
return {&"ok": false, &"code": &"WARP_CONNECTOR_REMOVE_FAILED", &"reason": path.last_error}
|
||||
for connector: Dictionary in expected:
|
||||
var connector_id: StringName = connector[&"id"]
|
||||
var existing := path.get_connector(connector_id)
|
||||
var endpoints: Array = connector[&"endpoints"]
|
||||
var usable := fare_affordable and bool(available.get(endpoints[0], false)) and bool(available.get(endpoints[1], false))
|
||||
var state := PathType.CONNECTOR_OPERATIONAL if usable else PathType.CONNECTOR_OUTAGE
|
||||
if not existing.is_empty() and existing[&"from"] == connector[&"from"] and existing[&"to"] == connector[&"to"] \
|
||||
and existing[&"operational_state"] == state:
|
||||
continue
|
||||
if before == null:
|
||||
before = path.create_snapshot()
|
||||
if existing.is_empty() or existing[&"from"] != connector[&"from"] or existing[&"to"] != connector[&"to"]:
|
||||
if not existing.is_empty():
|
||||
path.remove_connector(connector_id)
|
||||
if not path.configure_connector(connector_id, connector[&"from"], connector[&"to"], PATH_COST, true, &"", true,
|
||||
{&"movement_type": PathType.MOVEMENT_CONNECTOR}):
|
||||
path.restore_snapshot(before)
|
||||
return {&"ok": false, &"code": &"WARP_CONNECTOR_CONFIGURE_FAILED", &"reason": path.last_error}
|
||||
if not path.set_connector_operational_state(connector_id, state):
|
||||
path.restore_snapshot(before)
|
||||
return {&"ok": false, &"code": &"WARP_CONNECTOR_STATE_FAILED", &"reason": path.last_error}
|
||||
return {&"ok": true, &"connector_count": expected.size()}
|
||||
|
||||
|
||||
## Saved paths must hold exactly the derived warp connectors.
|
||||
static func connectors_match(path: PathService, facilities: Array) -> bool:
|
||||
var expected := expected_connectors(facilities)
|
||||
var ids := path.get_connector_ids_with_prefix(PREFIX)
|
||||
if ids.size() != expected.size():
|
||||
return false
|
||||
for connector: Dictionary in expected:
|
||||
var existing := path.get_connector(connector[&"id"])
|
||||
if existing.is_empty() or existing[&"from"] != connector[&"from"] or existing[&"to"] != connector[&"to"] \
|
||||
or int(existing[&"cost"]) != PATH_COST or StringName(existing[&"access_id"]) != &"" \
|
||||
or existing[&"movement_type"] != PathType.MOVEMENT_CONNECTOR:
|
||||
return false
|
||||
return true
|
||||
@@ -89,6 +89,16 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
table(canvas, at + Vector2(-2, -7)*p, p)
|
||||
&"chapel":
|
||||
chapel_furnishings(canvas, rect, p)
|
||||
&"mana_warp":
|
||||
# Original code-drawn transfer circle and rising motes; existing palette.
|
||||
var ring := Vector2(mid, floor_y - 3.0 * p)
|
||||
canvas.draw_circle(ring, 9.0 * p, INK)
|
||||
canvas.draw_circle(ring, 8.0 * p, TEAL)
|
||||
canvas.draw_circle(ring, 6.0 * p, Color("c1e0d4"))
|
||||
canvas.draw_circle(ring, 4.0 * p, TEAL)
|
||||
for i in 5:
|
||||
var x := mid + (i - 2) * 4.0 * p
|
||||
canvas.draw_rect(Rect2(x, floor_y - (9 + (i % 3) * 5) * p, p, 2.0 * p), CREAM)
|
||||
&"public_bath":
|
||||
canvas.draw_rect(Rect2(rect.position.x+4*p, floor_y-7*p, w-8*p, 7*p), CREAM)
|
||||
canvas.draw_rect(Rect2(rect.position.x+6*p, floor_y-6*p, w-12*p, 4*p), Color("72b6b8"))
|
||||
|
||||
@@ -35,6 +35,7 @@ const KIND_GUEST_ELEVATOR: StringName = &"guest_elevator"
|
||||
const KIND_SERVICE_ELEVATOR: StringName = &"service_elevator"
|
||||
const KIND_GUEST_STAIRS: StringName = &"guest_stairs"
|
||||
const KIND_SERVICE_STAIRS: StringName = &"service_stairs"
|
||||
const KIND_MANA_WARP: StringName = &"mana_warp"
|
||||
const SUPPORTED_KINDS: Array[StringName] = [
|
||||
KIND_GUEST_CORRIDOR,
|
||||
KIND_SERVICE_CORRIDOR,
|
||||
@@ -43,6 +44,7 @@ const SUPPORTED_KINDS: Array[StringName] = [
|
||||
KIND_SERVICE_ELEVATOR,
|
||||
KIND_GUEST_STAIRS,
|
||||
KIND_SERVICE_STAIRS,
|
||||
KIND_MANA_WARP,
|
||||
]
|
||||
const KNOWN_KINDS: Array[StringName] = [
|
||||
KIND_GUEST_CORRIDOR,
|
||||
@@ -52,6 +54,7 @@ const KNOWN_KINDS: Array[StringName] = [
|
||||
KIND_SERVICE_ELEVATOR,
|
||||
KIND_GUEST_STAIRS,
|
||||
KIND_SERVICE_STAIRS,
|
||||
KIND_MANA_WARP,
|
||||
]
|
||||
|
||||
const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = []
|
||||
@@ -692,6 +695,17 @@ static func _kind_schema(kind: StringName) -> Dictionary:
|
||||
&"guest_access", &"movement", &"schema_facility_proxy", &"vertical_connector",
|
||||
]),
|
||||
}
|
||||
KIND_MANA_WARP:
|
||||
# Walkable FRONT cells; WarpService links every warp pair (A07).
|
||||
return {
|
||||
&"layer": PathType.FRONT,
|
||||
&"footprint": Vector2i(2, 2),
|
||||
&"pass_through": true,
|
||||
&"staff_only": false,
|
||||
&"tags": _sorted_name_array([
|
||||
&"guest_access", &"movement", &"schema_facility_proxy", &"warp",
|
||||
]),
|
||||
}
|
||||
KIND_SERVICE_ELEVATOR:
|
||||
return {
|
||||
&"layer": PathType.BACK,
|
||||
|
||||
@@ -1420,6 +1420,15 @@ func has_connector(connector_id: StringName) -> bool:
|
||||
return _connectors.has(connector_id)
|
||||
|
||||
|
||||
func get_connector_ids_with_prefix(prefix: String) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for connector_id: Variant in _connectors:
|
||||
if String(connector_id).begins_with(prefix):
|
||||
ids.append(StringName(connector_id))
|
||||
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return ids
|
||||
|
||||
|
||||
func get_connector(connector_id: StringName) -> Dictionary:
|
||||
if not _connectors.has(connector_id):
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
extends SceneTree
|
||||
|
||||
## A07 Mana warp through ordinary construction, natural guests and the reserve.
|
||||
## "-- writer" (default) runs the mechanics and saves mid-operation; "-- reader"
|
||||
## restores that save in a new process and must reproduce the writer exactly.
|
||||
const Warp = preload("res://scripts/transport/warp_service.gd")
|
||||
const ManaOutage = preload("res://tests/helpers/mana_outage.gd")
|
||||
const SAVE_PATH := "user://saves/p14_02_warp_new_pid"
|
||||
const EXPECTED_PATH := "user://saves/p14_02_warp_new_pid_expected.json"
|
||||
const CONTINUE_MINUTES := 120
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _serial := 0
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var mode := "reader" if "reader" in OS.get_cmdline_user_args() else "writer"
|
||||
print("Warp runtime %s (pid %d)" % [mode, OS.get_process_id()])
|
||||
_clock = load("res://autoload/game_clock.gd").new()
|
||||
_state = load("res://autoload/game_state.gd").new()
|
||||
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_runtime.initialize(_clock, _state, 8808)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
_clock.set_paused(true)
|
||||
_expect(bool(_runtime.enable_production_operations().get(&"ok", false)), "natural operations start at Day 1 06:00")
|
||||
if mode == "writer":
|
||||
_write()
|
||||
else:
|
||||
_read()
|
||||
_runtime.free(); _clock.free(); _state.free()
|
||||
print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _cmd(kind: StringName, payload: Dictionary) -> CommandResult:
|
||||
_serial += 1
|
||||
return _runtime.submit_command(kind, payload, &"player", &"", 0, _clock.absolute_minute, StringName("warp.test.%d" % _serial))
|
||||
|
||||
|
||||
func _build(id: StringName, origin: Vector3i) -> bool:
|
||||
var result := _cmd(&"build_facility", {&"definition_id": id, &"origin": origin})
|
||||
_expect(result.is_success(), "build %s at %s %s" % [id, origin, result.message])
|
||||
return result.is_success()
|
||||
|
||||
|
||||
func _warp_connectors() -> Array[StringName]:
|
||||
return _runtime.path_service.get_connector_ids_with_prefix(Warp.PREFIX)
|
||||
|
||||
|
||||
func _warp_events() -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
||||
if event.event_type == &"WARP_TRAVELED":
|
||||
rows.append({&"minute": event.absolute_minute, &"mana": int(event.payload[&"mana"])})
|
||||
return rows
|
||||
|
||||
|
||||
func _write() -> void:
|
||||
_expect(_cmd(&"change_resources", {&"deltas": {&"gold": 6000, &"materials": 40}}).is_success(), "fund two warps for the fixture")
|
||||
var gold_before: int = _state.get_resource(&"gold")
|
||||
if not _build(&"mana_warp", Vector3i(0, 0, 0)) or not _build(&"reception", Vector3i(2, 0, 0)):
|
||||
return
|
||||
_expect(gold_before - int(_state.get_resource(&"gold")) >= 3000, "warp construction charges its approved 3000G")
|
||||
_clock.advance_minutes(359)
|
||||
_expect(_warp_connectors().is_empty(), "a single warp has no destination")
|
||||
_clock.advance_minutes(1)
|
||||
if not _build(&"mana_warp", Vector3i(0, 2, 0)) or not _build(&"standard_room", Vector3i(2, 2, 0)):
|
||||
return
|
||||
_clock.advance_minutes(360)
|
||||
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty(), "new warp waits for its first power evaluation")
|
||||
_clock.advance_minutes(10 - int(_clock.absolute_minute) % 10)
|
||||
var ids := _warp_connectors()
|
||||
_expect(ids.size() == 1, "two completed warps are linked by one connector")
|
||||
if ids.size() != 1:
|
||||
return
|
||||
var connector: Dictionary = _runtime.path_service.get_connector(ids[0])
|
||||
_expect(connector[&"from"] == Vector3i(0, 0, 0) and connector[&"to"] == Vector3i(0, 2, 0) and connector[&"operational_state"] == PathService.CONNECTOR_OPERATIONAL, "connector joins both warp origins and is operational")
|
||||
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)) == [Vector3i(0, 0, 0), Vector3i(0, 2, 0)], "path crosses floors in one instant step")
|
||||
_expect(_runtime.mana_network_service.get_total_consumer_demand() >= 2, "each warp registers 1 Mana upkeep")
|
||||
|
||||
# Outage and an unaffordable fare both remove the warp from routing.
|
||||
var before_outage: Dictionary = _runtime.create_snapshot()
|
||||
ManaOutage.hold(_runtime, _state)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_runtime.path_service.get_connector(ids[0])[&"operational_state"] == PathService.CONNECTOR_OUTAGE, "reserve outage stops the warp")
|
||||
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty(), "stopped warp offers no route")
|
||||
ManaOutage.release(_runtime, _state, Warp.MANA_PER_TRIP - 1)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_runtime.path_service.get_connector(ids[0])[&"operational_state"] == PathService.CONNECTOR_OUTAGE, "reserve below one fare keeps the warp closed")
|
||||
_expect(_runtime.restore_snapshot(before_outage), "rewind outage drill")
|
||||
_clock.advance_minutes(1)
|
||||
var mana_blocked: int = _state.get_resource(&"mana")
|
||||
_runtime.path_service.set_cell_temporary_block(Vector3i(0, 2, 0), true)
|
||||
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty()
|
||||
and _runtime.call(&"_warp_step_connector", Vector3i(0, 0, 0), Vector3i(0, 2, 0), [] as Array[StringName]) == &"", "blocked arrival offers no warp step and no fare")
|
||||
_expect(_state.get_resource(&"mana") == mana_blocked, "blocked arrival charges nothing")
|
||||
_expect(_runtime.restore_snapshot(before_outage), "rewind arrival drill")
|
||||
|
||||
# Natural guests use the warp; each crossing charges exactly 3 Mana once.
|
||||
_expect(_cmd(&"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "hire receptionist")
|
||||
var reception: FacilityInstance = null
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"reception": reception = facility
|
||||
_expect(_cmd(&"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": reception.instance_id, &"template_id": &"DAY"}).is_success(), "assign receptionist")
|
||||
var saved := false
|
||||
var crossings := 0
|
||||
for minute: int in range(1200):
|
||||
var mana_before: int = _state.get_resource(&"mana")
|
||||
var events_before := _warp_events().size()
|
||||
_clock.advance_minutes(1)
|
||||
var new_events := _warp_events().size() - events_before
|
||||
if new_events > 0:
|
||||
crossings += new_events
|
||||
var boundary: bool = int(_clock.absolute_minute) % 10 == 0
|
||||
if not boundary:
|
||||
_expect(mana_before - int(_state.get_resource(&"mana")) == Warp.MANA_PER_TRIP * new_events, "warp crossing charges 3 Mana per rider")
|
||||
if not saved:
|
||||
saved = true
|
||||
var snapshot: Dictionary = _runtime.create_snapshot()
|
||||
_expect(SaveService.save_snapshot(SAVE_PATH, snapshot)[&"ok"], "save right after a paid crossing")
|
||||
_clock.advance_minutes(CONTINUE_MINUTES)
|
||||
var file := FileAccess.open(EXPECTED_PATH, FileAccess.WRITE)
|
||||
file.store_string(JSON.stringify({
|
||||
"writer_pid": OS.get_process_id(),
|
||||
"saved_events": events_before + new_events,
|
||||
"mana": _state.get_resource(&"mana"),
|
||||
"digest": SimulationDigest.compute(_runtime.create_snapshot()),
|
||||
"events": JSON.stringify(_warp_events()),
|
||||
}))
|
||||
file.close()
|
||||
break
|
||||
if crossings == 0:
|
||||
for guest: GuestRuntime in _runtime.guest_service.get_guests():
|
||||
print("GUEST ", guest.guest_id, " ", guest.state, " ", guest.current_cell)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
print("FAC ", facility.definition_id, " ", facility.state, " ", facility.active_staff_ids)
|
||||
_expect(crossings > 0, "a natural guest crosses by warp")
|
||||
_expect(saved, "mid-operation save written")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "warp state round trips")
|
||||
|
||||
# Demolishing one end removes the link.
|
||||
var upper: StringName = &""
|
||||
for facility: Dictionary in _runtime.construction_service.get_facilities():
|
||||
if facility[&"definition_id"] == &"mana_warp" and facility[&"origin"] == Vector3i(0, 2, 0):
|
||||
upper = facility[&"instance_id"]
|
||||
var occupied := false
|
||||
for guest: GuestRuntime in _runtime.guest_service.get_guests():
|
||||
if guest.current_cell.x <= 1 and guest.current_cell.y >= 2: occupied = true
|
||||
if not occupied:
|
||||
_expect(_cmd(&"demolish_facility", {&"instance_id": upper}).is_success(), "demolish upper warp")
|
||||
_expect(_warp_connectors().is_empty(), "demolition removes the warp link")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "post-demolition state restores")
|
||||
|
||||
|
||||
func _read() -> void:
|
||||
var expected: Variant = JSON.parse_string(FileAccess.get_file_as_string(EXPECTED_PATH))
|
||||
_expect(expected is Dictionary, "reader finds writer record")
|
||||
if not expected is Dictionary:
|
||||
return
|
||||
_expect(int(expected["writer_pid"]) != OS.get_process_id(), "reader runs in a different process")
|
||||
var loaded := SaveService.load_snapshot(SAVE_PATH)
|
||||
_expect(loaded[&"ok"] and _runtime.restore_snapshot(loaded[&"snapshot"]), "reader restores mid-operation save")
|
||||
_expect(_warp_events().size() == int(expected["saved_events"]), "restored crossing history is not replayed")
|
||||
_clock.advance_minutes(CONTINUE_MINUTES)
|
||||
_expect(_state.get_resource(&"mana") == int(expected["mana"]), "continued Mana matches: no double fare")
|
||||
_expect(JSON.stringify(_warp_events()) == String(expected["events"]), "continued crossings match the writer")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == String(expected["digest"]), "whole continued state digest matches")
|
||||
SaveService.delete_save(SAVE_PATH)
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(EXPECTED_PATH))
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -131,7 +131,8 @@ func _run() -> void:
|
||||
)
|
||||
await _test_defense_notice()
|
||||
var palette := _main.find_child("BuildPalette", true, false) as GridContainer
|
||||
_expect(palette.get_child_count() == 23, "all23 facilities including the cleaning room appear in the build palette")
|
||||
_expect(palette.get_child_count() == 24, "all24 facilities including the cleaning room and A07 warp appear in the build palette")
|
||||
_expect((_main.get("build_buttons") as Dictionary).has(&"mana_warp"), "Mana warp is buildable from the ordinary palette")
|
||||
var iconized_build_buttons := 0
|
||||
for build_button_value: Variant in (_main.get("build_buttons") as Dictionary).values():
|
||||
if (build_button_value as Button).icon != null:
|
||||
|
||||
Reference in New Issue
Block a user