diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index 6539ba6..079c8a4 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -3702,6 +3702,17 @@ "scope": "normal placement; native and OS pending" } } + ,{ + "test": "tests/ui/test_p23_life_observation.gd", + "template": "P23 life observation: checks, failures", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "b9afa66ec73d3a5cadf86f9a71f0d13f5b085c3cc9a534dfe29b2bed0e2e76a0", + "authority": { + "path": "docs/spec/acceptance/P23-03.json", + "scope": "component and read-only observation only; native unexecuted; whole unit blocked" + } + } ], "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting", "roster_count_is_not_hardcoded": true, diff --git a/docs/plans/original-completion-2026-10-02/execution/p23-03/API_AND_STATE_MAP.md b/docs/plans/original-completion-2026-10-02/execution/p23-03/API_AND_STATE_MAP.md new file mode 100644 index 0000000..3a09209 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p23-03/API_AND_STATE_MAP.md @@ -0,0 +1,41 @@ +# P23-03 Phase 0 — API and state map + +Base: `860136f7099f5e3aee99da5798a627a77f2d8eea`. Static inspection only; no engine execution. + +## Exact source references + +Line numbers below refer to the base; SHA256 covers the complete file. + +| Source | Lines / API | SHA256 | +| --- | --- | --- | +| autoload/simulation_runtime.gd | 203 initialize(clock,state,seed); 225 seed assignment; 466 submit_command; 508 get_rng_stream; 511 create_snapshot; 1483 enable_production_operations; 1780 default demand configuration; 2831 arrival boundary; 2949 apply arrivals; 4137 queue_manual_checkpoint; 4141 save_to_path; 4169 load_from_path; 5066 seed streams; 6103 spawn_guest executor; 7629 build_facility executor; 10859 staff management report; 12464 supply management report | 4696df4a77feb465a5a3ba8b806569f7bcbe1d03c88289acfb78a3928f307d67 | +| autoload/game_state.gd | 20 reset | cf0e884910220e54c56b5875983e53de439af9d893be1e1b65c2121d2a30722f | +| scripts/core/simulation_kernel.gd | 91 reset; 104 advance_minutes; 122 advance_one_minute; 175 set_paused | 341c0c878c4894adbac628d0db6362ff69fc3347bdd9e61e50936e15dc2d1edb | +| scripts/facility/facility_service.gd | 109 get_instances; 656 create_snapshot | ceb4a3e9224587423f273cdb2b56e6843ab176ac1fc4fe79dd498b5ed5c33b77 | +| scripts/guest/guest_service.gd | 170 get_guests; 982 create_snapshot | 56880410e9a00816f9a95d30821982955532de8dd976980934e12016a9f2b21e | +| scripts/staff/staff_service.gd | 166 get_staff_members; 682 get_tasks; 1064 create_snapshot | 355ad5c3c9f85dc4d4ffc3bc4293e36f81078c403d1662512a735b2c8d815fb3 | +| scripts/transport/elevator_service.gd | 345 get_report; 362 create_snapshot | cea09dd9204551c26b31b30cd3aa847cc77e3b75fdac31662b9694a95fb3580c | +| scripts/world/path_service.gd | 545 get_cell_traffic | 447135729a1106639a6eee6754af3195c8a8b458ca26980ae91fa39d80dcfbd4 | +| scripts/supply/supply_logistics_bridge.gd | 122 get_report | b9e394af788595d2744b71f1907c8e7c3d762d38ff49992b1ee74e63b9639e80 | +| scripts/facility/facility_instance.gd | 8 OperationState; 10 State; 30 state and subsequent live fields | 0c6aa493f3299611e05dd26aa429dda8cf7b5c479e4d09632036fb862f79a324 | +| tests/transport/test_runtime_transport_comparison.gd | 91 existing fixed-demand setup; 109 real paid build command | ba577397fa8db5a6cf92cfd398dd7a5ece49e6b59856f14772b67b51ddc0bde9 | + +## Deterministic demand capability + +An existing deterministic input path is available without Runtime edits: fresh clock/state, `initialize(clock,state,seed)`, then the same scheduled `submit_command(&"spawn_guest", payload, ..., execute_at, idempotency_key)` sequence. The executor validates registered definitions and real walkable cells, creates wallets and GuestRuntime actors, and verifies Path actor projection and movement credit. Explicit IDs must avoid the reserved `guest-auto-` namespace. Inputs and processed results must both be recorded; a seed/hash alone does not prove identical arrivals. + +The existing transport comparison exercises public spawning, real paid construction and Runtime-owned movement. It is fixed-demand experimentation, not proof of natural autonomous demand or all six observation categories. Its numeric assertions are not adopted as new acceptance thresholds. Automatic production arrivals are a separate path (`enable_production_operations`): population, weather and terminal-state gates mean the same seed alone cannot guarantee identical accepted arrivals. Layouts must use identical production settings and compare actual arrival inputs/results. + +`build_facility` resolves the registered definition, runs `_build_facility_precheck`, then calls `construction_service.start_construction` with `definition.build_minutes`. Construction cost, time and capacities must come from definitions. B may add only the predeclared existing corridor/EV through this command. + +## Clock and observation + +`reset` defines initial time. Paused manual `advance_minutes` is explicitly supported; abort/pause boundaries must be checked. Commands run before minute increment, then time boundaries, mana, construction, logistics, facility completion, vitals, incidents, queues, AI and publication. + +Facility/guest/staff getters expose typed objects; presentation must copy values without mutating them. Elevator reports contain shafts/trips. Path traffic provides actual density/capacity/block fields. Logistics reports contain requests/deliveries, source/destination IDs, carrier, requirements, weight, state/reason and task ID. They do not contain warehouse stock: requested quantities must never be labelled stock. + +## Formal save/load and evidence + +Use `queue_manual_checkpoint` or `save_to_path`, then `load_from_path` under a distinct real PID. The load API pauses the clock and invokes SaveService package validation before restore. `restore_snapshot` alone is not formal resume. Preserve all package members, source/spec/engine/settings pins, whole typed sparse snapshots and actual CUA/process/cleanup evidence. + +Original references read: BASELINE.txt lines 10,13,16,25,37,43,69,83,106,216; IMPLEMENTATION_PLAN.md line 1800; immutable P23-03 spec; recorded direction 83e836e. All seven conditions remain mandatory, with no partial credit. Root native/OS/resume/full-regression evidence is missing. diff --git a/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md b/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md new file mode 100644 index 0000000..d9c13e9 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p23-03/WORKER_STATUS.md @@ -0,0 +1,94 @@ +# P23-03 worker status: submitted static work, implementation incomplete + +Base commit: `860136f7099f5e3aee99da5798a627a77f2d8eea`. +This submission preserves the previous dirty work and adds genuine static +implementation. It is **not acceptance-ready**. No external Runtime capability +gap or out-of-scope defect has been established. Remaining implementation is +listed separately from pending Root evidence; no acceptance credit is claimed. + +## Inspection and changes in this repair + +- Re-read the recorded direction, Phase 0 map, previous status and renderer diff. + The single frame builder supplies drawing and the deep-copy public report. + No renderer redesign or additional renderer was introduced. Existing renderer + and component test bytes were retained. +- Added `tests/fixtures/p23-03/layout_comparison.gd`. Both layouts initialize the + public Runtime with seed 806042, reset budget and clock, identical production + defaults and the same recorded 48-person arrival script. Commands build the + existing elevator and corridors; B adds only the declared second elevator. + Preparation time derives from registered definition build times. +- Predeclared metric: sum of destination-arrival minutes for the entire cohort. + The checker rejects incomplete cohorts, changed actual arrival payloads, + additional layout changes, changed initial state, and non-strict improvement. + No observed improvement is claimed: the fixture has not run. +- Extended diagnostic_stream with hash-addressed typed event/input records. + The fixture records actual command results, domain movement events and + per-minute guest/path/EV/facility projections, including service history, + queues and density. Whole aggregate state/report pairs are sparse (30-minute + intervals); the existing P08 typed encoder is reused without truncation. +- Extended independent checking of room service/rescue occupants and business + enum fields, queue allocation/overflow, shared transforms, traffic, logistics + report selection/order and cabin geometry. Added Rect2 and vector-key decode. + The checker still refuses whole-unit acceptance. + +## Exact remaining implementation (not external blockers) + +1. Finish rest destination selection from typed path costs and event/defense + locks. Current room check validates eligibility and reported rest references; + it does not independently prove which rest destination Runtime selected. +2. Finish every actor/passenger mode and geometry, including cleaning, + rest/rescue/service slots, visual track history, badge text and screenshot + rect/pixel provenance. Cabin endpoints and rectangles are checked, but + unpaused prior-frame interpolation origin is not independently established. +3. Complete A/B definition/source/production pins and command/event/path + cross-validation, invoke the fixture from the Root collector, and add + representative real operational scenarios for all six categories and BACK. + The added experiment covers the fixed-demand FRONT transport comparison; + it does not establish actual service purchasing or all-six-category coverage. +4. Replace run_p23_life_observation.py readiness stub with the actual Root + collector using P08 owned-process/profile/source/engine/ordinary-audio and + CUA request/ACK helpers. It remains unchanged; it launches nothing. + Formal save, distinct-PID normal resume, both-layout FRONT/BACK/pan/zoom, + complete CUA receipts, all-run cleanup/protection and failure history remain + to be wired and independently checked. +5. Add missing meaningful negatives, especially normal OS/distinct-PID resume, + visual provenance and the remaining rest/motion branches. Existing 12 local + tests are not a complete seven-condition test matrix. + +Steps (2) and (3) of the repair direction are therefore still incomplete; +step (4) is not implemented. This is a partial static submission, not a claim +that the requested repair is finished. Do not begin acceptance on an assumption +that all worker deliverables are complete. + +## Checks actually run in this repair + +- `python3 -B tools/test_p23_life_observation_evidence.py`: 12 test methods, + zero failures. Synthetic Python examples validate rejection logic only. +- AST syntax: all three P23 Python tools parsed. +- Marker JSON: old rows and metadata unchanged; exactly one appended row; + its SHA matches the unchanged GDScript component test. +- `git diff --check`: passed. +- No Godot parsing/runtime, native, OS, Runner or full regression execution. +- No git writes, subagents, integration, new assets or production changes. + suite-policy.json was not changed; no new suite was registered. + +## Source SHA256 at local checks + +| File | SHA256 | +| --- | --- | +| scenes/main/world_preview.gd | add770377c190a5ccdde95379539de9753f7dabc703c1d58452ba590c6eda2f0 | +| scripts/ui/room_presentation.gd | 55340b7b623532e861be1d21225635fd9169e2e40928597c1cd0458b99524dd9 | +| tests/ui/test_p23_life_observation.gd | b9afa66ec73d3a5cadf86f9a71f0d13f5b085c3cc9a534dfe29b2bed0e2e76a0 | +| tests/fixtures/p23-03/diagnostic_stream.gd | ff178a094fc310b0640d923a15058c6f6fcf859551c14f6428a5709dcd24260e | +| tests/fixtures/p23-03/layout_comparison.gd | ddf00268b8e32e3c5c078ebb57ada321382ecc7c5fbd87efad7e9dfbe768df6b | +| tools/run_p23_life_observation.py | 53c2f0638d4a861cf4fcb664504c25df33ae5478207867654f1c8cdf58fa23c7 | +| tools/check_p23_life_observation_evidence.py | 318b253a08248a753b88902e382edc5f01cbce6f1ed8092359698b69d3bb0503 | +| tools/test_p23_life_observation_evidence.py | 0f20d3de20b44f46f8738c0d022fcc1179656014e85fe31754f00b87ec2c288a | + +## Root evidence: pending + +After genuine completion and freezing: both-layout native and actual normal OS +observation, full CUA trace, formal save and distinct-PID resume, mandatory exact +version full regression and independent Opus review. These absent Root results +are **pending**, not blocked. The fixed 30-package / 88-unit denominator and all +P08/P22/P25/Windows/held-gesture/performance exclusions remain unchanged. diff --git a/scenes/main/world_preview.gd b/scenes/main/world_preview.gd index b6a87a2..15b2366 100644 --- a/scenes/main/world_preview.gd +++ b/scenes/main/world_preview.gd @@ -78,6 +78,7 @@ var _queue_positions: Dictionary = {} var _queue_hidden: Dictionary = {} var _landing_badges: Array[Dictionary] = [] var _transport_report: Dictionary = {} +var _life_observation: Dictionary = {} var _dragging := false var _drag_button := MOUSE_BUTTON_NONE @@ -511,6 +512,7 @@ func _draw() -> void: func _draw_contents() -> void: + _life_observation = {} if _inactive_layer_art != null: _inactive_layer_art.visible = false _draw_background() @@ -521,15 +523,25 @@ func _draw_contents() -> void: var map_rect := get_map_rect() if map_rect.size == Vector2.ZERO: return + _life_observation = _build_life_observation() + _room_presentations = _life_observation[&"rooms"] + _carried_patients = _life_observation[&"carried_patients"] + _treated_patients = _life_observation[&"treated_patients"] + _resting_staff = _life_observation[&"resting_staff"] + _queue_positions = _life_observation[&"queue_positions"] + _queue_hidden = _life_observation[&"queue_hidden"] + _landing_badges.assign(_life_observation[&"landing_badges"]) + _transport_report = _life_observation[&"elevators"] + _boarded_actor_ids = _life_observation[&"boarded"] + _actor_tracks = _life_observation[&"actor_tracks"] + _elevator_tracks = _life_observation[&"elevator_tracks"] _draw_world_cells(bounds, map_rect) - if show_grid: - _draw_congestion_overlay(bounds) - _prepare_room_presentations() - _prepare_queues() _draw_world_entity_icons() _draw_legacy_stairs() _draw_elevators() _draw_other_layer() + _draw_congestion_overlay(bounds) + _draw_life_badges() _draw_construction_labels() _draw_facility_labels() _draw_actors() @@ -540,6 +552,102 @@ func _draw_contents() -> void: _draw_map_caption(map_rect) +## The report describes the last submitted draw, not an independently sampled world. +## Callers cannot mutate either the frame or its interpolation tracks. +func get_life_observation_report() -> Dictionary: + return _life_observation.duplicate(true) + + +## Pure with respect to Runtime and this Control: helpers mutate only fresh local +## dictionaries (including copies of visual tracks). No whole Runtime snapshot +## is copied per frame; whole typed snapshots belong to sparse diagnostics. +func _build_life_observation() -> Dictionary: + var model := _observe_rooms() + model.merge(_observe_queues()) + model[&"layer"] = layer_mode + model[&"zoom"] = zoom_level + model[&"pan"] = pan_offset + model[&"viewport"] = size + model[&"animation_time"] = _animation_time + model[&"actor_tracks"] = _actor_tracks.duplicate(true) + model[&"elevator_tracks"] = _elevator_tracks.duplicate(true) + model[&"boarded"] = {} + var clock := get_node_or_null("/root/GameClock") + model[&"paused"] = clock != null and bool(clock.get("paused")) + model[&"cabins"] = _observe_elevators(model) + model[&"actor_drawings"] = _observe_actors(model) + var guests: Array[Dictionary] = [] + var staff: Array[Dictionary] = [] + var tasks: Array[Dictionary] = [] + var guest_service: Variant = _service(&"guest_service") + if guest_service != null and guest_service.has_method(&"get_guests"): + for guest: GuestRuntime in guest_service.get_guests(): + guests.append({&"id": guest.guest_id, &"state": GuestRuntime.State.keys()[guest.state], + &"cell": guest.current_cell, &"target": guest.target_facility_id, + &"service": guest.current_service_id, &"queue": guest.queue_request_id}) + var staff_service: Variant = _service(&"staff_service") + if staff_service != null: + for member: StaffRuntime in staff_service.get_staff_members(): + staff.append({&"id": member.staff_id, &"state": StaffRuntime.State.keys()[member.state], + &"cell": member.current_cell, &"task": member.current_task_id, + &"facility": member.assigned_facility_id, &"role": member.role_id, + &"carried_inventory_id": member.carried_inventory_id}) + for task: StaffTask in staff_service.get_tasks(): + tasks.append({&"id": task.task_id, &"state": StaffTask.State.keys()[task.state], + &"type": task.task_type, &"staff": task.assigned_staff_id, + &"target": task.target_entity_id, &"rescue_facility": task.rescue_facility_id}) + model[&"guests"] = guests + model[&"staff"] = staff + model[&"tasks"] = tasks + var bridge: Variant = _service(&"supply_logistics_bridge") + var goods: Dictionary = bridge.get_report() if bridge != null else {&"requests": [], &"deliveries": []} + model[&"goods"] = goods + var badges: Array[Dictionary] = [] + var facilities: Variant = _service(&"facility_service") + var world: Variant = _service(&"world_service") + if facilities != null and world != null: + for instance: FacilityInstance in facilities.get_instances(): + if instance.origin.z != layer_mode: + continue + var cells: Array[Vector3i] = world.get_entity_cells(instance.instance_id) + if cells.is_empty(): + continue + var rect := _cell_bounds_rect(cells) + var data: Dictionary = model[&"rooms"][instance.instance_id] + data[&"rect"] = rect + data[&"definition_id"] = instance.definition_id + var count := RoomPresentation.slots(rect, _cell_dimensions().x / 16.0, instance.definition_id).size() + badges.append({&"facility": instance.instance_id, &"kind": &"business", + &"at": rect.position + Vector2(2, 2), + &"text": RoomPresentation.business_label(data) + " " + RoomPresentation.label(data, count)}) + var requests: Array[Dictionary] = [] + for request: Dictionary in goods.get(&"requests", []): + if request[&"source_facility_id"] == instance.instance_id or request[&"destination_facility_id"] == instance.instance_id: + requests.append(request) + if not requests.is_empty(): + badges.append({&"facility": instance.instance_id, &"kind": &"supply", + &"at": rect.position + Vector2(2, 16), &"requests": requests, + &"text": RoomPresentation.supply_label(requests)}) + model[&"badges"] = badges + var traffic: Array[Dictionary] = [] + var path: Variant = _service(&"path_service") + var bounds := _world_bounds() + if path != null and not bounds.is_empty(): + for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"]) + 1): + for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1): + var cell := Vector3i(x, y, layer_mode) + var row: Dictionary = path.get_cell_traffic(cell) + if not row.is_empty(): + traffic.append({&"cell": cell, &"traffic": row, &"rect": get_cell_rect(cell).grow(-2.0)}) + model[&"traffic"] = traffic + return model + + +func _draw_life_badges() -> void: + for badge: Dictionary in _life_observation.get(&"badges", []): + _draw_readable_badge(badge[&"at"], badge[&"text"]) + + func _draw_background() -> void: var night := false var clock := get_node_or_null("/root/GameClock") @@ -650,40 +758,23 @@ func _draw_leaf_cluster(center: Vector2, p: float, variation: int) -> void: draw_rect(Rect2(c+offset,Vector2(3+i%2,2)*p),Color("95ad69") if i%3==0 else Color("648957")) -func _draw_congestion_overlay(bounds: Dictionary) -> void: - var path_service: Variant = _service(&"path_service") - if path_service == null or not path_service.has_method(&"get_cell_traffic"): - return - for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"]) + 1): - for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1): - var cell := Vector3i(x, y, layer_mode) - var traffic_value: Variant = path_service.call(&"get_cell_traffic", cell) - if not traffic_value is Dictionary or traffic_value.is_empty(): - continue - var traffic: Dictionary = traffic_value - var density := int(traffic.get(&"current_density", 0)) - var capacity := maxi(int(traffic.get(&"base_capacity", 1)), 1) - var blocked := bool(traffic.get(&"temporary_block", false)) - if density <= 0 and not blocked: - continue - var ratio := float(density) / float(capacity) - var color := Color("55c96b") - if blocked or ratio > 1.0: - color = Color("e34332") - elif ratio >= 0.65: - color = Color("e3a62f") - var rect := get_cell_rect(cell).grow(-2.0) - var center := rect.get_center() - var route_width := clampf(rect.size.y * 0.16, 2.0, 5.0) - draw_line( - Vector2(rect.position.x + rect.size.x * 0.12, center.y), - Vector2(rect.end.x - rect.size.x * 0.12, center.y), - Color(color, 0.90), - route_width - ) - if blocked: - draw_line(rect.position, rect.end, color, 2.0) - draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), color, 2.0) +func _draw_congestion_overlay(_bounds: Dictionary) -> void: + for row: Dictionary in _life_observation.get(&"traffic", []): + var rect: Rect2 = row[&"rect"] + var traffic: Dictionary = row[&"traffic"] + var density := int(traffic[&"current_density"]) + var blocked := bool(traffic[&"temporary_block"]) + if density <= 0 and not blocked: + continue + var ratio := float(density) / maxi(int(traffic[&"base_capacity"]), 1) + var color := Color("e34332") if blocked or ratio > 1.0 else Color("e3a62f") if ratio >= 0.65 else Color("55c96b") + var center := rect.get_center() + draw_line(Vector2(rect.position.x + rect.size.x * 0.12, center.y), + Vector2(rect.end.x - rect.size.x * 0.12, center.y), + Color(color, 0.90 if show_grid else 0.28), clampf(rect.size.y * 0.16, 2.0, 5.0)) + if blocked: + draw_line(rect.position, rect.end, Color(color, 0.90 if show_grid else 0.28), 2.0) + draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), Color(color, 0.90 if show_grid else 0.28), 2.0) ## Visual-only level comes from the existing live FacilityInstance, not the @@ -794,10 +885,6 @@ func _draw_world_entity_icons() -> void: draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id)) if instance_id==selected_entity_id: draw_rect(rect.grow(-p),Color("f5d582"),false,2*p) - var data: Dictionary = _room_presentations.get(instance_id, {}) - if not data.is_empty() and (rect.size.x >= 80 or instance_id == selected_entity_id) and id not in [&"guest_corridor", &"service_corridor", &"guest_stairs", &"service_stairs", &"guest_elevator", &"service_elevator"]: - var status := RoomPresentation.label(data, RoomPresentation.slots(rect, p, id).size()) - _draw_readable_badge(rect.position+Vector2(2, 2), status.replace("利用", "治療") if id == &"infirmary" else status.replace("利用", "休憩") if id in [&"staff_waiting_room", &"staff_dormitory"] else status) func _draw_readable_badge(at: Vector2, text: String) -> void: @@ -810,13 +897,21 @@ func _draw_readable_badge(at: Vector2, text: String) -> void: func _prepare_room_presentations() -> void: - _room_presentations.clear() - _carried_patients.clear() - _treated_patients.clear() - _resting_staff.clear() + var model := _observe_rooms() + _room_presentations = model[&"rooms"] + _carried_patients = model[&"carried_patients"] + _treated_patients = model[&"treated_patients"] + _resting_staff = model[&"resting_staff"] + + +func _observe_rooms() -> Dictionary: + var _room_presentations: Dictionary = {} + var _carried_patients: Dictionary = {} + var _treated_patients: Dictionary = {} + var _resting_staff: Dictionary = {} var service: Variant = _service(&"facility_service") if service == null: - return + return {&"rooms": _room_presentations, &"carried_patients": _carried_patients, &"treated_patients": _treated_patients, &"resting_staff": _resting_staff} for instance: Variant in service.get_instances(): if instance.origin.z == layer_mode: _room_presentations[instance.instance_id] = RoomPresentation.describe(instance) @@ -844,12 +939,22 @@ func _prepare_room_presentations() -> void: else: _carried_patients[task.target_entity_id] = task.assigned_staff_id + return {&"rooms": _room_presentations, &"carried_patients": _carried_patients, &"treated_patients": _treated_patients, &"resting_staff": _resting_staff} + func _prepare_queues() -> void: - _queue_positions.clear() - _queue_hidden.clear() - _landing_badges.clear() - _transport_report.clear() + var model := _observe_queues() + _queue_positions = model[&"queue_positions"] + _queue_hidden = model[&"queue_hidden"] + _landing_badges.assign(model[&"landing_badges"]) + _transport_report = model[&"elevators"] + + +func _observe_queues() -> Dictionary: + var _queue_positions: Dictionary = {} + var _queue_hidden: Dictionary = {} + var _landing_badges: Array[Dictionary] = [] + var _transport_report: Dictionary = {} var groups: Dictionary = {} var facilities: Variant = _service(&"facility_service") var guests: Variant = _service(&"guest_service") @@ -908,10 +1013,10 @@ func _prepare_queues() -> void: index += 1 for hidden: int in range(index, members.size()): _queue_hidden[members[hidden]] = true - var text := "待ち%d人" % members.size() - if index < members.size(): - text += "(他%d人)" % (members.size()-index) - _landing_badges.append({&"at": get_cell_rect(cell).position + Vector2(-80 if lift_cells.has(cell) else 0, -16), &"text": text}) + var text := RoomPresentation.queue_label(members.size(), members.size()-index) + _landing_badges.append({&"at": get_cell_rect(cell).position + Vector2(-80 if lift_cells.has(cell) else 0, -16), &"text": text, &"cell": cell, &"members": members.duplicate(), &"visible_count": index, &"overflow": members.size()-index}) + + return {&"queue_positions": _queue_positions, &"queue_hidden": _queue_hidden, &"landing_badges": _landing_badges, &"elevators": _transport_report} func _add_queue_member(groups: Dictionary, key: String, cell: Vector3i) -> void: @@ -1039,14 +1144,21 @@ func _draw_facility_labels() -> void: func _draw_elevators() -> void: - _boarded_actor_ids.clear() - var transport: Variant = _service(&"elevators") - if transport == null or not transport.has_method(&"get_report"): - _elevator_tracks.clear() - return - var report_value: Variant = _transport_report - if not report_value is Dictionary: - return + for row: Dictionary in _life_observation.get(&"cabins", []): + PixelArt.elevator_cabin(self, row[&"rect"], row[&"pixel"], layer_mode == LayerMode.BACK, row[&"operational"]) + for actor: Dictionary in row[&"passengers"]: + _draw_observed_actor(actor) + PixelArt.elevator_gate(self, row[&"rect"], row[&"pixel"], row[&"doors_open"]) + _draw_readable_badge(row[&"badge_at"], row[&"badge"]) + for badge: Dictionary in _life_observation.get(&"landing_badges", []): + _draw_readable_badge(badge[&"at"], badge[&"text"]) + + +func _observe_elevators(observation: Dictionary) -> Array[Dictionary]: + var cabins: Array[Dictionary] = [] + var _boarded_actor_ids: Dictionary = observation[&"boarded"] + var _elevator_tracks: Dictionary = observation[&"elevator_tracks"] + var report_value: Dictionary = observation[&"elevators"] var alive: Dictionary = {} var p := _cell_dimensions().x/16.0 for value: Variant in report_value.get(&"shafts", []): @@ -1062,7 +1174,7 @@ func _draw_elevators() -> void: for actor_id: Variant in passengers: _boarded_actor_ids[_elevator_actor_key(String(actor_id))] = true var floor_value := float(shaft.get(&"visual_floor", shaft.get(&"current_floor", stops[0].y))) - var visual_floor := _tracked_elevator_floor(shaft_id, floor_value) + var visual_floor := _tracked_elevator_floor(shaft_id, floor_value, observation) if stops[0].z != layer_mode: continue var origin := get_cell_rect(stops[0]) @@ -1074,7 +1186,7 @@ func _draw_elevators() -> void: var operational := bool(shaft.get(&"operational", true)) var phase := StringName(shaft.get(&"phase", &"IDLE")) var doors_open := operational and phase in [&"IDLE", &"BOARDING", &"ALIGHTING"] - PixelArt.elevator_cabin(self,cabin,p,layer_mode==LayerMode.BACK,operational) + var passengers_drawn: Array[Dictionary] = [] var seat_columns := maxi(int((cabin.size.x / p - 4.0) / 8.0), 1) var visible_count := mini(passengers.size(), seat_columns*2) for index: int in range(visible_count): @@ -1090,22 +1202,19 @@ func _draw_elevators() -> void: var x := cabin.position.x + (2.0 + (index % seat_columns + 0.5)*8.0)*p var feet := cabin.end.y - (2.0 + int(index / seat_columns)*9.0)*p var center := Vector2(x, feet+3*p-_cell_dimensions().y*0.5) - _draw_pixel_actor(center,actor_key,is_staff,state,false,carrying,&"",true) - PixelArt.elevator_gate(self,cabin,p,doors_open) - var capacity := int(shaft.get(&"capacity", 0)) - var load_text := "%d/%d人" % [passengers.size(), capacity] - if capacity > 0 and passengers.size() >= capacity: - load_text = "満員 " + load_text - if not operational: - load_text = "停止 " + load_text - _draw_readable_badge(Vector2(cabin.position.x, cabin.position.y-14), load_text) - for badge: Dictionary in _landing_badges: - _draw_readable_badge(badge[&"at"], badge[&"text"]) + passengers_drawn.append(_actor_drawing(center,actor_key,is_staff,state,false,carrying,&"",true,observation)) + cabins.append({&"shaft_id": shaft_id, &"rect": cabin, &"pixel": p, + &"operational": operational, &"doors_open": doors_open, + &"passengers": passengers_drawn, + &"badge_at": Vector2(cabin.position.x, cabin.position.y-14), + &"badge": RoomPresentation.elevator_label(shaft)}) for shaft_id: Variant in _elevator_tracks.keys(): if not alive.has(shaft_id): _elevator_tracks.erase(shaft_id) + return cabins + func _elevator_actor_key(actor_id: String) -> String: if actor_id.begins_with("guest:"): @@ -1115,7 +1224,8 @@ func _elevator_actor_key(actor_id: String) -> String: return actor_id -func _tracked_elevator_floor(shaft_id: StringName, current_floor: float) -> float: +func _tracked_elevator_floor(shaft_id: StringName, current_floor: float, observation: Dictionary = {}) -> float: + var _elevator_tracks: Dictionary = observation.get(&"elevator_tracks", self._elevator_tracks) if not _elevator_tracks.has(shaft_id): _elevator_tracks[shaft_id] = {&"floor":current_floor,&"from":current_floor,&"at":_animation_time} var track: Dictionary = _elevator_tracks[shaft_id] @@ -1131,6 +1241,19 @@ func _tracked_elevator_floor(shaft_id: StringName, current_floor: float) -> floa func _draw_actors() -> void: + for row: Dictionary in _life_observation.get(&"actor_drawings", []): + _draw_observed_actor(row) + + +func _observe_actors(observation: Dictionary) -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + var _actor_tracks: Dictionary = observation[&"actor_tracks"] + var _boarded_actor_ids: Dictionary = observation[&"boarded"] + var _queue_hidden: Dictionary = observation[&"queue_hidden"] + var _queue_positions: Dictionary = observation[&"queue_positions"] + var _carried_patients: Dictionary = observation[&"carried_patients"] + var _treated_patients: Dictionary = observation[&"treated_patients"] + var _resting_staff: Dictionary = observation[&"resting_staff"] var counts: Dictionary = {} var alive: Dictionary = {} var service_slots: Dictionary = {} @@ -1144,18 +1267,18 @@ func _draw_actors() -> void: if _boarded_actor_ids.has(key) or _queue_hidden.has(key): _actor_tracks.erase(key) continue - var position := _tracked_actor_position(key,guest.current_cell) + var position := _tracked_actor_position(key,guest.current_cell,observation) if guest.current_cell.z==layer_mode: var receiving := not String(guest.current_service_id).is_empty() if _queue_positions.has(key): position = _queue_positions[key] elif receiving: - position = _service_use_position(guest,position,service_slots) + position = _service_use_position(guest,position,service_slots,observation) if not position.is_finite(): continue else: position += _actor_offset(guest.current_cell,counts) - _draw_pixel_actor(position,key,false,int(guest.state),receiving,false,_guest_activity(guest)) + rows.append(_actor_drawing(position,key,false,int(guest.state),receiving,false,_guest_activity(guest),false,observation)) var staff_service: Variant = _service(&"staff_service") if staff_service != null and staff_service.has_method(&"get_staff_members"): for staff: Variant in staff_service.call(&"get_staff_members"): @@ -1166,27 +1289,29 @@ func _draw_actors() -> void: if _boarded_actor_ids.has(key) or _queue_hidden.has(key): _actor_tracks.erase(key) continue - var position := _tracked_actor_position(key,staff.current_cell) + var position := _tracked_actor_position(key,staff.current_cell,observation) if staff.current_cell.z==layer_mode: if _carried_patients.has(staff.staff_id): var carrier: Variant = staff_service.get_staff(_carried_patients[staff.staff_id]) if carrier != null: - position = _tracked_actor_position("staff/"+String(carrier.staff_id), carrier.current_cell) - Vector2(0, 12*_cell_dimensions().x/16.0) - _draw_pixel_actor(position,key,true,int(staff.state),false,false) + position = _tracked_actor_position("staff/"+String(carrier.staff_id), carrier.current_cell,observation) - Vector2(0, 12*_cell_dimensions().x/16.0) + rows.append(_actor_drawing(position,key,true,int(staff.state),false,false,&"",false,observation)) continue if _resting_staff.has(staff.staff_id): - position = _resting_staff_position(staff.staff_id, position) - if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false) + position = _resting_staff_position(staff.staff_id, position,observation) + if position.is_finite(): rows.append(_actor_drawing(position,key,true,int(staff.state),false,false,&"",false,observation)) continue if _treated_patients.has(staff.staff_id): - position = _rescue_treatment_position(staff.staff_id, position) - if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false) + position = _rescue_treatment_position(staff.staff_id, position,observation) + if position.is_finite(): rows.append(_actor_drawing(position,key,true,int(staff.state),false,false,&"",false,observation)) continue - _draw_pixel_actor(_queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts))),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff)) + rows.append(_actor_drawing(_queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts),observation)),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff),false,observation)) for key: Variant in _actor_tracks.keys(): if not alive.has(key): _actor_tracks.erase(key) + return rows + func _staff_work_animation(staff: StaffRuntime) -> StringName: if staff.role_id != &"cleaner": @@ -1201,7 +1326,8 @@ func _staff_work_animation(staff: StaffRuntime) -> StringName: return &"idle" -func _staff_work_position(staff: Variant, fallback: Vector2) -> Vector2: +func _staff_work_position(staff: Variant, fallback: Vector2, observation: Dictionary = {}) -> Vector2: + var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations) if staff.state not in [StaffRuntime.State.WORKING, StaffRuntime.State.AVAILABLE]: return fallback var service: Variant = _service(&"facility_service") @@ -1227,7 +1353,8 @@ func _staff_work_position(staff: Variant, fallback: Vector2) -> Vector2: return Vector2(rect.position.x+(slot+0.5)*rect.size.x/columns, rect.position.y+26*p-_cell_dimensions().y*0.5) -func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2: +func _tracked_actor_position(key: String, cell: Vector3i, observation: Dictionary = {}) -> Vector2: + var _actor_tracks: Dictionary = observation.get(&"actor_tracks", self._actor_tracks) if not _actor_tracks.has(key): _actor_tracks[key] = {&"cell":cell,&"from":cell,&"at":_animation_time,&"direction":1.0} var track: Dictionary = _actor_tracks[key] @@ -1246,7 +1373,8 @@ func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2: return cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(cell),t) -func _service_use_position(guest: Variant, fallback: Vector2, _slots: Dictionary) -> Vector2: +func _service_use_position(guest: Variant, fallback: Vector2, _slots: Dictionary, observation: Dictionary = {}) -> Vector2: + var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations) # Guest paths stop at an access cell. While a service is active, project the # occupant into that facility's use position; authoritative paths stay intact. var id := StringName(guest.target_facility_id) @@ -1281,6 +1409,19 @@ func _guest_activity(guest: Variant) -> StringName: func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false) -> void: + _draw_observed_actor(_actor_drawing(center, key, staff, state, receiving, carrying, activity, riding)) + + +func _draw_observed_actor(row: Dictionary) -> void: + var at: Vector2 = row[&"at"] + var p: float = row[&"scale"] + draw_rect(Rect2(at + Vector2(5, 17) * p, Vector2(8, 1) * p), Color("303c40", 0.2)) + People.draw(self, at, p, row[&"mode"], row[&"frame"], row[&"variant"], row[&"staff"], row[&"flip"]) + + +func _actor_drawing(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false, observation: Dictionary = {}) -> Dictionary: + var _actor_tracks: Dictionary = observation.get(&"actor_tracks", self._actor_tracks) + var _queue_positions: Dictionary = observation.get(&"queue_positions", self._queue_positions) var world_pixel := _cell_dimensions().x / 16.0 var scale := 0.5 if riding else 0.75 var p := maxf(floorf(world_pixel * scale), 1.0) if world_pixel >= 2.0 else world_pixel * scale @@ -1307,8 +1448,10 @@ func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, re elif _queue_positions.has(key) or (not staff and state == GuestRuntime.State.QUEUING): mode = &"wait" var at := (center + Vector2(-8 * p, _cell_dimensions().y * 0.5 - 3 * world_pixel - 17 * p)).round() - draw_rect(Rect2(at + Vector2(5, 17) * p, Vector2(8, 1) * p), Color("303c40", 0.2)) - People.draw(self, at, p, mode, frame, variant, staff, float(track.get(&"direction", 1.0)) < 0.0) + return {&"key": key, &"center": center, &"at": at, &"scale": p, &"mode": mode, + &"frame": frame, &"variant": variant, &"staff": staff, &"state": state, + &"receiving": receiving, &"carrying": carrying, &"activity": activity, + &"riding": riding, &"flip": float(track.get(&"direction", 1.0)) < 0.0} func _draw_excavation_jobs() -> void: @@ -1421,14 +1564,14 @@ func _facility_color(instance_id: StringName) -> Color: var instance: Variant = facility_service.call(&"get_instance", instance_id) if instance == null: return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582") - match int(instance.state): - 0: + match int(_room_presentations.get(instance_id, {}).get(&"state", instance.state)): + FacilityInstance.State.BROKEN: return Color("a24b45") - 1, 2, 3: + FacilityInstance.State.NO_ACCESS, FacilityInstance.State.NO_POWER, FacilityInstance.State.NO_STAFF: return Color("a77d3f") - 4: + FacilityInstance.State.CLOSED: return Color("6b6258") - 5: + FacilityInstance.State.DEGRADED: return Color("89914d") _: return Color("6f9a55") if layer_mode == LayerMode.FRONT else Color("67518e") @@ -1579,7 +1722,9 @@ func _normalized_rotation(value: int) -> int: return posmod(value, 360) / 90 * 90 -func _rescue_treatment_position(patient_id: StringName, fallback: Vector2) -> Vector2: +func _rescue_treatment_position(patient_id: StringName, fallback: Vector2, observation: Dictionary = {}) -> Vector2: + var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations) + var _treated_patients: Dictionary = observation.get(&"treated_patients", self._treated_patients) var facility_id: StringName = _treated_patients[patient_id] var world: Variant = _service(&"world_service") var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else [] @@ -1592,7 +1737,9 @@ func _rescue_treatment_position(patient_id: StringName, fallback: Vector2) -> Ve return anchors[slot]+Vector2(0,-2*p-_cell_dimensions().y*0.5) -func _resting_staff_position(staff_id: StringName, fallback: Vector2) -> Vector2: +func _resting_staff_position(staff_id: StringName, fallback: Vector2, observation: Dictionary = {}) -> Vector2: + var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations) + var _resting_staff: Dictionary = observation.get(&"resting_staff", self._resting_staff) var facility_id: StringName = _resting_staff[staff_id] var world: Variant = _service(&"world_service") var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else [] diff --git a/scripts/ui/room_presentation.gd b/scripts/ui/room_presentation.gd index 5945440..20dd175 100644 --- a/scripts/ui/room_presentation.gd +++ b/scripts/ui/room_presentation.gd @@ -10,7 +10,9 @@ static func describe(instance: Variant) -> Dictionary: if not ids.has(id): ids.append(id) ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) - return {&"operation": int(instance.operation_state), &"people": ids, + return {&"state": int(instance.state), &"state_name": FacilityInstance.State.keys()[instance.state], + &"reasons": instance.state_reasons.duplicate(), + &"operation": int(instance.operation_state), &"people": ids, &"count": ids.size(), &"limit": int(instance.customer_capacity), &"staff": instance.active_staff_ids.duplicate(), &"suspension": StringName(instance.suspension_reason)} @@ -50,3 +52,48 @@ static func label(data: Dictionary, visible_count: int) -> String: if count > visible_count: value += "(他%d人)" % (count - visible_count) return value + + +static func business_label(data: Dictionary) -> String: + if data.is_empty(): + return "" + if int(data[&"operation"]) == FacilityInstance.OperationState.SUSPENDED: + return "休止・摘発" if data[&"suspension"] == &"RAIDED" else "休止・破壊" + match int(data[&"state"]): + FacilityInstance.State.BROKEN: return "破損" + FacilityInstance.State.NO_ACCESS: return "通路なし" + FacilityInstance.State.NO_POWER: return "魔力なし" + FacilityInstance.State.NO_STAFF: return "職員不足" + FacilityInstance.State.CLOSED: return "営業停止" + FacilityInstance.State.DEGRADED: return "低下運転" + FacilityInstance.State.OPEN: return "営業中" + return String(data[&"state_name"]) + + +static func queue_label(count: int, overflow: int) -> String: + return "待ち%d人" % count + ("(他%d人)" % overflow if overflow > 0 else "") + + +static func elevator_label(shaft: Dictionary) -> String: + var count := (shaft[&"boarded"] as Array).size() + var capacity := int(shaft[&"capacity"]) + var value := "%d/%d人" % [count, capacity] + if capacity > 0 and count >= capacity: + value = "満員 " + value + if not bool(shaft[&"operational"]): + value = "停止 " + value + return value + + +static func supply_label(requests: Array[Dictionary]) -> String: + # These are requests, never invented stock quantities. Preserve actual states. + var states: Dictionary = {} + for request: Dictionary in requests: + var state := String(request[&"state"]) + states[state] = int(states.get(state, 0)) + 1 + var names: Array = states.keys() + names.sort() + var parts: PackedStringArray = [] + for state: String in names: + parts.append("%s:%d" % [state, states[state]]) + return "物資依頼 " + " / ".join(parts) diff --git a/tests/fixtures/p23-03/diagnostic_stream.gd b/tests/fixtures/p23-03/diagnostic_stream.gd new file mode 100644 index 0000000..fb2706a --- /dev/null +++ b/tests/fixtures/p23-03/diagnostic_stream.gd @@ -0,0 +1,56 @@ +extends RefCounted +## Sparse whole typed captures. No acceptance flags and no truncation. +const Codec := preload("res://tests/fixtures/p08-02/mixed_world.gd") +var directory: String +var manifest: FileAccess + +func open(path: String) -> bool: + directory = path + if DirAccess.dir_exists_absolute(path): + return false + if DirAccess.make_dir_recursive_absolute(path.path_join("objects")) != OK: + return false + manifest = FileAccess.open(path.path_join("manifest.jsonl"), FileAccess.WRITE) + return manifest != null + +func object(value: Variant) -> String: + var text := JSON.stringify(Codec.new().typed(value)) + "\n" + var digest := text.sha256_text() + var path := directory.path_join("objects").path_join(digest + ".json") + if FileAccess.file_exists(path): + return digest if FileAccess.get_sha256(path) == digest else "" + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return "" + file.store_string(text) + file.close() + return digest if FileAccess.get_sha256(path) == digest else "" + +func capture(label: String, runtime: Variant, view: WorldPreview) -> bool: + if manifest == null: + return false + var state: Dictionary = runtime.create_snapshot() + var report := view.get_life_observation_report() + var state_hash := object(state) + var report_hash := object(report) + if state_hash.is_empty() or report_hash.is_empty(): + return false + manifest.store_line(JSON.stringify({"label": label, "pid": OS.get_process_id(), + "ticks_usec": Time.get_ticks_usec(), "state": state_hash, "report": report_hash, + "input_method": "diagnostic", "acceptance": "pending"})) + manifest.flush() + return var_to_bytes(state) == var_to_bytes(runtime.create_snapshot()) + +func close() -> void: + if manifest != null: + manifest.close() + manifest = null + +func record(label: String, value: Dictionary) -> bool: + if manifest == null: return false + var digest := object(value) + if digest.is_empty(): return false + manifest.store_line(JSON.stringify({"label": label, "pid": OS.get_process_id(), + "ticks_usec": Time.get_ticks_usec(), "object": digest})) + manifest.flush() + return true diff --git a/tests/fixtures/p23-03/diagnostic_stream.gd.uid b/tests/fixtures/p23-03/diagnostic_stream.gd.uid new file mode 100644 index 0000000..0140e88 --- /dev/null +++ b/tests/fixtures/p23-03/diagnostic_stream.gd.uid @@ -0,0 +1 @@ +uid://d23lifestr03 diff --git a/tests/fixtures/p23-03/layout_comparison.gd b/tests/fixtures/p23-03/layout_comparison.gd new file mode 100644 index 0000000..0f67a0e --- /dev/null +++ b/tests/fixtures/p23-03/layout_comparison.gd @@ -0,0 +1,104 @@ +extends RefCounted +## Public-command experiment; no synthetic state, costs or capacities. +## Predeclared metric: total destination-arrival minutes of the entire cohort. +## B must be strictly smaller. Incomplete cohorts cannot establish improvement. +const Stream := preload("res://tests/fixtures/p23-03/diagnostic_stream.gd") +const SEED := 806042 +const WINDOW := 480 +const START := Vector3i(2, 0, 0) +const GOAL := Vector3i(2, 1, 0) +var stream: RefCounted + +func declaration() -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = SEED + var arrivals: Array[Dictionary] = [] + for index: int in range(48): + arrivals.append({&"id": StringName("p23-%03d" % index), + &"offset": (index / 4) * 15 + rng.randi_range(0, 2)}) + arrivals.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a[&"offset"] < b[&"offset"] or (a[&"offset"] == b[&"offset"] and String(a[&"id"]) < String(b[&"id"]))) + return {&"seed": SEED, &"initial_clock": [1, 6, 0], &"budget": &"GameState.reset", + &"production": &"Runtime.initialize defaults; production operations not enabled", + &"arrivals": arrivals, &"from": START, &"to": GOAL, &"window": WINDOW, + &"metric": &"sum_destination_arrival_minutes_entire_cohort", + &"improvement": {&"definition_id": &"guest_elevator", &"origin": Vector3i(4, 0, 0)}} + +func run(tree: SceneTree, directory: String, layout: String) -> bool: + if layout not in ["A", "B"]: return false + var global_clock := tree.root.get_node_or_null("GameClock") + if global_clock != null: global_clock.set_paused(true) + stream = Stream.new() + if not stream.open(directory): return false + var input := declaration() + if not stream.record("declaration", input): + stream.close() + return false + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1, 6, 0) + state.reset() + var ok: bool = runtime.initialize(clock, state, SEED)[&"valid"] + clock.set_paused(true) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + ok = stream.record("initial", runtime.create_snapshot()) and ok + var builds: Array = [[&"guest_elevator", Vector3i.ZERO], + [&"guest_corridor", Vector3i(2, 1, 0)], [&"guest_corridor", Vector3i(3, 1, 0)]] + if layout == "B": builds.append([input[&"improvement"][&"definition_id"], input[&"improvement"][&"origin"]]) + var construction_minutes := 0 + # Identical preparation time derived from the same registered definitions. + for id: StringName in [&"guest_elevator", &"guest_corridor"]: + construction_minutes = maxi(construction_minutes, runtime.definition_registry.get_facility(id).build_minutes) + for index: int in range(builds.size()): + var payload := {&"definition_id": builds[index][0], &"origin": builds[index][1]} + var result: CommandResult = runtime.submit_command(&"build_facility", payload, + &"player", &"", 0, clock.absolute_minute, StringName("p23.build.%d" % index)) + ok = stream.record("build-%d" % index, {&"payload": payload, &"result": result.to_dictionary()}) and result.is_success() and ok + clock.advance_minutes(construction_minutes + 1) + ok = stream.record("prepared", runtime.create_snapshot()) and ok + runtime.domain_events.subscribe(&"*", &"p23.capture", Callable(self, "_event")) + var view := WorldPreview.new() + view.size = Vector2(1440, 810) + view.set_runtime(runtime) + tree.root.add_child(view) + var finished: Dictionary = {} + for offset: int in range(WINDOW + 1): + if not ok: break + if offset > 0: clock.advance_minutes(1) + for arrival: Dictionary in input[&"arrivals"]: + if arrival[&"offset"] != offset: continue + var payload := {&"guest_id": arrival[&"id"], &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"current_cell": START, &"state": GuestRuntime.State.MOVING, + &"state_entered_at": clock.absolute_minute - 1, &"planned_exit_at": clock.absolute_minute + 2000, + &"path_handle": {&"target_cell": GOAL}}} + var result: CommandResult = runtime.submit_command(&"spawn_guest", payload, + &"world", arrival[&"id"], 10, -1, StringName("p23.spawn.%s" % arrival[&"id"])) + ok = stream.record("arrival-%s" % arrival[&"id"], {&"offset": offset, &"payload": payload, &"result": result.to_dictionary()}) and result.is_success() and ok + # Typed per-minute projections retain paths, queues, density and actual + # completed services. Whole aggregate snapshots are only sampled sparsely. + ok = stream.record("tick-%03d" % offset, {&"minute": clock.absolute_minute, + &"guests": runtime.guest_service.create_snapshot(), &"path": runtime.path_service.create_snapshot(), + &"elevators": runtime.elevators.create_snapshot(), &"facilities": runtime.facility_service.create_snapshot()}) and ok + for guest: GuestRuntime in runtime.guest_service.get_guests(): + if guest.current_cell != GOAL or finished.has(guest.guest_id): continue + finished[guest.guest_id] = offset + var leaving: Dictionary = runtime.guest_service.begin_leaving(guest.guest_id, clock.absolute_minute, &"COMPARISON_DESTINATION_REACHED") + var despawn: Dictionary = runtime.guest_service.despawn_guest(guest.guest_id, clock.absolute_minute) + ok = bool(leaving.get(&"ok", false)) and bool(despawn.get(&"ok", false)) and ok + if offset % 30 == 0: + view.queue_redraw() + await tree.process_frame + await tree.process_frame + ok = stream.capture("frame-%03d" % offset, runtime, view) and ok + ok = stream.record("final", runtime.create_snapshot()) and ok + stream.close() + view.free() + runtime.free() + clock.free() + state.free() + return ok + +func _event(event: SimulationDomainEvent) -> void: + stream.record("event", {&"type": event.event_type, &"minute": event.absolute_minute, + &"entities": event.entity_ids, &"payload": event.payload.duplicate(true)}) diff --git a/tests/ui/test_p23_life_observation.gd b/tests/ui/test_p23_life_observation.gd new file mode 100644 index 0000000..5b023b6 --- /dev/null +++ b/tests/ui/test_p23_life_observation.gd @@ -0,0 +1,77 @@ +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 + +func _initialize() -> void: + call_deferred("run") + +func expect(value: bool, message: String) -> void: + checks += 1 + if not value: + failures += 1 + push_error("P23 life observation: " + message) + +func run() -> void: + var clock: Variant = root.get_node("GameClock") + clock.set_paused(true) + # 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) + 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") + # 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) diff --git a/tests/ui/test_p23_life_observation.gd.uid b/tests/ui/test_p23_life_observation.gd.uid new file mode 100644 index 0000000..1dd49ef --- /dev/null +++ b/tests/ui/test_p23_life_observation.gd.uid @@ -0,0 +1 @@ +uid://d23lifeobs03 diff --git a/tools/check_p23_life_observation_evidence.py b/tools/check_p23_life_observation_evidence.py new file mode 100644 index 0000000..6c1e697 --- /dev/null +++ b/tools/check_p23_life_observation_evidence.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +"""Partial read-only semantic diagnostics. This version cannot accept P23-03. + +Whole typed inputs are preserved. The existing typed codec is reused verbatim. +Unimplemented visual/OS checks are explicit blockers, never successful checks. +""" +import argparse +import copy +import hashlib +import importlib.util +import json +import math +from pathlib import Path +import re +import sys + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parent.parent +SPEC = 'docs/spec/acceptance/P23-03.json' +SPEC_SHA = '8568457df849ef587c731f072d77244ef5420fc4e1190c5069cfd6cfeea9c89f' +CONDITIONS = ( + 'room_occupancy_and_operation', 'queues_and_overflow', + 'elevator_state_and_passengers', 'staff_state_and_jobs', + 'supplies_and_carried_goods', 'same_demand_route_improvement', + 'normal_os_and_distinct_pid_resume', +) +BLOCKERS = { + CONDITIONS[0]: 'Service/rescue occupancy, reason fields and room rectangles checked; rest route selection and badge pixels unfinished.', + CONDITIONS[1]: 'Queue lane allocation, overflow and positions checked; screenshot pixel provenance unfinished.', + CONDITIONS[2]: 'Shaft/trip and cabin geometry checked; prior-frame interpolation history and passenger geometry unfinished.', + CONDITIONS[3]: 'Typed staff/task projection implemented; motion/rest/rescue/cleaning drawing recomputation unfinished.', + CONDITIONS[4]: 'Carried inventory ID and logistics report checked; supply badge pixels unfinished.', + CONDITIONS[5]: 'Public-command A/B fixture and typed arrival metric added; native execution, definition pins and full command/path provenance checks pending.', + CONDITIONS[6]: 'P23 passive request/ACK collector and independent CUA/formal-resume gates unfinished; Root evidence missing.', +} + + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +codec = module('p23_existing_typed_codec', ROOT / 'tools/check_p08_migration_evidence.py') +def decode(node): + # Keep P08 scalar semantics, adding the renderer's Rect2 and vector keys. + if node['type'] == 'Dictionary': + result = {} + for key, value in node['entries']: + key = decode(key) + if isinstance(key, list): + key = tuple(key) + require(key not in result, 'duplicate typed dictionary key') + result[key] = decode(value) + return result + if node['type'] == 'Array': + return [decode(value) for value in node['items']] + if node['type'] == 'Rect2': + match = re.fullmatch(r'Rect2\(([^()]*)\)', node['text']) + require(match is not None, 'invalid typed Rect2') + values = [float(v.strip()) for v in match[1].split(',')] + require(len(values) == 4 and all(math.isfinite(v) for v in values), 'invalid rectangle') + return values + return codec.decode(node) + + +def require(value, message): + if not value: + raise ValueError(message) + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def enum_names(root, relative, name): + source = (root / relative).read_text() + match = re.search(r'\benum\s+' + re.escape(name) + r'\s*\{([^}]+)\}', source) + require(match is not None, 'missing source enum ' + name) + names = [x.strip() for x in match[1].split(',') if x.strip()] + require(all(re.fullmatch(r'[A-Z][A-Z_0-9]*', x) for x in names), 'unsupported enum schema') + require(len(set(names)) == len(names), 'duplicate source enum') + return names + + +def projection(rows, mapping, states): + result = [] + ids = set() + for row in rows: + value = {target: copy.deepcopy(row[source]) for target, source in mapping.items()} + require(value['id'] not in ids, 'duplicate actor/task ID') + ids.add(value['id']) + require(value['state'] in states, 'unknown typed state') + result.append(value) + return result + + +def compare_actor_rows(snapshot, report, root=ROOT): + runtime = snapshot['systems']['runtime'] + guest_states = enum_names(root, 'scripts/guest/guest_runtime.gd', 'State') + staff_states = enum_names(root, 'scripts/staff/staff_runtime.gd', 'State') + task_states = enum_names(root, 'scripts/staff/staff_task.gd', 'State') + mappings = ( + ('guests', runtime['guests']['guests'], guest_states, + dict(id='guest_id', state='state', cell='current_cell', target='target_facility_id', + service='current_service_id', queue='queue_request_id')), + ('staff', runtime['staff']['staff'], staff_states, + dict(id='staff_id', state='state', cell='current_cell', task='current_task_id', + facility='assigned_facility_id', role='role_id', carried_inventory_id='carried_inventory_id')), + ('tasks', runtime['staff']['tasks'], task_states, + dict(id='task_id', state='state', type='task_type', staff='assigned_staff_id', + target='target_entity_id', rescue_facility='rescue_facility_id')), + ) + for key, rows, states, mapping in mappings: + expected = projection(rows, mapping, states) + require(report[key] == expected, 'typed projection mismatch: ' + key) + return [key for key, *_ in mappings] + + +def elevator_report(snapshot): + saved = snapshot['systems']['runtime']['elevators'] + rows = [] + for shaft in saved['shafts']: + row = copy.deepcopy(shaft) + row.update(shaft['config']) + floor = float(shaft['current_floor']) + if shaft['phase'] in ('PICKUP', 'TRAVEL'): + destination = shaft['boarding_floor'] if shaft['phase'] == 'PICKUP' else shaft['target_floor'] + progress = 1.0 - shaft['remaining_minutes'] / max(float(shaft['phase_total_minutes']), 1.0) + floor = shaft['phase_origin_floor'] + (destination - shaft['phase_origin_floor']) * progress + row.update(visual_floor=floor, passenger_count=len(shaft['boarded']), + passenger_ids=copy.deepcopy(shaft['boarded']), + waiting_count=sum(t['shaft_id'] == shaft['shaft_id'] and t['state'] == 'QUEUED' + for t in saved['trips'])) + rows.append(row) + # WorldPreview sorts its report's trip copy for queue placement. + trips = sorted(copy.deepcopy(saved['trips']), key=lambda t: (t['enqueued_at'], t['actor_id'])) + return dict(current_minute=saved['current_minute'], shafts=rows, trips=trips) + + +def close(actual, expected, label): + if isinstance(expected, (float, int)) and not isinstance(expected, bool): + require(type(actual) in (int, float) and math.isfinite(actual) + and math.isclose(actual, expected, abs_tol=0.0001, rel_tol=0.000001), label) + elif isinstance(expected, dict): + require(isinstance(actual, dict) and actual.keys() == expected.keys(), label + ' keys') + for key in expected: + close(actual[key], expected[key], label + '/' + str(key)) + elif isinstance(expected, list): + require(isinstance(actual, list) and len(actual) == len(expected), label + ' length') + for a, e in zip(actual, expected): + close(a, e, label) + else: + require(type(actual) is type(expected) and actual == expected, label) + + +class Transform: + """Independent port of the shared cell/map transform; no report rect input.""" + def __init__(self, runtime, report): + self.bounds = runtime['world']['bounds'] + b = self.bounds + columns, rows = b['max_x'] - b['min_x'] + 1, b['max_y'] - b['min_y'] + 1 + width, height = report['viewport'] + require(width > 0 and height > 0 and report['zoom'] > 0, 'camera dimensions') + fit = min(max(width - 152, 1) / (columns * 16), max(height - 140, 1) / (rows * 24)) + pixel = max(math.floor(fit), 1) if fit >= 1 else max(fit, .25) + self.dim = [max(math.floor(v * pixel * report['zoom'] + .5), minimum) + for v, minimum in ((16, 4), (24, 6))] + self.origin = [math.floor((extent - count * dim) / 2 + pan) + for extent, count, dim, pan in zip((width, height), (columns, rows), self.dim, report['pan'])] + + def rect(self, cell): + b = self.bounds + return [self.origin[0] + (cell[0] - b['min_x']) * self.dim[0], + self.origin[1] + (b['max_y'] - cell[1]) * self.dim[1], *self.dim] + + def center(self, cell): + x, y, w, h = self.rect(cell) + return [x + w / 2, y + h / 2] + + def room(self, cells): + rects = [self.rect(c) for c in cells] + x, y = min(r[0] for r in rects), min(r[1] for r in rects) + return [x, y, max(r[0] + r[2] for r in rects) - x, max(r[1] + r[3] for r in rects) - y] + + +def compare_traffic(snapshot, report): + runtime = snapshot['systems']['runtime'] + transform = Transform(runtime, report) + rows = [] + for saved in sorted(runtime['path']['cell_traffic'], key=lambda r: (r['cell'][1], r['cell'][0])): + cell = saved['cell'] + if cell[2] != report['layer']: + continue + x, y, w, h = transform.rect(cell) + rows.append(dict(cell=cell, traffic={k: v for k, v in saved.items() if k != 'cell'}, + rect=[x + 2, y + 2, w - 4, h - 4])) + close(report['traffic'], rows, 'traffic and cell rect') + + +def compare_queues(snapshot, report): + runtime = snapshot['systems']['runtime'] + transform = Transform(runtime, report) + facilities = codec.indexed(runtime['facilities']['instances'], 'instance_id') + requests = codec.indexed(runtime['facilities']['requests'], 'request_id') + guests = codec.indexed(runtime['guests']['guests'], 'guest_id') + groups = {} + def add(key, cell): + if cell[2] != report['layer']: + return + members = groups.setdefault(tuple(cell), []) + if key not in members: + members.append(key) + for facility in facilities.values(): + for rid in facility['queue_entries']: + request = requests.get(rid) + if request is None: + continue + for gid in request['guest_ids']: + guest = guests.get(gid) + if guest and guest['state'] == 'QUEUING': + add('guest/' + gid, guest['current_cell']) + elevators = elevator_report(snapshot) + for trip in elevators['trips']: + if trip['state'] == 'QUEUED': + add(trip['actor_id'].replace('guest:', 'guest/', 1).replace('staff:', 'staff/', 1), trip['from']) + lift_cells = {tuple(cell) for shaft in elevators['shafts'] for cell in shaft['stops']} + walkable = {tuple(cell) for cell in runtime['world']['walkable_cells']} + owners = {tuple(cell): row['entity_id'] for row in runtime['world']['occupancies'] for cell in row['cells']} + reserved, positions, hidden, badges = set(), {}, {}, [] + for cell in sorted(groups, key=lambda c: (c[1], c[0])): + members = groups[cell] + lane = [] if cell in lift_cells else [cell] + for direction in (-1, 1): + for distance in range(1, 9): + target = (cell[0] + direction * distance, cell[1], cell[2]) + facility = facilities.get(owners.get(target)) + if target not in walkable or (facility and facility['definition_id'] not in ('guest_corridor', 'service_corridor')): + break + lane.append(target) + index = 0 + for target in lane: + if index >= len(members): + break + if target in reserved: + continue + reserved.add(target) + positions[members[index]] = transform.center(target) + index += 1 + hidden.update({member: True for member in members[index:]}) + overflow = len(members) - index + rect = transform.rect(cell) + badges.append(dict(at=[rect[0] + (-80 if cell in lift_cells else 0), rect[1] - 16], + text=f'待ち{len(members)}人' + (f'(他{overflow}人)' if overflow else ''), + cell=list(cell), members=members, visible_count=index, overflow=overflow)) + for key, expected in [('queue_positions', positions), ('queue_hidden', hidden), ('landing_badges', badges)]: + close(report[key], expected, key) + + +def compare_room_services(snapshot, report, root=ROOT): + """Check authoritative service occupants and reasons; rest selection remains pending. + + Extra rest/rescue occupants are checked separately below, never silently + counted as service users. This does not claim the missing path-cost proof. + """ + runtime = snapshot['systems']['runtime'] + states = enum_names(root, 'scripts/facility/facility_instance.gd', 'State') + operations = enum_names(root, 'scripts/facility/facility_instance.gd', 'OperationState') + rooms = [r for r in runtime['facilities']['instances'] if r['origin'][2] == report['layer']] + treated, carried = {}, {} + for task in runtime['staff']['tasks']: + if task['task_type'] != 'rescue_transport' or task['state'] in ('COMPLETED', 'CANCELLED', 'FAILED') or not task['assigned_staff_id']: + continue + if task['state'] == 'WORKING': + treated[task['target_entity_id']] = task['rescue_facility_id'] + else: + carried[task['target_entity_id']] = task['assigned_staff_id'] + close(report['treated_patients'], treated, 'rescue treatment') + close(report['carried_patients'], carried, 'rescue carrier') + staff = codec.indexed(runtime['staff']['staff'], 'staff_id') + all_rooms = codec.indexed(runtime['facilities']['instances'], 'instance_id') + for sid, fid in report['resting_staff'].items(): + require(sid in staff and fid in all_rooms, 'rest reference') + member, room = staff[sid], all_rooms[fid] + require(member['state'] == 'OFF_DUTY' and member['health'] > 0 and not member['current_task_id'], 'resting staff eligibility') + require(room['definition_id'] in ('staff_waiting_room', 'staff_dormitory') + and room['state'] in ('OPEN', 'DEGRADED') and not room['suspension_reason'], 'operating rest room') + require(set(report['rooms']) == {r['instance_id'] for r in rooms}, 'room identity') + for room in rooms: + observed = report['rooms'][room['instance_id']] + users = sorted({gid for service in room['active_services'] for gid in service['guest_ids']}) + users += [sid for sid in staff if report['resting_staff'].get(sid) == room['instance_id']] + users += [task['target_entity_id'] for task in runtime['staff']['tasks'] + if task['task_type'] == 'rescue_transport' and task['state'] == 'WORKING' + and task['assigned_staff_id'] and task['rescue_facility_id'] == room['instance_id']] + require(observed['people'] == users, 'service/rest/rescue occupant list') + require(observed['count'] == len(observed['people']), 'room count') + operation = 'SUSPENDED' if room['suspension_reason'] else ('OPERATING' if room['state'] in ('OPEN', 'DEGRADED') else 'INACTIVE') + expected = dict(state=states.index(room['state']), state_name=room['state'], + reasons=room['state_reasons'], operation=operations.index(operation), + limit=room['customer_capacity'], staff=room['active_staff_ids'], suspension=room['suspension_reason']) + for key, value in expected.items(): + close(observed[key], value, 'room/' + key) + cells = next((r['cells'] for r in runtime['world']['occupancies'] if r['entity_id'] == room['instance_id']), []) + if cells: + close(observed['rect'], Transform(runtime, report).room(cells), 'room rect') + require(observed['definition_id'] == room['definition_id'], 'room definition') + + +def comparison_records(directory): + records = {} + for line in (directory / 'manifest.jsonl').read_text().splitlines(): + row = json.loads(line) + if 'object' not in row: + continue + value = read_object(directory, row['object']) + if row['label'] == 'event': + continue + require(row['label'] not in records, 'duplicate experiment record') + records[row['label']] = value + return records + + +def compare_goods(snapshot, report, root=ROOT): + runtime = snapshot['systems']['runtime'] + states = enum_names(root, 'scripts/inventory/logistics_request.gd', 'State') + candidates = codec.indexed(json.loads((root / 'data/characters/mvp_staff_candidates.json').read_text())['entries'], 'id') + staff = codec.indexed(runtime['staff']['staff'], 'staff_id') + facilities = runtime['facilities']['instances'] + def owner(inventory): + return next((f['instance_id'] for f in facilities if f['local_inventory_id'] == inventory), '') + rows, active, recent = [], [], [] + for request in sorted(runtime['logistics_requests'], key=lambda r: r['request_id']): + require(request['state'] in states, 'logistics enum') + task = next((t for t in runtime['staff']['tasks'] + if t['target_entity_id'] == request['request_id'] + and t['task_type'] in ('supply_pickup', 'supply_delivery', 'supply_return') + and t['state'] not in ('COMPLETED', 'CANCELLED', 'FAILED')), None) + carrier = request['assigned_carrier_id'] + row = dict(source_facility_id=owner(request['source_inventory_id']), + destination_facility_id=owner(request['destination_inventory_id']), + carrier_name=candidates.get(carrier, {}).get('display_name', carrier) if carrier in staff else '', + reason_codes=[request['state_reason']] if request['state_reason'] else [], + request_id=request['request_id'], state=request['state'], reason=request['state_reason'], + carrier_id=carrier, source_inventory_id=request['source_inventory_id'], + destination_inventory_id=request['destination_inventory_id'], + item_requirements=request['item_requirements'], weight=request['total_weight'], + retry_count=request['retry_count'], task_id=task['task_id'] if task else '') + rows.append(row) + if request['state'] in ('COMPLETED', 'CANCELLED'): + recent.append((request['state_changed_at'], request['request_id'], row)) + else: + active.append(row) + recent.sort(key=lambda r: (r[0], r[1]), reverse=True) + close(report['goods'], dict(requests=rows, deliveries=active + [r[2] for r in recent[:10]]), 'goods') + + +def compare_cabin_geometry(snapshot, report): + runtime = snapshot['systems']['runtime'] + transform = Transform(runtime, report) + expected = [] + p = transform.dim[0] / 16 + for shaft in elevator_report(snapshot)['shafts']: + if not shaft['stops'] or shaft['stops'][0][2] != report['layer']: + continue + floor = shaft['visual_floor'] + track = report['elevator_tracks'][shaft['shaft_id']] + close(track['floor'], floor, 'authoritative elevator track endpoint') + require(track['at'] <= report['animation_time'], 'elevator track time') + if not report['paused']: + # The prior-frame origin still needs a chained visual-history proof. + t = max(0, min(1, (report['animation_time'] - track['at']) / .42)) + floor = track['from'] + (floor - track['from']) * t + x, y, w, h = transform.rect(shaft['stops'][0]) + baseline = y + h - (floor - shaft['stops'][0][1]) * transform.dim[1] - 3 * p + def rounded(v): + return math.copysign(math.floor(abs(v) + .5), v) + rect = [rounded(x + 4*p), rounded(baseline - 19*p), max(shaft.get('width', 2), 1)*w - 8*p, 19*p] + if rect[0] >= report['viewport'][0] or rect[1] >= report['viewport'][1] or rect[0]+rect[2] <= 0 or rect[1]+rect[3] <= 0: + continue + count, capacity = len(shaft['boarded']), shaft['capacity'] + badge = f'{count}/{capacity}人' + if capacity > 0 and count >= capacity: + badge = '満員 ' + badge + if not shaft['operational']: + badge = '停止 ' + badge + expected.append(dict(shaft_id=shaft['shaft_id'], rect=rect, pixel=p, operational=shaft['operational'], + doors_open=shaft['operational'] and shaft['phase'] in ('IDLE', 'BOARDING', 'ALIGHTING'), + badge_at=[rect[0], rect[1]-14], badge=badge)) + require(len(report['cabins']) == len(expected), 'cabin count') + for row, wanted in zip(report['cabins'], expected): + for key, value in wanted.items(): + close(row[key], value, 'cabin/' + key) + + +def compare_layouts(a, b): + require(a['declaration'] == b['declaration'], 'identical predeclared inputs') + declaration = a['declaration'] + require(declaration['metric'] == 'sum_destination_arrival_minutes_entire_cohort', 'predeclared metric') + require(a['initial'] == b['initial'], 'identical seed budget clock definitions and initial state') + arrivals = {r['id']: r['offset'] for r in declaration['arrivals']} + require(len(arrivals) == len(declaration['arrivals']) and arrivals, 'whole nonempty cohort') + builds = [] + metrics = [] + for records in (a, b): + commands = [records[k] for k in sorted(records) if k.startswith('build-')] + require(all(r['result']['code'] == 'OK' for r in commands), 'paid construction succeeded') + builds.append([r['payload'] for r in commands]) + require({k.removeprefix('arrival-') for k in records if k.startswith('arrival-')} == set(arrivals), 'arrival cohort identity') + for gid, offset in arrivals.items(): + row = records['arrival-' + gid] + require(row['offset'] == offset and row['result']['code'] == 'OK', 'actual accepted arrival') + require(row['payload'] == a['arrival-' + gid]['payload'], 'same actual arrival command') + completed = {} + first_minute = records['tick-000']['minute'] + for offset in range(declaration['window'] + 1): + tick = records['tick-%03d' % offset] + require(tick['minute'] == first_minute + offset, 'contiguous actual clock') + for guest in tick['guests']['guests']: + gid = guest['guest_id'] + require(gid in arrivals and offset >= arrivals[gid], 'actual cohort membership') + if guest['current_cell'] == declaration['to']: + completed.setdefault(gid, offset - arrivals[gid]) + require(set(completed) == set(arrivals), 'no unfinished or omitted journeys') + metrics.append(sum(completed.values())) + require(builds[1] == builds[0] + [declaration['improvement']], 'only predeclared layout improvement') + require(b['tick-000']['minute'] == a['tick-000']['minute'], 'same observation start') + require(metrics[1] < metrics[0], 'strict whole-cohort improvement') + return dict(metric=declaration['metric'], A=metrics[0], B=metrics[1]) + + +def read_object(directory, digest): + require(isinstance(digest, str) and re.fullmatch('[0-9a-f]{64}', digest), 'invalid object digest') + path = directory / 'objects' / (digest + '.json') + require(not path.is_symlink() and sha(path) == digest, 'whole typed object hash') + typed = json.loads(path.read_bytes()) + require(typed.get('type') == 'Dictionary', 'whole dictionary required') + return decode(typed) + + +def inspect_stream(directory, root=ROOT): + labels = set() + checked = [] + with (directory / 'manifest.jsonl').open() as stream: + for line in stream: + row = json.loads(line) + if 'object' in row: + read_object(directory, row['object']) + continue + require(row['label'] not in labels, 'duplicate streamed label') + labels.add(row['label']) + require(type(row['pid']) is int and row['pid'] > 0, 'actual diagnostic PID required') + state = read_object(directory, row['state']) + report = read_object(directory, row['report']) + fields = compare_actor_rows(state, report, root) + require(report['elevators'] == elevator_report(state), 'typed elevator report mismatch') + compare_room_services(state, report, root) + compare_queues(state, report) + compare_traffic(state, report) + compare_goods(state, report, root) + compare_cabin_geometry(state, report) + checked.append(dict(label=row['label'], fields=fields + ['elevators', 'room_service_fields', + 'queues', 'traffic', 'goods', 'cabin_geometry'])) + require(checked, 'empty diagnostic stream') + return checked + + +def condition_matrix(): + return [dict(condition=name, status='blocked', reason=BLOCKERS[name]) for name in CONDITIONS] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--diagnostics', type=Path) + parser.add_argument('--layout-a', type=Path) + parser.add_argument('--layout-b', type=Path) + args = parser.parse_args(argv) + # Registration hashes the immutable JSON after trimming outer whitespace. + require(hashlib.sha256((ROOT / SPEC).read_text().strip().encode()).hexdigest() == SPEC_SHA, + 'immutable spec mismatch') + checked = inspect_stream(args.diagnostics.resolve()) if args.diagnostics else [] + require(bool(args.layout_a) == bool(args.layout_b), 'both layout streams required') + comparison = compare_layouts(comparison_records(args.layout_a), comparison_records(args.layout_b)) if args.layout_a else None + print(json.dumps(dict(task_id='P23-03', status='blocked', semantic_diagnostics=checked, + layout_comparison=comparison, condition_matrix=condition_matrix(), root_evidence='pending'), ensure_ascii=False)) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/run_p23_life_observation.py b/tools/run_p23_life_observation.py new file mode 100644 index 0000000..25d2cbd --- /dev/null +++ b/tools/run_p23_life_observation.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Read-only P23 readiness report. Collection is blocked in this candidate. + +No runner, engine, profile, subprocess, browser or OS input is started. Root must +not mistake this preparation interface for a submitted native/OS collector. +""" +import json +import sys + +sys.dont_write_bytecode = True +from check_p23_life_observation_evidence import condition_matrix + + +def main(): + print(json.dumps({'task_id': 'P23-03', 'status': 'blocked', 'engine_runs': 0, + 'condition_matrix': condition_matrix(), + 'required_infrastructure': ['run_all_tests.py', 'strict_test_markers.py', + 'regression/owned_outer.py', + 'regression/ordinary_proof_gate.py'], + 'root_evidence': 'missing'}, ensure_ascii=False)) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/test_p23_life_observation_evidence.py b/tools/test_p23_life_observation_evidence.py new file mode 100644 index 0000000..db46ca6 --- /dev/null +++ b/tools/test_p23_life_observation_evidence.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Local semantic-checker tests; synthetic examples are never game evidence.""" +import copy +import unittest +import sys + +sys.dont_write_bytecode = True +import check_p23_life_observation_evidence as check + + +class SemanticTests(unittest.TestCase): + def setUp(self): + self.guest = dict(guest_id='g', state='QUEUING', current_cell=[1, 0, 0], + target_facility_id='f', current_service_id='', queue_request_id='q') + self.staff = dict(staff_id='s', state='WORKING', current_cell=[2, 0, 1], + current_task_id='t', assigned_facility_id='f', role_id='porter', + carried_inventory_id='cargo') + self.task = dict(task_id='t', state='WORKING', task_type='delivery', + assigned_staff_id='s', target_entity_id='request', rescue_facility_id='') + self.snapshot = {'systems': {'runtime': {'guests': {'guests': [self.guest]}, + 'staff': {'staff': [self.staff], 'tasks': [self.task]}}}} + self.report = dict(guests=[dict(id='g', state='QUEUING', cell=[1, 0, 0], target='f', service='', queue='q')], + staff=[dict(id='s', state='WORKING', cell=[2, 0, 1], task='t', facility='f', + role='porter', carried_inventory_id='cargo')], + tasks=[dict(id='t', state='WORKING', type='delivery', staff='s', + target='request', rescue_facility='')]) + + def test_real_schema_projection(self): + self.assertEqual(check.compare_actor_rows(self.snapshot, self.report), ['guests', 'staff', 'tasks']) + + def test_changed_state_position_target_task_and_goods_are_rejected(self): + for category, field, replacement in [('guests', 'cell', [8, 0, 0]), ('guests', 'target', 'other'), + ('guests', 'state', 'MOVING'), ('staff', 'task', ''), + ('staff', 'carried_inventory_id', ''), ('tasks', 'staff', 'other')]: + with self.subTest(category=category, field=field): + report = copy.deepcopy(self.report) + report[category][0][field] = replacement + with self.assertRaises(ValueError): + check.compare_actor_rows(self.snapshot, report) + + def test_missing_extra_and_boolean_only_rows_are_rejected(self): + for bad in [[], self.report['guests'] * 2, [True]]: + report = copy.deepcopy(self.report) + report['guests'] = bad + with self.assertRaises(ValueError): + check.compare_actor_rows(self.snapshot, report) + + def test_unknown_enum_and_duplicate_id_are_rejected(self): + self.guest['state'] = 'FICTIONAL' + with self.assertRaises(ValueError): + check.compare_actor_rows(self.snapshot, self.report) + self.guest['state'] = 'QUEUING' + self.snapshot['systems']['runtime']['guests']['guests'].append(copy.deepcopy(self.guest)) + with self.assertRaises(ValueError): + check.compare_actor_rows(self.snapshot, self.report) + + def test_typed_rectangle_and_duplicate_vector_key(self): + self.assertEqual(check.decode(dict(type='Rect2', text='Rect2(1, 2, 16, 24)')), [1, 2, 16, 24]) + with self.assertRaises(ValueError): + check.decode(dict(type='Rect2', text='Rect2(1, nan, 16, 24)')) + key = dict(type='Vector3i', text='Vector3i(1, 2, 0)') + value = dict(type='int', text='1') + with self.assertRaises(ValueError): + check.decode(dict(type='Dictionary', entries=[[key, value], [key, value]])) + + def test_immutable_typed_input_is_not_modified(self): + before = copy.deepcopy(self.snapshot) + check.compare_actor_rows(self.snapshot, self.report) + self.assertEqual(before, self.snapshot) + + +class SpatialTests(unittest.TestCase): + def setUp(self): + self.runtime = dict(world=dict(bounds=dict(min_x=0, max_x=1, min_y=0, max_y=0), + walkable_cells=[[0, 0, 0], [1, 0, 0]], occupancies=[]), + path=dict(cell_traffic=[dict(cell=[0, 0, 0], base_capacity=2, + current_density=3, dynamic_cost=0, temporary_block=False)]), + facilities=dict(instances=[], requests=[]), guests=dict(guests=[]), + staff=dict(staff=[], tasks=[]), logistics_requests=[], + elevators=dict(current_minute=0, shafts=[], trips=[])) + self.snapshot = dict(systems=dict(runtime=self.runtime)) + self.report = dict(viewport=[184, 164], zoom=1, pan=[0, 0], layer=0, + traffic=[dict(cell=[0, 0, 0], rect=[78, 72, 12, 20], + traffic=dict(base_capacity=2, current_density=3, + dynamic_cost=0, temporary_block=False))], + queue_positions={}, queue_hidden={}, landing_badges=[], rooms={}, + treated_patients={}, carried_patients={}, resting_staff={}, + goods=dict(requests=[], deliveries=[])) + + def test_density_and_pan_are_independently_recomputed(self): + check.compare_traffic(self.snapshot, self.report) + for key, value in [('current_density', 0), ('base_capacity', 9)]: + changed = copy.deepcopy(self.report) + changed['traffic'][0]['traffic'][key] = value + with self.assertRaises(ValueError): + check.compare_traffic(self.snapshot, changed) + self.report['pan'][0] = 20 + with self.assertRaises(ValueError): + check.compare_traffic(self.snapshot, self.report) + + def test_missing_queue_overflow_rejected(self): + self.runtime['elevators']['trips'] = [dict(actor_id='guest:g'+str(i), enqueued_at=i, + state='QUEUED', **{'from': [0, 0, 0]}) for i in range(3)] + self.report.update(queue_positions={'guest/g0': [84, 82], 'guest/g1': [100, 82]}, + queue_hidden={'guest/g2': True}, landing_badges=[dict(at=[76, 54], + text='待ち3人(他1人)', cell=[0, 0, 0], + members=['guest/g0', 'guest/g1', 'guest/g2'], visible_count=2, overflow=1)]) + check.compare_queues(self.snapshot, self.report) + self.report['landing_badges'][0]['overflow'] = 0 + with self.assertRaises(ValueError): + check.compare_queues(self.snapshot, self.report) + + def test_fictional_goods_and_rooms_rejected(self): + check.compare_goods(self.snapshot, self.report) + check.compare_room_services(self.snapshot, self.report) + self.report['goods']['requests'].append(dict(stock=100)) + with self.assertRaises(ValueError): + check.compare_goods(self.snapshot, self.report) + self.report['rooms']['invented'] = dict(count=0) + with self.assertRaises(ValueError): + check.compare_room_services(self.snapshot, self.report) + + def test_room_reason_and_rescue_occupancy(self): + room = dict(instance_id='clinic', origin=[0, 0, 0], definition_id='infirmary', + state='NO_POWER', state_reasons=['NO_POWER'], suspension_reason='', + customer_capacity=2, active_staff_ids=[], active_services=[], occupied_cells=[]) + self.runtime['facilities']['instances'] = [room] + self.runtime['staff']['tasks'] = [dict(task_id='rescue', task_type='rescue_transport', + state='WORKING', assigned_staff_id='carrier', target_entity_id='patient', rescue_facility_id='clinic')] + self.report['treated_patients'] = {'patient': 'clinic'} + self.report['rooms'] = {'clinic': dict(state=2, state_name='NO_POWER', reasons=['NO_POWER'], + operation=0, people=['patient'], count=1, limit=2, staff=[], suspension='')} + check.compare_room_services(self.snapshot, self.report) + for field, wrong in [('state', 6), ('operation', 1), ('people', []), ('count', 0)]: + changed = copy.deepcopy(self.report) + changed['rooms']['clinic'][field] = wrong + with self.assertRaises(ValueError): + check.compare_room_services(self.snapshot, changed) + + def test_cabin_geometry_and_phase(self): + shaft = dict(shaft_id='lift', config=dict(stops=[[0, 0, 0]], width=2, capacity=4), + current_floor=0, phase='IDLE', operational=True, boarded=[]) + self.runtime['elevators']['shafts'] = [shaft] + self.report.update(paused=True, animation_time=0, elevator_tracks={'lift': dict(floor=0, at=0)}, + cabins=[dict(shaft_id='lift', rect=[80, 72, 24, 19], pixel=1, operational=True, + doors_open=True, badge_at=[80, 58], badge='0/4人', passengers=[])]) + check.compare_cabin_geometry(self.snapshot, self.report) + for field, value in [('rect', [80, 70, 24, 19]), ('doors_open', False), ('badge', '4/4人')]: + changed = copy.deepcopy(self.report) + changed['cabins'][0][field] = value + with self.assertRaises(ValueError): + check.compare_cabin_geometry(self.snapshot, changed) + + +class ComparisonTests(unittest.TestCase): + def pair(self): + declaration = dict(metric='sum_destination_arrival_minutes_entire_cohort', + arrivals=[dict(id='g', offset=0)], window=2, to=[2, 1, 0], + improvement=dict(definition_id='guest_elevator', origin=[4, 0, 0])) + a = dict(declaration=declaration, initial=dict(seed=1)) + a['arrival-g'] = dict(offset=0, payload=dict(guest_id='g'), result=dict(code='OK')) + for minute in range(3): + a['tick-%03d' % minute] = dict(minute=minute, guests=dict(guests=[dict(guest_id='g', + current_cell=[2, 1, 0] if minute == 2 else [2, 0, 0])])) + b = copy.deepcopy(a) + b['build-0'] = dict(payload=declaration['improvement'], result=dict(code='OK')) + b['tick-001']['guests']['guests'][0]['current_cell'] = [2, 1, 0] + return a, b + + def test_strict_metric_from_typed_cells(self): + a, b = self.pair() + self.assertEqual(check.compare_layouts(a, b)['B'], 1) + for mutation in ('demand', 'incomplete', 'no_improvement', 'extra_build'): + with self.subTest(mutation=mutation): + a, b = self.pair() + if mutation == 'demand': + b['arrival-g']['payload']['guest_id'] = 'other' + elif mutation == 'incomplete': + for minute in (1, 2): + b['tick-%03d' % minute]['guests']['guests'][0]['current_cell'] = [2, 0, 0] + elif mutation == 'no_improvement': + b['tick-001'] = copy.deepcopy(a['tick-001']) + else: + b['build-1'] = copy.deepcopy(b['build-0']) + with self.assertRaises(ValueError): + check.compare_layouts(a, b) + + +if __name__ == '__main__': + unittest.main()