264 lines
16 KiB
GDScript
264 lines
16 KiB
GDScript
extends SceneTree
|
|
## Component and read-only rendering checks; never OS or same-demand proof.
|
|
const Presentation := preload("res://scripts/ui/room_presentation.gd")
|
|
const Stream := preload("res://tests/fixtures/p23-03/diagnostic_stream.gd")
|
|
var checks := 0
|
|
var failures := 0
|
|
|
|
class QueueWorld extends RefCounted:
|
|
var revision := 0
|
|
func get_bounds() -> Dictionary:
|
|
return {&"min_x": -1, &"max_x": 2, &"min_y": 0, &"max_y": 1}
|
|
func is_walkable(cell: Vector3i) -> bool:
|
|
return cell.y >= 0 and cell.y <= 1 and cell.x >= -1 and cell.x <= 2
|
|
func get_occupant(_cell: Vector3i) -> StringName:
|
|
return &""
|
|
|
|
class QueueLift extends RefCounted:
|
|
var layer := 0
|
|
var visual_floor := 0.3
|
|
func get_report() -> Dictionary:
|
|
var trips: Array = []
|
|
for i: int in 3:
|
|
trips.append({&"state": PathService.ELEVATOR_REQUEST_QUEUED, &"from": Vector3i(0,0,layer),
|
|
&"actor_id": "guest:q%d" % i, &"enqueued_at": i})
|
|
return {&"shafts": [{&"shaft_id": &"wide", &"stops": [Vector3i(0,0,layer), Vector3i(0,1,layer)],
|
|
&"width": 2, &"capacity": 4, &"boarded": [], &"visual_floor": visual_floor,
|
|
&"operational": true, &"phase": &"PICKUP"}], &"trips": trips}
|
|
|
|
class QueueRuntime extends RefCounted:
|
|
var world_service := QueueWorld.new()
|
|
var elevators := QueueLift.new()
|
|
|
|
func repair28_geometry() -> void:
|
|
var view := WorldPreview.new()
|
|
view.size = Vector2(408,332)
|
|
var fixture := QueueRuntime.new()
|
|
view._runtime = fixture
|
|
root.add_child(view)
|
|
for layer: int in [0,1]:
|
|
view.layer_mode = layer as WorldPreview.LayerMode
|
|
fixture.elevators.layer = layer
|
|
var model: Dictionary = view._observe_queues()
|
|
expect(model[&"shaft_footprint"].has(Vector3i(1,0,layer)), "width-2 interior belongs to shaft")
|
|
expect(model[&"queue_positions"].size() == 1 and model[&"queue_hidden"].size() == 2, "shaft stops outward lane and retains overflow")
|
|
var badge: Dictionary = model[&"landing_badges"][0]
|
|
expect(badge[&"members"].size() == badge[&"visible_count"] + badge[&"overflow"], "no queue member lost")
|
|
model.merge({&"boarded": {}, &"elevator_tracks": {}, &"paused": true})
|
|
var cabins: Array[Dictionary] = view._observe_elevators(model)
|
|
expect(cabins.size() == 1, "active-layer cabin remains visible")
|
|
for at: Vector2 in model[&"queue_positions"].values():
|
|
for cell: Vector3i in model[&"shaft_footprint"]:
|
|
expect(not view.get_cell_rect(cell).has_point(at), "queue anchor outside full footprint")
|
|
expect(not cabins[0][&"rect"].has_point(at), "queue anchor outside empty cabin")
|
|
expect(cabins[0][&"passenger_count"] == 0 and cabins[0][&"passengers"].is_empty(), "empty cabin has no passenger")
|
|
expect(cabins[0][&"operational"] and not cabins[0][&"doors_open"] and cabins[0][&"phase"] == &"PICKUP", "typed moving cabin state")
|
|
for floor: float in [0.0, 0.8]:
|
|
fixture.elevators.visual_floor = floor
|
|
var pose_model: Dictionary = view._observe_queues()
|
|
var cell := Vector3i(0, 0 if floor == 0 else 1, layer)
|
|
var key := "guest/landing"
|
|
var person := {&"id": &"landing", &"cell": cell, &"service": &"", &"target": &""}
|
|
pose_model.merge({&"boarded": {}, &"elevator_tracks": {}, &"paused": true,
|
|
&"actor_tracks": {key: {&"cell": cell, &"from": cell, &"at": 0.0, &"direction": 1.0}},
|
|
&"rooms": {}, &"guests": [person], &"staff": [], &"carried_patients": {},
|
|
&"resting_staff": {}, &"treated_patients": {}})
|
|
pose_model[&"cabins"] = view._observe_elevators(pose_model)
|
|
var row: Dictionary = view._actor_drawing(view.cell_to_canvas(cell), key, false,
|
|
GuestRuntime.State.MOVING, false, false, &"", false, pose_model)
|
|
var region: Dictionary = view._life_actor_region(row, person, pose_model)
|
|
expect(region.has(&"center") and not region[&"transit"], "same-floor and fractional cabin use landing region")
|
|
expect(not (region[&"bounds"] as Rect2).intersects(pose_model[&"cabins"][0][&"rect"]), "landing bounds outside cabin")
|
|
pose_model[&"actor_drawings"] = [row]
|
|
view._fit_life_actors(pose_model)
|
|
expect(pose_model[&"actor_drawings"].size() == 1 and pose_model[&"actor_aggregations"].is_empty(), "landing actor retained")
|
|
expect(not view._life_core(row).intersects(pose_model[&"cabins"][0][&"rect"]), "fitted non-passenger core outside cabin")
|
|
expect(row[&"cell"] == cell and not row[&"riding"] and row[&"state"] == GuestRuntime.State.MOVING, "landing placement preserves actual state")
|
|
fixture.elevators.visual_floor = 0.3
|
|
for operational: bool in [true,false]:
|
|
for phase: StringName in [&"IDLE", &"BOARDING", &"PICKUP"]:
|
|
var shaft: Dictionary = fixture.elevators.get_report()[&"shafts"][0]
|
|
shaft[&"operational"] = operational
|
|
shaft[&"phase"] = phase
|
|
var text := Presentation.elevator_label(shaft)
|
|
expect(text.contains("0/4人") and text.contains(String(phase)), "count and phase visible")
|
|
expect(text.begins_with("運転" if operational else "停止"), "operation visible beyond lamp")
|
|
expect(text.contains(" 開 " if operational and phase != &"PICKUP" else " 閉 "), "doors visible beyond lamp")
|
|
var obstacles: Array[Rect2] = [Rect2(20,0,20,90), Rect2(45,80,20,14)]
|
|
var segments := view._traffic_segments(Rect2(10,85,60,5), Rect2(0,0,80,96), obstacles)
|
|
var area := 0.0
|
|
for segment: Rect2 in segments:
|
|
area += segment.get_area()
|
|
for obstacle: Rect2 in obstacles:
|
|
expect(not segment.intersects(obstacle), "traffic never crosses cabin or badge")
|
|
expect(is_equal_approx(area, 100.0), "traffic preserves all uncovered area")
|
|
var covering: Array[Rect2] = [Rect2(0,80,80,16)]
|
|
var fallback := view._traffic_segments(Rect2(10,85,60,5), Rect2(0,0,80,96), covering)
|
|
expect(fallback.size() == 1 and fallback[0] == Rect2(10,0,60,5), "shaft-column density remains visible")
|
|
view.free()
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run")
|
|
|
|
func repair36_labels() -> void:
|
|
var view := WorldPreview.new()
|
|
var members: Array[String] = ["guest/a", "guest/b", "guest/c"]
|
|
var identities: Array[Dictionary] = []
|
|
for key: String in members:
|
|
identities.append({&"key": key, &"id": key.trim_prefix("guest/"), &"state": 0, &"cell": Vector3i.ZERO, &"mode": &"idle"})
|
|
var queue := {&"at": Vector2(40,40), &"text": "待ち3人(他3人)", &"cell": Vector3i.ZERO, &"members": members.duplicate(), &"visible_count": 0, &"overflow": members.size()}
|
|
var aggregate := {&"at": queue[&"at"], &"text": "+3", &"members": identities.duplicate(true), &"drawn_keys": []}
|
|
var model := {&"cabins": [], &"badges": [], &"landing_badges": [queue], &"actor_aggregations": [aggregate]}
|
|
var blocked := Rect2(100,100,20,20)
|
|
model[&"cabins"] = [{&"rect": blocked, &"badge_at": Vector2(200,200), &"badge": "EV", &"badge_rect": view._readable_badge_rect(Vector2(200,200), "EV")}]
|
|
model[&"shaft_footprint"] = []
|
|
var region: Dictionary = view._life_landing_region({&"bounds": blocked, &"transit": false}, Vector3i.ZERO, model)
|
|
expect(region.get(&"no_lane", false) and region[&"badge_at"] == queue[&"at"], "lane-less stranded actor uses hidden queue anchor")
|
|
view._resolve_observation_labels(model)
|
|
expect(not view._readable_badge_rect(queue[&"at"], queue[&"text"]).intersection(view._readable_badge_rect(aggregate[&"at"], aggregate[&"text"])).has_area(), "hidden queue and stranded aggregate labels do not overlap")
|
|
expect(queue[&"text"] == "待ち3人(他3人)" and aggregate[&"text"] == "+3", "complete counts remain readable")
|
|
expect(queue[&"members"] == members and aggregate[&"members"] == identities and aggregate[&"drawn_keys"].is_empty(), "label placement preserves member identities and visibility")
|
|
expect(queue[&"at"] == queue[&"anchor"] and aggregate[&"anchor"] == queue[&"anchor"], "uncontested queue keeps anchor and stranded anchor remains recorded")
|
|
var solitary := {&"at": Vector2(400,400), &"text": "+1"}
|
|
model[&"actor_aggregations"] = [solitary]
|
|
view._resolve_observation_labels(model)
|
|
expect(solitary[&"at"] == solitary[&"anchor"] and solitary[&"displaced_by"].is_empty(), "uncontested label stays at anchor")
|
|
view.free()
|
|
|
|
func expect(value: bool, message: String) -> void:
|
|
checks += 1
|
|
if not value:
|
|
failures += 1
|
|
push_error("P23 life observation: " + message)
|
|
|
|
func run() -> void:
|
|
var layout := OS.get_environment("P23_LAYOUT")
|
|
var output_directory := OS.get_environment("P23_OUTPUT")
|
|
if layout in ["A", "B"] and not output_directory.is_empty():
|
|
var comparison := preload("res://tests/fixtures/p23-03/layout_comparison.gd").new()
|
|
expect(await comparison.run(self, output_directory, layout), "native layout " + layout)
|
|
print("P23 life observation: %d checks, %d failures" % [checks, failures])
|
|
quit(0 if failures == 0 else 1)
|
|
return
|
|
var clock: Variant = root.get_node("GameClock")
|
|
clock.set_paused(true)
|
|
repair28_geometry()
|
|
repair36_labels()
|
|
# Isolated real typed components test state labels, without claiming gameplay.
|
|
var labels: Dictionary = {}
|
|
for state: int in FacilityInstance.State.values():
|
|
var room := FacilityInstance.new().configure(&"component", &"reception")
|
|
room.state = state as FacilityInstance.State
|
|
var description := Presentation.describe(room)
|
|
expect(description[&"state_name"] == FacilityInstance.State.keys()[state], "enum name preserved")
|
|
var text := Presentation.business_label(description)
|
|
expect(not text.is_empty() and not labels.has(text), "distinct visible business reason")
|
|
labels[text] = true
|
|
for reason: StringName in [&"RAIDED", &"DESTROYED"]:
|
|
var room := FacilityInstance.new().configure(&"component", &"reception", {&"suspension_reason": reason})
|
|
var description := Presentation.describe(room)
|
|
expect(description[&"operation"] == FacilityInstance.OperationState.SUSPENDED, "actual suspension operation")
|
|
var text := Presentation.business_label(description)
|
|
expect(not labels.has(text), "raid and damage have separate visible labels")
|
|
labels[text] = true
|
|
var room := FacilityInstance.new().configure(&"component", &"reception", {&"customer_capacity": 3})
|
|
var service := ServiceInstance.new()
|
|
service.guest_ids.assign([&"b", &"a"])
|
|
room.active_services[&"service"] = service
|
|
room.queue_entries.assign([&"queue-only"])
|
|
var description := Presentation.describe(room)
|
|
expect(description[&"people"] == [&"a", &"b"] and description[&"count"] == 2, "queue entries are not room occupants")
|
|
description[&"people"].clear()
|
|
expect(service.guest_ids.size() == 2, "presentation owns its arrays")
|
|
var runtime: Variant = root.get_node("SimulationRuntime")
|
|
var view := WorldPreview.new()
|
|
view.size = Vector2(1440, 810)
|
|
view.set_runtime(runtime)
|
|
root.add_child(view)
|
|
# R13's four same-cell MOVING guests. Component fixture only, no Runtime edit.
|
|
var crowd: Array = []
|
|
var offsets := [0, 16, -16, 8]
|
|
for i: int in 4:
|
|
crowd.append({&"row": {&"key": "guest/os-p23-%03d" % (12+i),
|
|
&"center": Vector2(108+offsets[i], 118-4*(i%2)),
|
|
&"at": Vector2(84+offsets[i], 103-4*(i%2)), &"scale": 3.0,
|
|
&"mode": &"idle", &"frame": 0, &"flip": false,
|
|
&"state": GuestRuntime.State.MOVING, &"cell": Vector3i(1,1,0)},
|
|
&"bounds": Rect2(80,74,56,88), &"transit": false})
|
|
crowd.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"row"][&"center"].x < b[&"row"][&"center"].x)
|
|
# _life_pack uses the view's cell dimensions for the unchanged feet formula.
|
|
# The x-capacity assertion is independent of the current default world size.
|
|
expect(view._life_pack(crowd, 3).is_empty(), "four cores cannot fit 72px in 56px")
|
|
var saved_size := view.size
|
|
var saved_zoom := view.zoom_level
|
|
var dim: Vector2 = view._cell_dimensions()
|
|
view.zoom_level *= 64.0/dim.x
|
|
var fitted: Array = view._life_pack(crowd, 2)
|
|
expect(fitted.size() == 4, "all four R13 occupants fit at p=2")
|
|
for row: Dictionary in fitted:
|
|
expect(row[&"scale"] == 2.0 and row[&"cell"] == Vector3i(1,1,0), "fitting preserves cell and uses p=2")
|
|
var regroup: Array = crowd.slice(0, 2).duplicate(true)
|
|
for i: int in 2:
|
|
regroup[i][&"row"][&"center"] = Vector2(108, 118+26*i)
|
|
regroup[i][&"bounds"] = Rect2(80,74,20,130)
|
|
var regrouped: Array = view._life_fitted_groups(regroup)
|
|
expect(regrouped.size() == 2, "scale change splits vertical component")
|
|
for group: Array in regrouped:
|
|
expect(group.size() == 1 and group[0][&"row"][&"scale"] == 2.0, "regroup avoids stale p=1 fit")
|
|
var saturated: Array = []
|
|
for i: int in 10:
|
|
var member: Dictionary = crowd[0].duplicate(true)
|
|
member[&"row"][&"key"] = "guest/saturation-%02d" % i
|
|
member[&"row"][&"center"] = Vector2(108,118)
|
|
saturated.append(member)
|
|
var saturated_groups: Array = view._life_fitted_groups(saturated)
|
|
expect(saturated_groups.size() == 1, "saturation keeps one vertical component")
|
|
expect(view._life_pack(saturated_groups[0], 1).is_empty(), "ten cores cannot fit 60px in 56px")
|
|
expect(view._life_pack(saturated_groups[0].slice(0,9), 1).size() == 9, "nine cores fit without losing identities")
|
|
var aggregate_model := {&"guests": [], &"staff": [], &"actor_drawings": [], &"cabins": []}
|
|
var aggregate_rows: Array = []
|
|
for member: Dictionary in saturated:
|
|
var row: Dictionary = member[&"row"].duplicate(true)
|
|
row[&"riding"] = true
|
|
aggregate_rows.append(row)
|
|
aggregate_model[&"guests"].append({&"id": String(row[&"key"]).trim_prefix("guest/"), &"cell": Vector3i(1,1,0)})
|
|
aggregate_model[&"cabins"].append({&"rect": Rect2(76,70,64,96), &"passengers": aggregate_rows})
|
|
view._fit_life_actors(aggregate_model)
|
|
expect(aggregate_model[&"cabins"][0][&"passengers"].size() == 9, "aggregation draws all nine fitting members")
|
|
var aggregate: Dictionary = aggregate_model[&"actor_aggregations"][0]
|
|
expect(aggregate[&"members"].size() == 10 and aggregate[&"drawn_keys"].size() == 9, "aggregation retains every identity")
|
|
expect(aggregate[&"text"] == "+1" and aggregate[&"at"] == Vector2(80,74), "actual aggregation badge position and text")
|
|
view.zoom_level = saved_zoom
|
|
view.size = saved_size
|
|
var before: PackedByteArray = var_to_bytes(runtime.create_snapshot())
|
|
var tracks: PackedByteArray = var_to_bytes([view._actor_tracks, view._elevator_tracks])
|
|
var model := view._build_life_observation()
|
|
expect(var_to_bytes(runtime.create_snapshot()) == before, "builder does not mutate Runtime")
|
|
expect(var_to_bytes([view._actor_tracks, view._elevator_tracks]) == tracks, "builder does not advance visual tracks")
|
|
expect(model.has(&"rooms") and model.has(&"landing_badges") and model.has(&"elevators") and model.has(&"staff") and model.has(&"goods") and model.has(&"badges"), "all six category fields exist")
|
|
expect(model[&"traffic"].size() > 0 and not view.show_grid, "normal view observes real walkable traffic")
|
|
await process_frame
|
|
await process_frame
|
|
var report := view.get_life_observation_report()
|
|
expect(not report.is_empty(), "actual drawing published an observation")
|
|
expect(report.get(&"observation_serial", -1) == report.get(&"overlay_consumed_serial", -2), "LifeOverlay consumed this observation")
|
|
expect(report.get(&"overlay_passes", []) == [&"traffic", &"cabins", &"actors"], "traffic drawn before cabins and actors")
|
|
var indices: Array = report.get(&"overlay_child_indices", [])
|
|
expect(indices.size() == 3 and indices[0] < indices[1] and indices[1] < indices[2], "LifeOverlay above inactive art and below lens")
|
|
# The getter exposes the last actual draw and must not create another model.
|
|
var original: PackedByteArray = var_to_bytes(report)
|
|
if report.has(&"traffic"):
|
|
report[&"traffic"].clear()
|
|
expect(var_to_bytes(view.get_life_observation_report()) == original, "nested report arrays are isolated")
|
|
report.clear()
|
|
expect(var_to_bytes(view.get_life_observation_report()) == original, "public report is a deep copy")
|
|
var output := OS.get_environment("P23_LIFE_DIAGNOSTIC_ROOT")
|
|
if not output.is_empty():
|
|
var stream := Stream.new()
|
|
expect(stream.open(output), "fresh sparse diagnostic stream")
|
|
expect(stream.capture("initial", runtime, view), "whole typed state and draw report capture")
|
|
stream.close()
|
|
view.free()
|
|
print("P23 life observation: %d checks, %d failures" % [checks, failures])
|
|
quit(0 if failures == 0 else 1)
|