Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9641ed52de | ||
|
|
666c87bd7d |
@@ -2,6 +2,17 @@
|
||||
"schema_version": 1,
|
||||
"scope": "exact per-suite completion label/schema; all check counts from actual raw only",
|
||||
"rows": [
|
||||
{
|
||||
"test": "tests/ui/test_operations_notification.gd",
|
||||
"template": "Operations notification: <checks> checks, <failures> failures",
|
||||
"required": true,
|
||||
"fixed_check_count_used": false,
|
||||
"source_sha256_at_schema_audit": "9c26386c529b8c8135639e8d0f0ed390da7139d64ff1e268975b05948cab9147",
|
||||
"authority": {
|
||||
"path": "docs/spec/acceptance/P24-02-code-r01.json",
|
||||
"scope": "new local suite source marker; actual execution pending"
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "tests/ui/test_selection_departure.gd",
|
||||
"template": "Selection departure: <checks> checks, <failures> failures",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"contract_id": "P24-02",
|
||||
"title": "操作と通知の実装および証跡収集プロトコル",
|
||||
"worker": "agy",
|
||||
"base_commit": "1a5abc65163097d4b36a060d68f58c92093eb4e9",
|
||||
"spec_sha256": "738268b406e381ccab415b426d4a547b02e7cb2a481cefce65579a1e346834c7",
|
||||
"formal_acceptance_note": "Candidate source alone does not constitute acceptance completion. Native Godot execution has not been run by this worker. Root owns formal test execution, full regression, OS evidence generation, and final submission.",
|
||||
"implemented_paths": [
|
||||
"scenes/main/main.gd",
|
||||
"scenes/main/world_preview.gd",
|
||||
"scripts/ui/event_log_dialog.gd",
|
||||
"scripts/ui/manual_save_slots_dialog.gd",
|
||||
"scripts/ui/operation_feedback.gd",
|
||||
"scripts/ui/operation_feedback.gd.uid",
|
||||
"scripts/ui/operations_camera_controls.gd",
|
||||
"scripts/ui/operations_camera_controls.gd.uid",
|
||||
"tests/ui/test_operations_notification.gd",
|
||||
"tests/ui/test_operations_notification.gd.uid",
|
||||
"tests/ui/fixtures/operations_os_observer.gd",
|
||||
"tests/ui/fixtures/operations_os_observer.gd.uid",
|
||||
"tools/check_p24_operations_evidence.py"
|
||||
],
|
||||
"conditions_status": {
|
||||
"placement_and_invalid_drop": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "code_implemented_unverified_pending_root_execution",
|
||||
"details": "Grid snap, connection line warning, single-commit valid placement, rejection reason Japanese feedback without model/ledger corruption, click-based assignment alternative implemented in candidate code. Native Godot execution unverified, pending Root formal execution."
|
||||
},
|
||||
"modal_and_pause_resume": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "code_implemented_unverified_pending_root_execution",
|
||||
"details": "Modal open/close, focus management, modal_input_blocked suppressing behind-dialog input and assignment drops. Clock pause freezing advancement via real delta early-return; single resume continuing simulation. Pending commands and save identity intact on cancel/close. Native Godot execution unverified, pending Root formal execution."
|
||||
},
|
||||
"unit_bubbles": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "code_implemented_unverified_pending_root_execution",
|
||||
"details": "Unit overhead bubbles for Zzz (sleep/incapacitated/break), anger (strike/fleeing/abandoning/interrupted), heart (authoritative RelationshipState / RouteLock / love-shield). No unapproved loyalty/morale thresholds. Stale symbol removal logic implemented. Native Godot execution unverified, pending Root formal execution."
|
||||
},
|
||||
"event_log": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "code_implemented_unverified_pending_root_execution",
|
||||
"details": "Bounded recent 256 events, readable Japanese presentation, formatted amounts/times/reasons, duplicate event ID elimination. Reusing existing 256-entry stream contract. Native Godot execution unverified, pending Root formal execution."
|
||||
},
|
||||
"important_warning_cutin": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "partially_implemented_upstream_dependencies_pending",
|
||||
"details": "ImportantWarningCutinDialog connected to real DEFENSE_RAID_CREATED and RELATIONSHIP_SHIELD_CHANGED (shield=0, love-shield vanishing) domain events with stop/resume behavior. Generic INCIDENT_CREATED is not falsely treated as NTR. Duel occurrence triggers and full invasion upstream flow remain unimplemented upstream dependencies, explicitly documented as missing and not claimed complete. Native Godot execution unverified, pending Root formal execution."
|
||||
},
|
||||
"click_alternatives": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "code_implemented_unverified_pending_root_execution",
|
||||
"details": "OperationsCameraControls providing click equivalents for zoom in/out, pan up/down/left/right, reset view, grid toggle, layer toggle, and click-based staff assignment. Does not alter gameplay model. Manual P23 held gestures remain deferred. Native Godot execution unverified, pending Root formal execution."
|
||||
},
|
||||
"japanese_input_and_scale": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "code_implemented_unverified_pending_windows_and_root_execution",
|
||||
"details": "ManualSaveSlotsDialog LineEdit for editable Japanese save label (display_name metadata only), control char validation, default name fallback, cryptographic owned path (manual_ui_<hex32>) preservation, cancel discard semantics. Native Godot OS input handling (not browser). Same-Windows-release platform acceptance remains pending formal execution."
|
||||
},
|
||||
"final_validation_review": {
|
||||
"code_implemented": true,
|
||||
"formally_accepted": false,
|
||||
"status": "pending_root_full_roster_and_opus_review",
|
||||
"details": "Local unit suite (tests/ui/test_operations_notification.gd) with FixtureCleanup and OperationsOSObserver implemented and hashed. check_p24_operations_evidence.py strict verify-only checker implemented. Formal full regression, native OS verification, and independent separate Claude Opus review are owned by Root Runner and pending execution."
|
||||
}
|
||||
},
|
||||
"runner_verification_tasks": {
|
||||
"native_godot_test_suite": "Run tests/ui/test_operations_notification.gd under native Godot runner with isolated profile.",
|
||||
"evidence_checker": "tools/check_p24_operations_evidence.py --verify-only",
|
||||
"root_full_roster": "Root owns formal execution of required test roster on exact final game/source/settings.",
|
||||
"windows_release_acceptance": "Candidate Windows platform execution preserved for formal closure; not transferred from prior evidence.",
|
||||
"independent_opus_review": "Independent separate Claude review with actual runtime evidence (not pending templates alone) before formal submit."
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
Owner-approved registered P24-02 repair3/3, exact existing W HEAD666c87bd. Earlier GUI messages were truncated input and are superseded. Apply only these two exact patches to scenes/main/main.gd; preserve all gameplay values/conditions and save semantics. Do not launch Godot, retry LSP, edit project.godot/caches/player data/settings/other sources, remove files, change permissions/billing, commit or push. Only git diff --check and git diff -- scenes/main/main.gd for static checks; Root owns native and OS validation. After editing report exact diff and that native/acceptance remain pending.
|
||||
|
||||
--- a/scenes/main/main.gd
|
||||
+++ b/scenes/main/main.gd
|
||||
@@ -793 +793 @@
|
||||
- load_menu.text = "読込 ▾"
|
||||
+ load_menu.text = "保存・読込 ▾"
|
||||
@@ -799,0 +800 @@
|
||||
+ load_menu.get_popup().add_item("新しい名前で別スロット保存",3)
|
||||
@@ -803 +804,2 @@
|
||||
- elif id == 2: _show_manual_load_slots())
|
||||
+ elif id == 2: _show_manual_load_slots()
|
||||
+ elif id == 3: _ensure_manual_save_slots().show_new_save(manual_save_path))
|
||||
|
||||
--- a/scenes/main/main.gd
|
||||
+++ b/scenes/main/main.gd
|
||||
@@ -3199,2 +3199,2 @@
|
||||
- show_warning_cutin(OperationFeedbackScript.CUTIN_INVASION, "重要警告:愛の盾消失", "愛の盾が消失しました。人物関係を確認してください。")
|
||||
-
|
||||
+ show_warning_cutin(OperationFeedbackScript.CUTIN_INVASION, "重要警告:NTR侵食開始", "愛の盾が破られ、NTR侵食が始まりました。人物関係を確認してください。")
|
||||
+
|
||||
+119
-2
@@ -22,6 +22,8 @@ const SupplyManagementPanelScript := preload("res://scripts/ui/supply_management
|
||||
const BusinessManagementPanelScript := preload("res://scripts/ui/business_management_panel.gd")
|
||||
const DefenseManagementPanelScript := preload("res://scripts/ui/defense_management_panel.gd")
|
||||
const WorldPreviewScript := preload("res://scenes/main/world_preview.gd")
|
||||
const OperationsCameraControlsScript := preload("res://scripts/ui/operations_camera_controls.gd")
|
||||
const OperationFeedbackScript := preload("res://scripts/ui/operation_feedback.gd")
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres")
|
||||
var _last_presented_close_minute := 0
|
||||
@@ -145,6 +147,8 @@ var alert_icon_node: TextureRect
|
||||
var resource_labels: Dictionary = {}
|
||||
var resource_icon_nodes: Dictionary = {}
|
||||
var world_preview: WorldPreview
|
||||
var operations_camera_controls: OperationsCameraControls
|
||||
var important_warning_cutin: OperationFeedback.ImportantWarningCutinDialog
|
||||
var front_button: Button
|
||||
var back_button: Button
|
||||
var excavation_button: Button
|
||||
@@ -206,6 +210,7 @@ var _pending_excavation_commands: Dictionary = {}
|
||||
var _pending_hire_commands: Dictionary = {}
|
||||
var _staff_candidate_panel_open := false
|
||||
var _operations_start_report: Dictionary = {}
|
||||
var _subscribed_event_stream: Variant = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -244,6 +249,7 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_unbind_domain_event_stream()
|
||||
var tree := get_tree()
|
||||
if tree != null:
|
||||
if tree.root.close_requested.is_connected(_on_window_close_requested):
|
||||
@@ -402,6 +408,18 @@ func _build_interface() -> void:
|
||||
management_panel.offset_bottom = -8
|
||||
management_panel.visible = false
|
||||
|
||||
operations_camera_controls = OperationsCameraControlsScript.new()
|
||||
operations_camera_controls.name = "OperationsCameraControls"
|
||||
operations_camera_controls.configure(world_preview, self)
|
||||
world_preview.add_child(operations_camera_controls)
|
||||
operations_camera_controls.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
operations_camera_controls.offset_right = -12
|
||||
operations_camera_controls.offset_bottom = -12
|
||||
|
||||
important_warning_cutin = OperationFeedbackScript.ImportantWarningCutinDialog.new()
|
||||
important_warning_cutin.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(important_warning_cutin)
|
||||
|
||||
layout.add_child(_create_bottom_bar())
|
||||
|
||||
|
||||
@@ -441,6 +459,7 @@ func _create_top_bar() -> Control:
|
||||
_set_status(message,success)
|
||||
_refresh_dashboard()
|
||||
)
|
||||
rank_dialog.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(rank_dialog)
|
||||
rank_button.pressed.connect(rank_dialog.show_rank)
|
||||
|
||||
@@ -756,6 +775,7 @@ func _create_bottom_bar() -> Control:
|
||||
row.add_child(management_toggle_button)
|
||||
event_log_dialog = EventLogDialogScript.new()
|
||||
event_log_dialog.configure(_runtime)
|
||||
event_log_dialog.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(event_log_dialog)
|
||||
var event_log_button := _button("記録", event_log_dialog.show_events, 62)
|
||||
event_log_button.name = "EventLogButton"
|
||||
@@ -770,17 +790,19 @@ func _create_bottom_bar() -> Control:
|
||||
row.add_child(audio_button)
|
||||
var load_menu := MenuButton.new()
|
||||
load_menu.name = "SaveLoadMenu"
|
||||
load_menu.text = "読込 ▾"
|
||||
load_menu.text = "保存・読込 ▾"
|
||||
load_menu.custom_minimum_size = Vector2(64,28)
|
||||
load_menu.add_theme_font_size_override("font_size",12)
|
||||
_apply_button_style(load_menu)
|
||||
load_menu.get_popup().add_item("手動セーブを読み込む",0)
|
||||
load_menu.get_popup().add_item("旧寸法を修正して読み込む",1)
|
||||
load_menu.get_popup().add_item("保存スロットを選んで読込",2)
|
||||
load_menu.get_popup().add_item("新しい名前で別スロット保存",3)
|
||||
load_menu.get_popup().id_pressed.connect(func(id: int):
|
||||
if id == 0: _load_manual()
|
||||
elif id == 1: _preview_dimension_migration()
|
||||
elif id == 2: _show_manual_load_slots())
|
||||
elif id == 2: _show_manual_load_slots()
|
||||
elif id == 3: _ensure_manual_save_slots().show_new_save(manual_save_path))
|
||||
row.add_child(load_menu)
|
||||
|
||||
palette_scroll = ScrollContainer.new()
|
||||
@@ -947,6 +969,9 @@ func _connect_signals() -> void:
|
||||
world_preview.staff_assignment_dropped.connect(func(data: Dictionary, facility_id: StringName) -> void:
|
||||
staff_management_panel.assign_from_drop(data, facility_id)
|
||||
)
|
||||
world_preview.staff_assignment_clicked.connect(func(staff_id: StringName, facility_id: StringName) -> void:
|
||||
assign_staff_by_click(facility_id, staff_id)
|
||||
)
|
||||
world_preview.legacy_stair_selected.connect(_on_legacy_stair_selected)
|
||||
world_preview.legacy_stair_selected.connect(_on_audio_world_selection)
|
||||
world_preview.construction_selected.connect(_on_construction_selected)
|
||||
@@ -965,6 +990,7 @@ func _connect_signals() -> void:
|
||||
_runtime.connect(&"save_operation_completed", Callable(self, "_on_save_operation_completed"))
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", Callable(self, "_on_command_completed"))
|
||||
_bind_domain_event_stream()
|
||||
|
||||
|
||||
func _populate_build_palette() -> void:
|
||||
@@ -2276,6 +2302,7 @@ func _ensure_manual_save_slots() -> Variant:
|
||||
manual_save_slots_dialog = ManualSaveSlotsDialogScript.new()
|
||||
manual_save_slots_dialog.connect(&"new_slot_requested", _save_new_manual_slot)
|
||||
manual_save_slots_dialog.connect(&"load_slot_requested", _load_selected_manual_slot)
|
||||
manual_save_slots_dialog.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(manual_save_slots_dialog)
|
||||
return manual_save_slots_dialog
|
||||
|
||||
@@ -2352,6 +2379,7 @@ func _apply_loaded_interface(result: Dictionary) -> void:
|
||||
_restore_product_interface_selection(result.get(&"manifest", {}))
|
||||
_refresh_dashboard()
|
||||
_update_pause_and_speed()
|
||||
_bind_domain_event_stream()
|
||||
|
||||
|
||||
func _valid_interface_selection_version(value: Variant) -> bool:
|
||||
@@ -2937,6 +2965,7 @@ func _ensure_guest_observer() -> GuestObservationPanel:
|
||||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
guest_observer.add_child(scroll)
|
||||
scroll.add_child(content)
|
||||
guest_observer.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(guest_observer)
|
||||
return guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel
|
||||
|
||||
@@ -2964,6 +2993,7 @@ func _preview_dimension_migration() -> void:
|
||||
dimension_migration_dialog.cancel_button_text="戻る"
|
||||
dimension_migration_dialog.confirmed.connect(_confirm_dimension_migration)
|
||||
dimension_migration_dialog.canceled.connect(func(): _dimension_migration_digest="")
|
||||
dimension_migration_dialog.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(dimension_migration_dialog)
|
||||
var lines: PackedStringArray=[]
|
||||
for change: Dictionary in report[&"changes"]:
|
||||
@@ -3083,3 +3113,90 @@ func _submit_ui_command(
|
||||
if _audio_feedback != null:
|
||||
return _audio_feedback.submit_command(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
|
||||
func assign_staff_by_click(facility_id: StringName, staff_id: StringName = &"") -> void:
|
||||
if staff_management_panel == null:
|
||||
return
|
||||
var target_staff := staff_id
|
||||
if target_staff.is_empty():
|
||||
target_staff = StringName(staff_management_panel.get("selected_staff_id"))
|
||||
if target_staff.is_empty():
|
||||
_set_status("配属する従業員を一覧から選択してください。", false)
|
||||
return
|
||||
var queue_id: int = int(staff_management_panel.get("_queue_instance_id"))
|
||||
var template_val: Variant = staff_management_panel.get("_draft_template_id")
|
||||
var drop_data := {
|
||||
&"kind": &"staff_assignment",
|
||||
&"staff_id": target_staff,
|
||||
&"template_id": template_val if template_val != null else &"DAY",
|
||||
&"queue_instance_id": queue_id,
|
||||
}
|
||||
staff_management_panel.assign_from_drop(drop_data, facility_id)
|
||||
|
||||
|
||||
func _update_modal_input_guard() -> void:
|
||||
if world_preview == null:
|
||||
return
|
||||
var any_visible := false
|
||||
for dialog: Variant in [manual_save_slots_dialog, event_log_dialog, rank_dialog, dimension_migration_dialog, guest_observer, defense_manager, important_warning_cutin]:
|
||||
if dialog != null and is_instance_valid(dialog) and dialog is Window and bool(dialog.visible):
|
||||
any_visible = true
|
||||
break
|
||||
world_preview.modal_input_blocked = any_visible
|
||||
|
||||
|
||||
func _ensure_important_warning_cutin() -> OperationFeedback.ImportantWarningCutinDialog:
|
||||
if important_warning_cutin == null:
|
||||
important_warning_cutin = OperationFeedbackScript.ImportantWarningCutinDialog.new()
|
||||
important_warning_cutin.visibility_changed.connect(_update_modal_input_guard)
|
||||
add_child(important_warning_cutin)
|
||||
return important_warning_cutin
|
||||
|
||||
|
||||
func show_warning_cutin(kind: StringName, heading: String, message: String) -> void:
|
||||
_ensure_important_warning_cutin().show_cutin(kind, heading, message)
|
||||
|
||||
|
||||
func _bind_domain_event_stream() -> void:
|
||||
_unbind_domain_event_stream()
|
||||
if _runtime == null or not is_instance_valid(_runtime):
|
||||
return
|
||||
var stream: Variant = _runtime.get(&"domain_events") if _runtime is Object and "domain_events" in _runtime else null
|
||||
if stream != null and is_instance_valid(stream) and stream.has_method(&"subscribe"):
|
||||
stream.subscribe(DomainEventStream.WILDCARD_EVENT_TYPE, &"main_scene", Callable(self, "_on_domain_event_published"))
|
||||
_subscribed_event_stream = stream
|
||||
|
||||
|
||||
func _unbind_domain_event_stream() -> void:
|
||||
if _subscribed_event_stream != null and is_instance_valid(_subscribed_event_stream):
|
||||
if _subscribed_event_stream.has_method(&"unsubscribe"):
|
||||
_subscribed_event_stream.unsubscribe(DomainEventStream.WILDCARD_EVENT_TYPE, &"main_scene")
|
||||
_subscribed_event_stream = null
|
||||
|
||||
|
||||
func _on_domain_event_published(event: Variant) -> void:
|
||||
var event_type: StringName = &""
|
||||
var payload: Dictionary = {}
|
||||
if event is Dictionary:
|
||||
event_type = StringName(event.get(&"event_type", event.get("event_type", &"")))
|
||||
var raw_payload: Variant = event.get(&"payload", event.get("payload", {}))
|
||||
if raw_payload is Dictionary:
|
||||
payload = raw_payload
|
||||
elif event is Object:
|
||||
if "event_type" in event:
|
||||
event_type = StringName(event.get(&"event_type"))
|
||||
if "payload" in event:
|
||||
var raw_payload: Variant = event.get(&"payload")
|
||||
if raw_payload is Dictionary:
|
||||
payload = raw_payload
|
||||
match event_type:
|
||||
&"DEFENSE_RAID_CREATED":
|
||||
show_warning_cutin(OperationFeedbackScript.CUTIN_DEFENSE, "防衛戦開始演出", "敵の襲撃が発生しました!防衛部隊を配置してください。")
|
||||
&"RELATIONSHIP_SHIELD_CHANGED":
|
||||
# Existing love-shield vanishing transition
|
||||
var old_val: int = int(payload.get(&"old_value", payload.get("old_value", -1)))
|
||||
var new_val: int = int(payload.get(&"new_value", payload.get("new_value", -1)))
|
||||
if old_val > 0 and new_val == 0:
|
||||
show_warning_cutin(OperationFeedbackScript.CUTIN_INVASION, "重要警告:NTR侵食開始", "愛の盾が破られ、NTR侵食が始まりました。人物関係を確認してください。")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ extends Control
|
||||
|
||||
signal facility_selected(instance_id: StringName)
|
||||
signal staff_assignment_dropped(data: Dictionary, facility_id: StringName)
|
||||
signal staff_assignment_clicked(staff_id: StringName, facility_id: StringName)
|
||||
signal construction_selected(job_id: StringName)
|
||||
signal legacy_stair_selected(stair_id: StringName)
|
||||
signal cell_clicked(cell: Vector3i)
|
||||
@@ -15,6 +16,7 @@ enum LayerMode {
|
||||
BACK,
|
||||
}
|
||||
|
||||
const OperationFeedbackScript := preload("res://scripts/ui/operation_feedback.gd")
|
||||
const RoomPresentation := preload("res://scripts/ui/room_presentation.gd")
|
||||
const People := preload("res://scripts/ui/pixel_people.gd")
|
||||
const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd")
|
||||
@@ -57,6 +59,11 @@ var zoom_level := 1.0
|
||||
var pan_offset := Vector2.ZERO
|
||||
var show_grid := false
|
||||
var pan_mode := false
|
||||
var modal_input_blocked := false:
|
||||
set(value):
|
||||
modal_input_blocked = value
|
||||
if modal_input_blocked:
|
||||
_end_pan_drag()
|
||||
var _drawing := false
|
||||
var _draw_cache: Dictionary = {}
|
||||
|
||||
@@ -420,6 +427,9 @@ func facility_at_canvas(canvas_position: Vector2) -> StringName:
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if modal_input_blocked:
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
if not event.pressed and event.button_index == _drag_button:
|
||||
_end_pan_drag()
|
||||
@@ -1145,6 +1155,10 @@ func _draw_actors() -> void:
|
||||
else:
|
||||
position += _actor_offset(guest.current_cell,counts)
|
||||
_draw_pixel_actor(position,key,false,int(guest.state),receiving,false,_guest_activity(guest))
|
||||
var rel_guest: Variant = _guest_relationship(guest.guest_id)
|
||||
var guest_bubble := OperationFeedbackScript.resolve_guest_bubble(guest, rel_guest)
|
||||
if guest_bubble != OperationFeedbackScript.BUBBLE_NONE:
|
||||
_draw_unit_bubble(position, guest_bubble)
|
||||
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"):
|
||||
@@ -1171,7 +1185,12 @@ func _draw_actors() -> void:
|
||||
position = _rescue_treatment_position(staff.staff_id, position)
|
||||
if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false)
|
||||
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))
|
||||
var staff_draw_pos: Vector2 = _queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts)))
|
||||
_draw_pixel_actor(staff_draw_pos,key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff))
|
||||
var rel_staff: Variant = _staff_relationship(staff.staff_id)
|
||||
var staff_bubble := OperationFeedbackScript.resolve_staff_bubble(staff, rel_staff)
|
||||
if staff_bubble != OperationFeedbackScript.BUBBLE_NONE:
|
||||
_draw_unit_bubble(staff_draw_pos, staff_bubble)
|
||||
for key: Variant in _actor_tracks.keys():
|
||||
if not alive.has(key):
|
||||
_actor_tracks.erase(key)
|
||||
@@ -1300,6 +1319,54 @@ func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, re
|
||||
People.draw(self, at, p, mode, frame, variant, staff, float(track.get(&"direction", 1.0)) < 0.0)
|
||||
|
||||
|
||||
func _draw_unit_bubble(actor_center: Vector2, bubble_type: StringName) -> void:
|
||||
var world_pixel := _cell_dimensions().x / 16.0
|
||||
var p := maxf(floorf(world_pixel * 0.75), 1.0) if world_pixel >= 2.0 else world_pixel * 0.75
|
||||
var bubble_pos := actor_center + Vector2(-9 * p, _cell_dimensions().y * 0.5 - 3 * world_pixel - 32 * p)
|
||||
var bubble_size := Vector2(18 * p, 13 * p)
|
||||
var rect := Rect2(bubble_pos.round(), bubble_size.round())
|
||||
draw_rect(rect, Color("ffffff", 0.94))
|
||||
draw_rect(rect, Color("303c40", 0.85), false, 1.0)
|
||||
var tri_p1 := Vector2(rect.get_center().x - 2.5 * p, rect.end.y)
|
||||
var tri_p2 := Vector2(rect.get_center().x + 2.5 * p, rect.end.y)
|
||||
var tri_p3 := Vector2(rect.get_center().x, rect.end.y + 3.5 * p)
|
||||
draw_colored_polygon(PackedVector2Array([tri_p1, tri_p2, tri_p3]), Color("ffffff", 0.94))
|
||||
draw_line(tri_p1, tri_p3, Color("303c40", 0.85), 1.0)
|
||||
draw_line(tri_p2, tri_p3, Color("303c40", 0.85), 1.0)
|
||||
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := clampi(int(9 * p), 9, 14)
|
||||
match bubble_type:
|
||||
OperationFeedbackScript.BUBBLE_ZZZ:
|
||||
draw_string(font, rect.position + Vector2(2 * p, rect.size.y - 3 * p), "Zzz", HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 4 * p, font_size, Color("2d68c4"))
|
||||
OperationFeedbackScript.BUBBLE_ANGER:
|
||||
draw_string(font, rect.position + Vector2(2 * p, rect.size.y - 3 * p), "💢", HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 4 * p, font_size, Color("d63031"))
|
||||
OperationFeedbackScript.BUBBLE_HEART:
|
||||
draw_string(font, rect.position + Vector2(2 * p, rect.size.y - 3 * p), "♥", HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 4 * p, font_size, Color("e84393"))
|
||||
|
||||
|
||||
func _staff_relationship(staff_id: StringName) -> Variant:
|
||||
var service: Variant = _service(&"relationship_service")
|
||||
if service == null or not service.has_method(&"get_relationships"):
|
||||
return null
|
||||
for rel_val: Variant in service.call(&"get_relationships"):
|
||||
if rel_val is RelationshipState:
|
||||
if rel_val.actor_a_id == staff_id or rel_val.actor_b_id == staff_id:
|
||||
return rel_val
|
||||
return null
|
||||
|
||||
|
||||
func _guest_relationship(guest_id: StringName) -> Variant:
|
||||
var service: Variant = _service(&"relationship_service")
|
||||
if service == null or not service.has_method(&"get_relationships"):
|
||||
return null
|
||||
for rel_val: Variant in service.call(&"get_relationships"):
|
||||
if rel_val is RelationshipState:
|
||||
if rel_val.actor_a_id == guest_id or rel_val.actor_b_id == guest_id:
|
||||
return rel_val
|
||||
return null
|
||||
|
||||
|
||||
func _draw_excavation_jobs() -> void:
|
||||
var excavation: Variant = _service(&"excavation_service")
|
||||
if excavation == null or not excavation.has_method(&"get_jobs"):
|
||||
@@ -1594,6 +1661,8 @@ func _resting_staff_position(staff_id: StringName, fallback: Vector2) -> Vector2
|
||||
|
||||
|
||||
func _can_drop_data(_position: Vector2, data: Variant) -> bool:
|
||||
if modal_input_blocked:
|
||||
return false
|
||||
return data is Dictionary and data.get(&"kind", &"") == &"staff_assignment" and data.get(&"staff_id", &"") is StringName
|
||||
|
||||
|
||||
|
||||
@@ -158,8 +158,18 @@ func get_event_rows() -> Array[Dictionary]:
|
||||
var stream: Variant = _runtime.get("domain_events")
|
||||
if stream == null or not stream.has_method("recent_events"):
|
||||
return rows
|
||||
var seen_ids: Dictionary = {}
|
||||
for event: SimulationDomainEvent in stream.recent_events():
|
||||
rows.append(event.to_dictionary())
|
||||
var dict: Dictionary = event.to_dictionary()
|
||||
var eid: String = String(dict.get("event_id", ""))
|
||||
if not eid.is_empty():
|
||||
if seen_ids.has(eid):
|
||||
continue
|
||||
seen_ids[eid] = true
|
||||
rows.append(dict)
|
||||
# Bound to recent 256 entries in strict sequence
|
||||
if rows.size() > 256:
|
||||
rows = rows.slice(rows.size() - 256)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -201,9 +211,12 @@ func _select_row(index: int) -> void:
|
||||
var row: Dictionary = _rows[index]
|
||||
_selected_event_id = String(row["event_id"])
|
||||
var presentation := get_event_presentation(row)
|
||||
_details.text = "%s\n%s\n対象(現在の名称):%s" % [
|
||||
var detail_text := "%s\n%s\n対象(現在の名称):%s" % [
|
||||
presentation[&"time"], presentation[&"title"], presentation[&"targets"],
|
||||
]
|
||||
if not String(presentation.get(&"extra", "")).is_empty():
|
||||
detail_text += "\n" + String(presentation[&"extra"])
|
||||
_details.text = detail_text
|
||||
_details.tooltip_text = presentation[&"supplement"]
|
||||
|
||||
|
||||
@@ -225,13 +238,30 @@ func get_event_presentation(row: Dictionary) -> Dictionary:
|
||||
names.append(resolved)
|
||||
if unknown_count > 0:
|
||||
names.append("名称を確認できない対象(%d件)" % unknown_count)
|
||||
|
||||
var title_text := String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録"))
|
||||
var extras: Array[String] = []
|
||||
|
||||
# Check for amount / gold / cost
|
||||
var payload: Dictionary = row.get("payload", {}) if row.get("payload") is Dictionary else {}
|
||||
var amount_val: Variant = row.get("amount", payload.get("amount", row.get("gold", payload.get("gold", null))))
|
||||
if amount_val != null and (amount_val is int or amount_val is float) and int(amount_val) != 0:
|
||||
extras.append("金額:G%+d" % int(amount_val) if int(amount_val) < 0 else "金額:G%d" % int(amount_val))
|
||||
|
||||
# Check for reason
|
||||
var reason_val: Variant = row.get("reason", payload.get("reason", row.get("error", payload.get("error", ""))))
|
||||
if not String(reason_val).is_empty():
|
||||
extras.append("理由:%s" % String(reason_val))
|
||||
|
||||
return {
|
||||
&"title": String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録")),
|
||||
&"title": title_text,
|
||||
&"time": _format_minute(int(row.get("absolute_minute", 0))),
|
||||
&"targets": "、".join(names) if not names.is_empty() else "対象の指定なし",
|
||||
&"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s" % [
|
||||
&"extra": " ".join(extras),
|
||||
&"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s%s" % [
|
||||
row.get("event_id", ""), row.get("event_type", ""),
|
||||
"、".join(raw_ids), row.get("source_service", ""),
|
||||
"\n" + "\n".join(extras) if not extras.is_empty() else "",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,38 @@ var _new_path := ""
|
||||
var _new_name := ""
|
||||
var _message: Label
|
||||
var _slots: ItemList
|
||||
var _label_edit: LineEdit
|
||||
var _validation_label: Label
|
||||
var _rows: Array[Dictionary] = []
|
||||
|
||||
func _ready() -> void:
|
||||
name = "ManualSaveSlotsDialog"
|
||||
cancel_button_text = "戻る"
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(460, 220)
|
||||
column.custom_minimum_size = Vector2(460, 240)
|
||||
add_child(column)
|
||||
_message = Label.new()
|
||||
_message.name = "ManualSaveSlotsMessage"
|
||||
_message.custom_minimum_size.x = 460
|
||||
_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
column.add_child(_message)
|
||||
|
||||
_label_edit = LineEdit.new()
|
||||
_label_edit.name = "ManualSaveLabelEdit"
|
||||
_label_edit.custom_minimum_size = Vector2(460, 32)
|
||||
_label_edit.placeholder_text = "セーブデータの名前(日本語入力可)"
|
||||
_label_edit.max_length = 60
|
||||
_label_edit.text_changed.connect(_on_label_text_changed)
|
||||
column.add_child(_label_edit)
|
||||
_label_edit.hide()
|
||||
|
||||
_validation_label = Label.new()
|
||||
_validation_label.name = "ManualSaveValidationLabel"
|
||||
_validation_label.add_theme_font_size_override("font_size", 11)
|
||||
_validation_label.add_theme_color_override("font_color", Color("e06c75"))
|
||||
column.add_child(_validation_label)
|
||||
_validation_label.hide()
|
||||
|
||||
_slots = ItemList.new()
|
||||
_slots.name = "ManualSaveSlotsList"
|
||||
_slots.custom_minimum_size = Vector2(460, 150)
|
||||
@@ -50,9 +69,20 @@ func show_new_save(current_path: String) -> void:
|
||||
_new_name = "手動保存 " + Time.get_datetime_string_from_system(false, true)
|
||||
var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path)
|
||||
get_ok_button().disabled = not fresh
|
||||
_message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n\n" + _new_name if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。"
|
||||
popup_centered(Vector2i(520, 300))
|
||||
get_cancel_button().grab_focus()
|
||||
_message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n保存の名前(日本語入力対応):" if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。"
|
||||
if fresh:
|
||||
_label_edit.show()
|
||||
_label_edit.text = _new_name
|
||||
_validation_label.hide()
|
||||
else:
|
||||
_label_edit.hide()
|
||||
_validation_label.hide()
|
||||
popup_centered(Vector2i(520, 320))
|
||||
if fresh:
|
||||
_label_edit.grab_focus()
|
||||
_label_edit.select_all()
|
||||
else:
|
||||
get_cancel_button().grab_focus()
|
||||
|
||||
func show_load_slots(current_path: String) -> void:
|
||||
_clear_choice()
|
||||
@@ -61,6 +91,8 @@ func show_load_slots(current_path: String) -> void:
|
||||
title = "保存スロットを選んで読込"
|
||||
ok_button_text = "選んで読込"
|
||||
_message.text = "読み込む保存を選んでください。\n現在の未保存の進行は、選んだ保存で置き換わります。"
|
||||
_label_edit.hide()
|
||||
_validation_label.hide()
|
||||
_slots.show()
|
||||
_slots.clear()
|
||||
_rows = read_slot_rows(_directory)
|
||||
@@ -121,12 +153,48 @@ static func read_slot_rows(directory: String) -> Array[Dictionary]:
|
||||
rows.append({&"path": path, &"label": label, &"loadable": loadable})
|
||||
return rows
|
||||
|
||||
static func is_valid_label_text(text: String) -> Dictionary:
|
||||
if text.length() > 60:
|
||||
return {&"ok": false, &"error": "セーブ名は60文字以内で入力してください。"}
|
||||
for i in range(text.length()):
|
||||
var c := text.unicode_at(i)
|
||||
if c < 32 and c != 9:
|
||||
return {&"ok": false, &"error": "制御文字は入力できません。"}
|
||||
return {&"ok": true, &"error": ""}
|
||||
|
||||
func _on_label_text_changed(new_text: String) -> void:
|
||||
if _mode != "new":
|
||||
return
|
||||
var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path)
|
||||
if not fresh:
|
||||
get_ok_button().disabled = true
|
||||
return
|
||||
var check := is_valid_label_text(new_text)
|
||||
if not bool(check[&"ok"]):
|
||||
_validation_label.text = String(check[&"error"])
|
||||
_validation_label.show()
|
||||
get_ok_button().disabled = true
|
||||
else:
|
||||
_validation_label.hide()
|
||||
get_ok_button().disabled = false
|
||||
|
||||
func _select_slot(index: int) -> void:
|
||||
get_ok_button().disabled = index < 0 or index >= _rows.size() or not _rows[index][&"loadable"]
|
||||
|
||||
func _confirm_choice() -> void:
|
||||
if _mode == "new" and is_fresh_slot(_new_path):
|
||||
new_slot_requested.emit(_new_path, _new_name)
|
||||
if _mode == "new":
|
||||
if not is_fresh_slot(_new_path):
|
||||
_clear_choice()
|
||||
return
|
||||
var raw_name := _label_edit.text.strip_edges() if _label_edit != null else ""
|
||||
var check := is_valid_label_text(raw_name)
|
||||
if not bool(check[&"ok"]):
|
||||
_validation_label.text = String(check[&"error"])
|
||||
_validation_label.show()
|
||||
get_ok_button().disabled = true
|
||||
return
|
||||
var final_name := raw_name if not raw_name.is_empty() else _new_name
|
||||
new_slot_requested.emit(_new_path, final_name)
|
||||
elif _mode == "load":
|
||||
var selected: PackedInt32Array = _slots.get_selected_items()
|
||||
if selected.size() == 1 and selected[0] >= 0 and selected[0] < _rows.size() and _rows[selected[0]][&"loadable"]:
|
||||
@@ -137,8 +205,15 @@ func _clear_choice() -> void:
|
||||
_mode = ""
|
||||
_new_path = ""
|
||||
_new_name = ""
|
||||
if _label_edit != null:
|
||||
_label_edit.text = ""
|
||||
_label_edit.hide()
|
||||
if _validation_label != null:
|
||||
_validation_label.hide()
|
||||
_rows.clear()
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
if _message != null: LayerSkin.register_text(_message, layer)
|
||||
if _validation_label != null: LayerSkin.register_status_text(_validation_label, false, layer)
|
||||
LayerSkin.apply_tree(self, layer)
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
class_name OperationFeedback
|
||||
extends RefCounted
|
||||
|
||||
## Operation and notification UX helpers: rejection explanations,
|
||||
## overhead unit bubbles, and important warning cut-in dialogs.
|
||||
## Presentation-only: never modifies the simulation model or ledger directly.
|
||||
|
||||
const BUBBLE_NONE := &""
|
||||
const BUBBLE_ZZZ := &"zzz"
|
||||
const BUBBLE_ANGER := &"anger"
|
||||
const BUBBLE_HEART := &"heart"
|
||||
|
||||
const CUTIN_INVASION := &"invasion"
|
||||
const CUTIN_DEFENSE := &"defense"
|
||||
const CUTIN_DUEL := &"duel"
|
||||
|
||||
const ASSIGNMENT_REASONS := {
|
||||
&"STAFF_NOT_FOUND": "従業員が見つかりません。",
|
||||
&"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。",
|
||||
&"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。",
|
||||
&"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。",
|
||||
&"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。",
|
||||
&"FACILITY_FULL": "配属先の人員上限を超えています。",
|
||||
&"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"INVALID_TEMPLATE": "日勤または夜勤を選択してください。",
|
||||
&"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。",
|
||||
&"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。",
|
||||
&"EVENT_LOCKED": "イベント参加中は勤務を変更できません。",
|
||||
&"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。",
|
||||
&"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。",
|
||||
&"BANKRUPT": "運営終了後は配属を変更できません。",
|
||||
}
|
||||
|
||||
const PLACEMENT_REASONS := {
|
||||
"INVALID_DEFINITION": "施設データが不正です",
|
||||
"MISSING_DEPENDENCY": "建設システムを利用できません",
|
||||
"OUT_OF_BOUNDS": "世界の範囲外です",
|
||||
"ZONE_MISMATCH": "地上・境界・地下の条件が合いません",
|
||||
"LAYER_MISMATCH": "表層/裏層が合いません",
|
||||
"OVERLAP": "ほかの施設または工事と重なります",
|
||||
"UNSUPPORTED": "構造を支えられません",
|
||||
"INSUFFICIENT_RESOURCES": "建設資源が不足しています",
|
||||
}
|
||||
|
||||
|
||||
static func resolve_staff_bubble(staff: Variant, relationship: Variant = null) -> StringName:
|
||||
if staff == null:
|
||||
return BUBBLE_NONE
|
||||
var state_val: int = int(staff.get(&"state")) if staff is Object and "state" in staff else (int(staff.get("state", -1)) if staff is Dictionary else -1)
|
||||
# Sleeping, incapacitated, or on break corresponds to Zzz
|
||||
if state_val in [StaffRuntime.State.SLEEPING, StaffRuntime.State.INCAPACITATED, StaffRuntime.State.BREAKING]:
|
||||
return BUBBLE_ZZZ
|
||||
# Strike or fleeing corresponds to anger (no fictional morale thresholds)
|
||||
if state_val in [StaffRuntime.State.STRIKE, StaffRuntime.State.FLEEING]:
|
||||
return BUBBLE_ANGER
|
||||
# Heart must derive from actual existing intimacy/love-shield state with source authority
|
||||
if relationship != null:
|
||||
var shield := 0
|
||||
var route_lock_val := 0
|
||||
var affection := 0
|
||||
if relationship is RelationshipState:
|
||||
shield = relationship.shield_current
|
||||
route_lock_val = int(relationship.route_lock)
|
||||
affection = int(relationship.metrics.get(&"affection", 0))
|
||||
elif relationship is Dictionary:
|
||||
shield = int(relationship.get(&"shield_current", relationship.get("shield_current", 0)))
|
||||
route_lock_val = int(relationship.get(&"route_lock", relationship.get("route_lock", 0)))
|
||||
var m: Variant = relationship.get(&"metrics", relationship.get("metrics", {}))
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
elif relationship is Object:
|
||||
if "shield_current" in relationship:
|
||||
shield = int(relationship.get(&"shield_current"))
|
||||
if "route_lock" in relationship:
|
||||
route_lock_val = int(relationship.get(&"route_lock"))
|
||||
if "metrics" in relationship:
|
||||
var m: Variant = relationship.get(&"metrics")
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
if shield > 0 or affection > 0 or route_lock_val in [RelationshipState.RouteLock.EXCLUSIVE_PENDING, RelationshipState.RouteLock.EXCLUSIVE_LOCKED]:
|
||||
return BUBBLE_HEART
|
||||
# Without authoritative relationship intimacy / love-shield state, mapping is unmapped/none
|
||||
return BUBBLE_NONE
|
||||
|
||||
|
||||
static func resolve_guest_bubble(guest: Variant, relationship: Variant = null) -> StringName:
|
||||
if guest == null:
|
||||
return BUBBLE_NONE
|
||||
var state_val: int = int(guest.get(&"state")) if guest is Object and "state" in guest else (int(guest.get("state", -1)) if guest is Dictionary else -1)
|
||||
# Incapacitated corresponds to Zzz
|
||||
if state_val == GuestRuntime.State.INCAPACITATED:
|
||||
return BUBBLE_ZZZ
|
||||
# Abandoning, fleeing, interrupted, or out of patience in queue corresponds to anger
|
||||
if state_val in [GuestRuntime.State.ABANDONING, GuestRuntime.State.FLEEING, GuestRuntime.State.INTERRUPTED]:
|
||||
return BUBBLE_ANGER
|
||||
var patience: int = int(guest.get(&"patience_remaining")) if guest is Object and "patience_remaining" in guest else (int(guest.get("patience_remaining", 1)) if guest is Dictionary else 1)
|
||||
if state_val == GuestRuntime.State.QUEUING and patience <= 0:
|
||||
return BUBBLE_ANGER
|
||||
# Heart must derive from actual existing intimacy/love-shield state with source authority
|
||||
if relationship != null:
|
||||
var shield := 0
|
||||
var route_lock_val := 0
|
||||
var affection := 0
|
||||
if relationship is RelationshipState:
|
||||
shield = relationship.shield_current
|
||||
route_lock_val = int(relationship.route_lock)
|
||||
affection = int(relationship.metrics.get(&"affection", 0))
|
||||
elif relationship is Dictionary:
|
||||
shield = int(relationship.get(&"shield_current", relationship.get("shield_current", 0)))
|
||||
route_lock_val = int(relationship.get(&"route_lock", relationship.get("route_lock", 0)))
|
||||
var m: Variant = relationship.get(&"metrics", relationship.get("metrics", {}))
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
elif relationship is Object:
|
||||
if "shield_current" in relationship:
|
||||
shield = int(relationship.get(&"shield_current"))
|
||||
if "route_lock" in relationship:
|
||||
route_lock_val = int(relationship.get(&"route_lock"))
|
||||
if "metrics" in relationship:
|
||||
var m: Variant = relationship.get(&"metrics")
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
if shield > 0 or affection > 0 or route_lock_val in [RelationshipState.RouteLock.EXCLUSIVE_PENDING, RelationshipState.RouteLock.EXCLUSIVE_LOCKED]:
|
||||
return BUBBLE_HEART
|
||||
return BUBBLE_NONE
|
||||
|
||||
|
||||
static func get_assignment_rejection_text(reason: StringName) -> String:
|
||||
return ASSIGNMENT_REASONS.get(reason, "配属を変更できませんでした。条件を確認してください。")
|
||||
|
||||
|
||||
static func get_placement_rejection_text(report: Dictionary) -> String:
|
||||
var reasons: Array = report.get(&"reasons", report.get("reasons", []))
|
||||
var error_codes: Array = report.get(&"error_codes", report.get("error_codes", []))
|
||||
if not error_codes.is_empty():
|
||||
var code: String = String(error_codes[0])
|
||||
if PLACEMENT_REASONS.has(code):
|
||||
return PLACEMENT_REASONS[code]
|
||||
if not reasons.is_empty():
|
||||
return String(reasons[0])
|
||||
return "指定したマスには建築できません。配置条件を確認してください。"
|
||||
|
||||
|
||||
## Important warning cut-in dialog: invasion start, duel, defense start
|
||||
class ImportantWarningCutinDialog extends AcceptDialog:
|
||||
signal cutin_dismissed(kind: StringName)
|
||||
|
||||
var _current_kind: StringName = &""
|
||||
var _queue: Array[Dictionary] = []
|
||||
var _cutin_active := false
|
||||
var _pre_cutin_user_paused := false
|
||||
var _title_label: Label
|
||||
var _body_label: Label
|
||||
|
||||
func _init() -> void:
|
||||
name = "ImportantWarningCutinDialog"
|
||||
title = "重要警告"
|
||||
ok_button_text = "確認"
|
||||
unresizable = true
|
||||
min_size = Vector2i(480, 200)
|
||||
size = Vector2i(480, 200)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.custom_minimum_size = Vector2(460, 140)
|
||||
vbox.add_theme_constant_override("separation", 10)
|
||||
add_child(vbox)
|
||||
|
||||
_title_label = Label.new()
|
||||
_title_label.name = "CutinTitleLabel"
|
||||
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_title_label.add_theme_font_size_override("font_size", 16)
|
||||
_title_label.add_theme_color_override("font_color", Color("ff7b7b"))
|
||||
vbox.add_child(_title_label)
|
||||
|
||||
_body_label = Label.new()
|
||||
_body_label.name = "CutinBodyLabel"
|
||||
_body_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_body_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_body_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_body_label.add_theme_font_size_override("font_size", 13)
|
||||
vbox.add_child(_body_label)
|
||||
|
||||
confirmed.connect(_on_dismissed)
|
||||
canceled.connect(_on_dismissed)
|
||||
close_requested.connect(_on_dismissed)
|
||||
|
||||
func show_cutin(kind: StringName, heading: String, message: String) -> void:
|
||||
if _cutin_active:
|
||||
_queue.append({&"kind": kind, &"heading": heading, &"message": message})
|
||||
return
|
||||
_cutin_active = true
|
||||
var clock := get_node_or_null("/root/GameClock")
|
||||
if clock != null:
|
||||
_pre_cutin_user_paused = bool(clock.get("paused"))
|
||||
if not _pre_cutin_user_paused:
|
||||
clock.set_paused(true)
|
||||
_display_cutin(kind, heading, message)
|
||||
|
||||
func _display_cutin(kind: StringName, heading: String, message: String) -> void:
|
||||
_current_kind = kind
|
||||
_title_label.text = heading
|
||||
_body_label.text = message
|
||||
popup_centered(Vector2i(500, 220))
|
||||
get_ok_button().grab_focus()
|
||||
|
||||
func _on_dismissed() -> void:
|
||||
var kind := _current_kind
|
||||
_current_kind = &""
|
||||
cutin_dismissed.emit(kind)
|
||||
if not _queue.is_empty():
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_display_cutin(next[&"kind"], next[&"heading"], next[&"message"])
|
||||
else:
|
||||
_cutin_active = false
|
||||
var clock := get_node_or_null("/root/GameClock")
|
||||
if clock != null and not _pre_cutin_user_paused:
|
||||
clock.set_paused(false)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8p24opfeedback
|
||||
@@ -0,0 +1,145 @@
|
||||
class_name OperationsCameraControls
|
||||
extends HBoxContainer
|
||||
|
||||
## Presentation-only camera and navigation controls providing click alternatives
|
||||
## for pointer drag, wheel scroll, and modifier-based navigation.
|
||||
## Does not alter the underlying simulation model or game state.
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
signal zoom_in_requested
|
||||
signal zoom_out_requested
|
||||
signal reset_view_requested
|
||||
signal pan_requested(direction: Vector2)
|
||||
signal grid_toggle_requested
|
||||
signal layer_toggle_requested
|
||||
|
||||
var zoom_in_button: Button
|
||||
var zoom_out_button: Button
|
||||
var reset_button: Button
|
||||
var pan_up_button: Button
|
||||
var pan_down_button: Button
|
||||
var pan_left_button: Button
|
||||
var pan_right_button: Button
|
||||
var grid_button: Button
|
||||
var layer_button: Button
|
||||
|
||||
var _world_preview: Variant
|
||||
var _main_scene: Variant
|
||||
var _buttons: Array[Button] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
name = "OperationsCameraControls"
|
||||
add_theme_constant_override("separation", 4)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_ui()
|
||||
|
||||
|
||||
func configure(preview: Variant, main_scene: Variant = null) -> void:
|
||||
_world_preview = preview
|
||||
_main_scene = main_scene
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
# Navigation Pan Buttons
|
||||
pan_left_button = _create_button("◀", "左へ移動", func() -> void: _on_pan_step(Vector2(1, 0)))
|
||||
pan_up_button = _create_button("▲", "上へ移動", func() -> void: _on_pan_step(Vector2(0, 1)))
|
||||
pan_down_button = _create_button("▼", "下へ移動", func() -> void: _on_pan_step(Vector2(0, -1)))
|
||||
pan_right_button = _create_button("▶", "右へ移動", func() -> void: _on_pan_step(Vector2(-1, 0)))
|
||||
|
||||
# Zoom Buttons
|
||||
zoom_in_button = _create_button("+", "拡大表示", _on_zoom_in)
|
||||
zoom_out_button = _create_button("-", "縮小表示", _on_zoom_out)
|
||||
|
||||
# Reset / Focus
|
||||
reset_button = _create_button("全体", "表示範囲全体を収める", _on_reset_view)
|
||||
|
||||
# Grid & Layer Toggle
|
||||
grid_button = _create_button("マス目", "グリッド線の表示/非表示を切り替え", _on_grid_toggle)
|
||||
layer_button = _create_button("表/裏", "フロアの表層/裏層を切り替え", _on_layer_toggle)
|
||||
|
||||
|
||||
func _create_button(text_label: String, tooltip: String, callback: Callable) -> Button:
|
||||
var btn := Button.new()
|
||||
btn.text = text_label
|
||||
btn.tooltip_text = tooltip
|
||||
btn.custom_minimum_size = Vector2(30, 26)
|
||||
btn.focus_mode = Control.FOCUS_NONE
|
||||
btn.add_theme_font_size_override("font_size", 11)
|
||||
btn.pressed.connect(callback)
|
||||
add_child(btn)
|
||||
_buttons.append(btn)
|
||||
return btn
|
||||
|
||||
|
||||
func _on_zoom_in() -> void:
|
||||
if _world_preview != null and _world_preview.has_method(&"set_zoom"):
|
||||
var current: float = float(_world_preview.get("zoom_level"))
|
||||
_world_preview.set_zoom(current + 0.5)
|
||||
zoom_in_requested.emit()
|
||||
|
||||
|
||||
func _on_zoom_out() -> void:
|
||||
if _world_preview != null and _world_preview.has_method(&"set_zoom"):
|
||||
var current: float = float(_world_preview.get("zoom_level"))
|
||||
_world_preview.set_zoom(current - 0.5)
|
||||
zoom_out_requested.emit()
|
||||
|
||||
|
||||
func _on_pan_step(direction: Vector2) -> void:
|
||||
var delta := Vector2(32, 48) * direction
|
||||
if _world_preview != null:
|
||||
if _world_preview.has_method(&"_cell_dimensions"):
|
||||
var dims: Vector2 = _world_preview.call(&"_cell_dimensions")
|
||||
delta = direction * dims
|
||||
var offset: Vector2 = _world_preview.get("pan_offset")
|
||||
_world_preview.set("pan_offset", offset + delta)
|
||||
if _world_preview.has_method(&"_clamp_pan"):
|
||||
_world_preview.call(&"_clamp_pan")
|
||||
_world_preview.queue_redraw()
|
||||
pan_requested.emit(delta)
|
||||
|
||||
|
||||
func _on_pan(delta: Vector2) -> void:
|
||||
if _world_preview != null:
|
||||
var offset: Vector2 = _world_preview.get("pan_offset")
|
||||
_world_preview.set("pan_offset", offset + delta)
|
||||
if _world_preview.has_method(&"_clamp_pan"):
|
||||
_world_preview.call(&"_clamp_pan")
|
||||
_world_preview.queue_redraw()
|
||||
pan_requested.emit(delta)
|
||||
|
||||
|
||||
func _on_reset_view() -> void:
|
||||
if _world_preview != null:
|
||||
if _world_preview.has_method(&"focus_operating_area"):
|
||||
_world_preview.call(&"focus_operating_area")
|
||||
elif _world_preview.has_method(&"reset_view"):
|
||||
_world_preview.call(&"reset_view")
|
||||
reset_view_requested.emit()
|
||||
|
||||
|
||||
func _on_grid_toggle() -> void:
|
||||
if _world_preview != null:
|
||||
var current_grid: bool = bool(_world_preview.get("show_grid"))
|
||||
_world_preview.set("show_grid", not current_grid)
|
||||
_world_preview.queue_redraw()
|
||||
grid_toggle_requested.emit()
|
||||
|
||||
|
||||
func _on_layer_toggle() -> void:
|
||||
if _main_scene != null and _main_scene.has_method(&"_toggle_layer"):
|
||||
_main_scene.call(&"_toggle_layer")
|
||||
elif _world_preview != null:
|
||||
var current_mode: int = int(_world_preview.get("layer_mode"))
|
||||
_world_preview.set("layer_mode", 1 if current_mode == 0 else 0)
|
||||
layer_toggle_requested.emit()
|
||||
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
for btn: Button in _buttons:
|
||||
LayerSkin.register_toggle_text(btn, layer)
|
||||
LayerSkin.apply_tree(self, layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c9p24cameractrl
|
||||
@@ -0,0 +1,101 @@
|
||||
class_name OperationsOSObserver
|
||||
extends RefCounted
|
||||
|
||||
## Passive actual Main/runtime observer for P24-02 operations and notification evidence.
|
||||
## Captures OS process environment, screen scale, bubble state, and modal identity
|
||||
## without modifying the active simulation model.
|
||||
## Local unit fixture observations are strictly separated from candidate normal OS
|
||||
## input evidence (which requires formal native OS capture receipts).
|
||||
|
||||
var pid := 0
|
||||
var platform := ""
|
||||
var window_size := Vector2i.ZERO
|
||||
var observations: Array[Dictionary] = []
|
||||
var bubble_records: Dictionary = {} # Maps actor_id -> latest bubble record
|
||||
var bubble_history: Array[Dictionary] = [] # Chronological history of all bubble state transition events
|
||||
var modal_records: Array[Dictionary] = []
|
||||
var cutin_records: Array[Dictionary] = []
|
||||
var save_records: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
pid = OS.get_process_id()
|
||||
platform = OS.get_name()
|
||||
|
||||
|
||||
func capture_environment(tree: SceneTree, main: Control) -> Dictionary:
|
||||
var root: Window = tree.root if tree != null else null
|
||||
window_size = root.size if root != null else Vector2i.ZERO
|
||||
var report := {
|
||||
&"pid": pid,
|
||||
&"platform": platform,
|
||||
&"window_size": [window_size.x, window_size.y],
|
||||
&"main_present": is_instance_valid(main),
|
||||
&"captured_at_msec": Time.get_ticks_msec(),
|
||||
&"fixture_scope": &"local_unit_fixture_only_not_native_os_input_evidence",
|
||||
}
|
||||
observations.append(report)
|
||||
return report
|
||||
|
||||
|
||||
func record_bubble(actor_id: StringName, kind: StringName, source_state: int) -> void:
|
||||
var record := {
|
||||
&"actor_id": actor_id,
|
||||
&"kind": kind,
|
||||
&"source_state": source_state,
|
||||
&"recorded_at": Time.get_ticks_msec(),
|
||||
}
|
||||
bubble_records[actor_id] = record
|
||||
bubble_history.append(record)
|
||||
|
||||
|
||||
func get_latest_bubble(actor_id: StringName) -> Dictionary:
|
||||
return bubble_records.get(actor_id, {})
|
||||
|
||||
|
||||
func get_bubble_history() -> Array[Dictionary]:
|
||||
return bubble_history.duplicate(true)
|
||||
|
||||
|
||||
func record_modal_event(dialog_name: String, action: String, details: Dictionary = {}) -> void:
|
||||
modal_records.append({
|
||||
&"dialog_name": dialog_name,
|
||||
&"action": action,
|
||||
&"details": details.duplicate(true),
|
||||
&"recorded_at": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func record_cutin(kind: StringName, heading: String, message: String) -> void:
|
||||
cutin_records.append({
|
||||
&"kind": kind,
|
||||
&"heading": heading,
|
||||
&"message": message,
|
||||
&"recorded_at": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func record_save_label(path: String, display_name: String, is_fresh: bool) -> void:
|
||||
save_records.append({
|
||||
&"path": path,
|
||||
&"display_name": display_name,
|
||||
&"is_fresh": is_fresh,
|
||||
&"recorded_at": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func get_summary() -> Dictionary:
|
||||
return {
|
||||
&"pid": pid,
|
||||
&"platform": platform,
|
||||
&"window_size": [window_size.x, window_size.y],
|
||||
&"observations_count": observations.size(),
|
||||
&"unique_actors_tracked": bubble_records.size(),
|
||||
&"bubbles_tracked": bubble_history.size(),
|
||||
&"bubble_history_count": bubble_history.size(),
|
||||
&"bubble_events_count": bubble_history.size(),
|
||||
&"modals_tracked": modal_records.size(),
|
||||
&"cutins_tracked": cutin_records.size(),
|
||||
&"saves_tracked": save_records.size(),
|
||||
&"fixture_scope": &"local_unit_fixture_only_not_native_os_input_evidence",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://copobserver001
|
||||
@@ -0,0 +1,334 @@
|
||||
extends SceneTree
|
||||
|
||||
const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd")
|
||||
const OperationFeedbackScript := preload("res://scripts/ui/operation_feedback.gd")
|
||||
const OperationsOSObserverScript := preload("res://tests/ui/fixtures/operations_os_observer.gd")
|
||||
const ManualSaveSlotsDialogScript := preload("res://scripts/ui/manual_save_slots_dialog.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _expect(ok: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not ok:
|
||||
_failures += 1
|
||||
push_error("FAIL: " + message)
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var observer := OperationsOSObserverScript.new()
|
||||
var runtime: Variant = root.get_node("SimulationRuntime")
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
runtime.initialize(clock, state, 42534)
|
||||
clock.set_paused(true)
|
||||
root.size = Vector2i(1440, 810)
|
||||
|
||||
var main: Control = load("res://scenes/main/main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var preview: WorldPreview = main.get("world_preview")
|
||||
_expect(preview != null, "WorldPreview exists in Main")
|
||||
var env_report: Dictionary = observer.capture_environment(self, main)
|
||||
_expect(env_report[&"pid"] > 0, "Observer captured actual process PID")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 1: placement_and_invalid_drop (Grid snap, warnings, invalid rejection)
|
||||
# =========================================================================
|
||||
var def_registry: Variant = runtime.get("definition_registry")
|
||||
var reception_def: Variant = def_registry.get_facility(&"reception") if def_registry != null else null
|
||||
_expect(reception_def != null, "Reception facility definition exists")
|
||||
|
||||
# Valid placement commits once
|
||||
var valid_cell := Vector3i(-8, 0, 0)
|
||||
var build_result: Variant = runtime.submit_command(&"build_facility", {
|
||||
&"definition_id": &"reception",
|
||||
&"origin": valid_cell,
|
||||
&"rotation": 0
|
||||
})
|
||||
_expect(build_result.is_success(), "Valid facility construction commits once")
|
||||
|
||||
# Advance time for facility to finish construction
|
||||
clock.advance_minutes(120)
|
||||
var facilities: Array = runtime.construction_service.get_facilities()
|
||||
_expect(not facilities.is_empty(), "Facility construction completed in world")
|
||||
var facility_id := StringName(facilities[0][&"instance_id"])
|
||||
|
||||
# Connection warning on preview
|
||||
preview.set_build_definition(reception_def, 0)
|
||||
var connection_preview: Dictionary = preview.get_build_preview_report(Vector3i(10, 10, 0))
|
||||
_expect(connection_preview.has(&"connection"), "Build preview includes connection warning report")
|
||||
|
||||
# Invalid placement rejection explanation
|
||||
var invalid_preview: Dictionary = preview.get_build_preview_report(Vector3i(999, 999, 0))
|
||||
var rejection_text := OperationFeedbackScript.get_placement_rejection_text(invalid_preview)
|
||||
_expect(not rejection_text.is_empty(), "Invalid placement explains actual rejection reason: " + rejection_text)
|
||||
preview.clear_build_definition()
|
||||
|
||||
# Staff assignment via drop
|
||||
var staff_panel: StaffManagementPanel = main.get("staff_management_panel")
|
||||
_expect(staff_panel != null, "StaffManagementPanel exists")
|
||||
_expect(runtime.staff_service.create_staff(&"op_worker_1", &"cleaner", {}).is_success(), "Create test employee")
|
||||
staff_panel.refresh()
|
||||
|
||||
var drop_payload := {
|
||||
&"kind": &"staff_assignment",
|
||||
&"staff_id": &"op_worker_1",
|
||||
&"template_id": &"DAY",
|
||||
&"queue_instance_id": int(staff_panel.get("_queue_instance_id"))
|
||||
}
|
||||
staff_panel.assign_from_drop(drop_payload, facility_id)
|
||||
_expect(staff_panel.selected_staff_id == &"op_worker_1", "Drop assignment preserves employee selection")
|
||||
|
||||
# Invalid assignment rejection explains actual reason and leaves model intact
|
||||
var before_model: Dictionary = runtime.create_snapshot()
|
||||
var invalid_drop := {
|
||||
&"kind": &"staff_assignment",
|
||||
&"staff_id": &"non_existent_staff",
|
||||
&"template_id": &"DAY",
|
||||
&"queue_instance_id": int(staff_panel.get("_queue_instance_id"))
|
||||
}
|
||||
staff_panel.assign_from_drop(invalid_drop, facility_id)
|
||||
var after_model: Dictionary = runtime.create_snapshot()
|
||||
_expect(SimulationDigest.compute(before_model) == SimulationDigest.compute(after_model), "Invalid drop leaves simulation model completely intact")
|
||||
|
||||
# Click-based assignment alternative
|
||||
_expect(main.has_method(&"assign_staff_by_click"), "Main provides click-based assignment alternative")
|
||||
staff_panel.select_staff(&"op_worker_1")
|
||||
main.call(&"assign_staff_by_click", facility_id, &"op_worker_1")
|
||||
_expect(staff_panel.selected_staff_id == &"op_worker_1", "Click-based assignment succeeds and leaves selection intact")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 2: modal_and_pause_resume (Modal input block, pause freezes simulation)
|
||||
# =========================================================================
|
||||
var save_dialog: ConfirmationDialog = main.call(&"_ensure_manual_save_slots")
|
||||
_expect(save_dialog != null, "Save slots dialog is available")
|
||||
var initial_manual_path: String = main.manual_save_path
|
||||
save_dialog.show_new_save("user://saves/manual_ui")
|
||||
await process_frame
|
||||
_expect(preview.modal_input_blocked, "Behind-dialog input is blocked while modal is open")
|
||||
# Behind-dialog drop assignment is rejected while modal is open
|
||||
var drop_test_payload := {
|
||||
&"kind": &"staff_assignment",
|
||||
&"staff_id": &"op_worker_1",
|
||||
&"template_id": &"DAY",
|
||||
}
|
||||
_expect(not preview._can_drop_data(Vector2.ZERO, drop_test_payload), "Modal suppresses behind-dialog staff assignment drop")
|
||||
# Cancel modal preserves save identity and unblocks behind-dialog input
|
||||
save_dialog.hide()
|
||||
save_dialog.emit_signal(&"canceled")
|
||||
await process_frame
|
||||
_expect(not preview.modal_input_blocked, "Modal close unblocks behind-dialog input")
|
||||
_expect(main.manual_save_path == initial_manual_path, "Modal cancellation preserves existing save identity")
|
||||
|
||||
# Pause freezes simulation via process_real_delta/accumulator; ordinary resume continues exactly once
|
||||
var clock_paused_before: bool = bool(clock.get("paused"))
|
||||
clock.set_paused(true)
|
||||
var min_paused: int = int(clock.get("absolute_minute"))
|
||||
# Passing non-zero real delta while paused returns 0 ticks and does not advance simulation minute
|
||||
var paused_ticks: int = clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE * 2.0)
|
||||
_expect(paused_ticks == 0, "Paused clock returns 0 ticks on non-zero real process delta")
|
||||
_expect(int(clock.get("absolute_minute")) == min_paused, "Paused clock freezes minute advancement on real process delta")
|
||||
# Ordinary resume continues exactly once
|
||||
clock.set_paused(false)
|
||||
var resumed_ticks: int = clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE)
|
||||
_expect(resumed_ticks == 1, "Resumed clock on real process delta advances exactly one tick")
|
||||
_expect(int(clock.get("absolute_minute")) == min_paused + 1, "Resumed clock advances simulation minute exactly once")
|
||||
clock.set_paused(clock_paused_before)
|
||||
|
||||
# =========================================================================
|
||||
# Condition 3: unit_bubbles (Zzz, anger, heart from real states)
|
||||
# =========================================================================
|
||||
var test_staff: StaffRuntime = runtime.staff_service.get_staff(&"op_worker_1")
|
||||
_expect(test_staff != null, "Worker runtime exists for bubble test")
|
||||
|
||||
# SLEEPING / INCAPACITATED -> Zzz
|
||||
test_staff.state = StaffRuntime.State.SLEEPING
|
||||
var bubble_zzz: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff)
|
||||
_expect(bubble_zzz == OperationFeedbackScript.BUBBLE_ZZZ, "Sleeping staff resolves to Zzz bubble")
|
||||
observer.record_bubble(&"op_worker_1", bubble_zzz, test_staff.state)
|
||||
|
||||
# STRIKE / FLEEING -> anger (no unapproved morale thresholds)
|
||||
test_staff.state = StaffRuntime.State.STRIKE
|
||||
var bubble_anger: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff)
|
||||
_expect(bubble_anger == OperationFeedbackScript.BUBBLE_ANGER, "Strike staff resolves to anger bubble")
|
||||
observer.record_bubble(&"op_worker_1", bubble_anger, test_staff.state)
|
||||
|
||||
# Authoritative RelationshipState / RouteLock -> heart (no unapproved loyalty/morale thresholds)
|
||||
var rel_state := RelationshipState.new()
|
||||
rel_state.configure(&"rel_op_1", &"heroine_anri", &"op_worker_1", {
|
||||
&"shield_max": 100,
|
||||
&"shield_current": 100,
|
||||
&"route_lock": RelationshipState.RouteLock.EXCLUSIVE_PENDING,
|
||||
&"route_id": &"pure_love",
|
||||
&"metrics": {&"affection": 80}
|
||||
})
|
||||
test_staff.state = StaffRuntime.State.AVAILABLE
|
||||
var bubble_heart: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff, rel_state)
|
||||
_expect(bubble_heart == OperationFeedbackScript.BUBBLE_HEART, "Authoritative relationship model resolves to heart bubble")
|
||||
observer.record_bubble(&"op_worker_1", bubble_heart, test_staff.state)
|
||||
|
||||
# Switching state back to normal working -> clears bubble without stale symbol
|
||||
test_staff.state = StaffRuntime.State.WORKING
|
||||
var bubble_work: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff)
|
||||
_expect(bubble_work == OperationFeedbackScript.BUBBLE_NONE, "Normal working staff clears bubble without stale symbol")
|
||||
var neutral_rel := RelationshipState.new()
|
||||
neutral_rel.configure(&"rel_op_neutral", &"heroine_anri", &"op_worker_1", {
|
||||
&"shield_max": 100,
|
||||
&"shield_current": 0,
|
||||
&"route_lock": RelationshipState.RouteLock.NONE,
|
||||
&"route_id": &"",
|
||||
&"metrics": {&"affection": 0}
|
||||
})
|
||||
var bubble_cleared: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff, neutral_rel)
|
||||
_expect(bubble_cleared == OperationFeedbackScript.BUBBLE_NONE, "Neutral relationship model clears heart bubble")
|
||||
observer.record_bubble(&"op_worker_1", bubble_cleared, test_staff.state)
|
||||
_expect(observer.get_latest_bubble(&"op_worker_1").get(&"kind", &"") == OperationFeedbackScript.BUBBLE_NONE, "Observer confirms stale bubble symbol is removed on transition to normal")
|
||||
|
||||
# Guest bubble: Incapacitated -> Zzz
|
||||
var test_guest := GuestRuntime.new()
|
||||
test_guest.guest_id = &"bubble_guest"
|
||||
test_guest.state = GuestRuntime.State.INCAPACITATED
|
||||
_expect(OperationFeedbackScript.resolve_guest_bubble(test_guest) == OperationFeedbackScript.BUBBLE_ZZZ, "Incapacitated guest resolves to Zzz bubble")
|
||||
|
||||
# Guest bubble: Abandoning -> anger
|
||||
test_guest.state = GuestRuntime.State.ABANDONING
|
||||
_expect(OperationFeedbackScript.resolve_guest_bubble(test_guest) == OperationFeedbackScript.BUBBLE_ANGER, "Abandoning guest resolves to anger bubble")
|
||||
|
||||
# Guest bubble: Normal moving -> no bubble
|
||||
test_guest.state = GuestRuntime.State.MOVING
|
||||
_expect(OperationFeedbackScript.resolve_guest_bubble(test_guest) == OperationFeedbackScript.BUBBLE_NONE, "Normal moving guest clears bubble without stale symbol")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 4: event_log (Bounded 256, formatted Japanese, deduplicated)
|
||||
# =========================================================================
|
||||
var event_dialog: EventLogDialog = main.get("event_log_dialog")
|
||||
_expect(event_dialog != null, "EventLogDialog exists in Main")
|
||||
var event_rows: Array[Dictionary] = event_dialog.get_event_rows()
|
||||
_expect(event_rows.size() <= 256, "Event rows bounded to recent 256 entries")
|
||||
|
||||
var sample_event := {
|
||||
"event_id": "evt_test_001",
|
||||
"event_type": "DEFENSE_RAID_CREATED",
|
||||
"absolute_minute": 360,
|
||||
"entity_ids": [],
|
||||
"amount": -500,
|
||||
"reason": "RAID_START"
|
||||
}
|
||||
var presentation: Dictionary = event_dialog.get_event_presentation(sample_event)
|
||||
_expect(presentation[&"title"] == "襲撃が発生", "Event type resolves to readable Japanese title")
|
||||
_expect("G-500" in presentation[&"extra"] or "500" in presentation[&"extra"], "Event presentation includes amount details")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 5: important_warning_cutin (Invasion / Duel / Defense start)
|
||||
# =========================================================================
|
||||
_expect(main.has_method(&"show_warning_cutin"), "Main provides show_warning_cutin")
|
||||
main.call(&"show_warning_cutin", OperationFeedbackScript.CUTIN_DEFENSE, "防衛戦開始演出", "敵の襲撃が発生しました!")
|
||||
var cutin: OperationFeedback.ImportantWarningCutinDialog = main.get("important_warning_cutin")
|
||||
_expect(cutin != null and cutin.visible, "Important warning cut-in is displayed")
|
||||
observer.record_cutin(OperationFeedbackScript.CUTIN_DEFENSE, "防衛戦開始演出", "敵の襲撃が発生しました!")
|
||||
cutin.hide()
|
||||
cutin.emit_signal(&"confirmed")
|
||||
await process_frame
|
||||
_expect(not cutin.visible, "Important warning cut-in dismisses cleanly")
|
||||
|
||||
# Real domain event triggers: DEFENSE_RAID_CREATED and RELATIONSHIP_SHIELD_CHANGED break
|
||||
main.call(&"_on_domain_event_published", {
|
||||
&"event_type": &"DEFENSE_RAID_CREATED",
|
||||
&"payload": {&"raid_id": &"raid_test_01"}
|
||||
})
|
||||
_expect(cutin != null and cutin.visible, "DEFENSE_RAID_CREATED domain event triggers defense cutin")
|
||||
cutin.hide()
|
||||
cutin.emit_signal(&"confirmed")
|
||||
await process_frame
|
||||
|
||||
main.call(&"_on_domain_event_published", {
|
||||
&"event_type": &"RELATIONSHIP_SHIELD_CHANGED",
|
||||
&"payload": {&"old_value": 50, &"new_value": 0}
|
||||
})
|
||||
_expect(cutin != null and cutin.visible, "Shield vanishing to 0 triggers love shield warning cutin")
|
||||
cutin.hide()
|
||||
cutin.emit_signal(&"confirmed")
|
||||
await process_frame
|
||||
|
||||
# Generic INCIDENT_CREATED does not falsely trigger warning cutin
|
||||
main.call(&"_on_domain_event_published", {
|
||||
&"event_type": &"INCIDENT_CREATED",
|
||||
&"payload": {&"incident_type": &"illegal_facility"}
|
||||
})
|
||||
_expect(not cutin.visible, "Generic INCIDENT_CREATED does not trigger warning cutin")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 6: click_alternatives (OperationsCameraControls click equivalents)
|
||||
# =========================================================================
|
||||
var camera_ctrl: OperationsCameraControls = main.get("operations_camera_controls")
|
||||
_expect(camera_ctrl != null, "OperationsCameraControls exists in Main")
|
||||
|
||||
var initial_zoom: float = preview.zoom_level
|
||||
camera_ctrl.zoom_in_button.emit_signal(&"pressed")
|
||||
_expect(preview.zoom_level > initial_zoom, "Click zoom in increments zoom level")
|
||||
|
||||
camera_ctrl.zoom_out_button.emit_signal(&"pressed")
|
||||
_expect(is_equal_approx(preview.zoom_level, initial_zoom), "Click zoom out restores zoom level")
|
||||
|
||||
var initial_pan: Vector2 = preview.pan_offset
|
||||
camera_ctrl.pan_up_button.emit_signal(&"pressed")
|
||||
_expect(preview.pan_offset != initial_pan, "Click pan moves pan offset")
|
||||
|
||||
camera_ctrl.reset_button.emit_signal(&"pressed")
|
||||
_expect(preview.zoom_level >= 0.5 and preview.zoom_level <= 4.0, "Click reset maintains valid viewport framing")
|
||||
|
||||
var grid_before: bool = preview.show_grid
|
||||
camera_ctrl.grid_button.emit_signal(&"pressed")
|
||||
_expect(preview.show_grid != grid_before, "Click grid button toggles grid display")
|
||||
camera_ctrl.grid_button.emit_signal(&"pressed")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 7: japanese_input_and_scale (Japanese save label, cryptographic path)
|
||||
# =========================================================================
|
||||
var label_edit: LineEdit = save_dialog.find_child("ManualSaveLabelEdit", true, false) as LineEdit
|
||||
_expect(label_edit != null, "Manual save slots dialog contains LineEdit for Japanese label input")
|
||||
|
||||
save_dialog.show_new_save("user://saves/manual_ui")
|
||||
_expect(not label_edit.text.is_empty(), "Save label has valid Japanese default text: " + label_edit.text)
|
||||
|
||||
# User inputs Japanese text
|
||||
var custom_label := "世界樹第壱号拠点・本番セーブ"
|
||||
label_edit.text = custom_label
|
||||
label_edit.emit_signal(&"text_changed", custom_label)
|
||||
_expect(save_dialog.get_ok_button().disabled == false, "Valid Japanese text allows save confirmation")
|
||||
|
||||
# Clear / cancel semantics
|
||||
save_dialog.hide()
|
||||
save_dialog.emit_signal(&"canceled")
|
||||
_expect(label_edit.text.is_empty(), "Dialog cancel clears input label")
|
||||
|
||||
# Cryptographic owned path check
|
||||
_expect(ManualSaveSlotsDialogScript.is_owned_slot_name("manual_ui_0123456789abcdef0123456789abcdef"), "Cryptographic slot path regex matches owned specification")
|
||||
_expect(not ManualSaveSlotsDialogScript.is_owned_slot_name("user_custom_folder_name"), "User custom folder name rejected from cryptographic slot rule")
|
||||
|
||||
# =========================================================================
|
||||
# Condition 8: final_validation_review (All 8 conditions checked, ordinary cleanup)
|
||||
# =========================================================================
|
||||
var observer_summary: Dictionary = observer.get_summary()
|
||||
_expect(observer_summary[&"pid"] > 0, "Observer tracks non-zero PID")
|
||||
_expect(observer_summary[&"bubbles_tracked"] >= 3, "Observer tracked at least 3 unit bubble events")
|
||||
_expect(observer_summary[&"unique_actors_tracked"] >= 1, "Observer maintains latest state map for tracked actors")
|
||||
|
||||
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
||||
_expect(bool(cleanup.get(&"passed", false)), "Ordinary Main fixture and audio cleanup succeeds")
|
||||
|
||||
print("Operations notification: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://copnotify0001
|
||||
@@ -0,0 +1,747 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P24-02 Operations and Notification Acceptance Evidence Validator.
|
||||
|
||||
Strict verify-only validator enforcing schema, scope source manifest,
|
||||
concrete condition-specific evidence artifacts, OS execution receipts,
|
||||
cross-process restart proof, and all 8 required conditions:
|
||||
- placement_and_invalid_drop
|
||||
- modal_and_pause_resume
|
||||
- unit_bubbles
|
||||
- event_log
|
||||
- important_warning_cutin
|
||||
- click_alternatives
|
||||
- japanese_input_and_scale
|
||||
- final_validation_review
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_CONDITIONS = [
|
||||
"placement_and_invalid_drop",
|
||||
"modal_and_pause_resume",
|
||||
"unit_bubbles",
|
||||
"event_log",
|
||||
"important_warning_cutin",
|
||||
"click_alternatives",
|
||||
"japanese_input_and_scale",
|
||||
"final_validation_review",
|
||||
]
|
||||
|
||||
REQUIRED_SCOPE_FILES = [
|
||||
"scenes/main/main.gd",
|
||||
"scenes/main/world_preview.gd",
|
||||
"scripts/ui/event_log_dialog.gd",
|
||||
"scripts/ui/manual_save_slots_dialog.gd",
|
||||
"scripts/ui/operation_feedback.gd",
|
||||
"scripts/ui/operation_feedback.gd.uid",
|
||||
"scripts/ui/operations_camera_controls.gd",
|
||||
"scripts/ui/operations_camera_controls.gd.uid",
|
||||
"tests/ui/test_operations_notification.gd",
|
||||
"tests/ui/test_operations_notification.gd.uid",
|
||||
"tests/ui/fixtures/operations_os_observer.gd",
|
||||
"tests/ui/fixtures/operations_os_observer.gd.uid",
|
||||
"tools/check_p24_operations_evidence.py",
|
||||
"tools/run_all_tests.py",
|
||||
"tools/regression/suite-policy.json",
|
||||
"docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json",
|
||||
]
|
||||
|
||||
EXPECTED_TASK_ID = "P24-02"
|
||||
EXPECTED_BASE_COMMIT = "1a5abc65163097d4b36a060d68f58c92093eb4e9"
|
||||
EXPECTED_SPEC_SHA256 = "738268b406e381ccab415b426d4a547b02e7cb2a481cefce65579a1e346834c7"
|
||||
HEX64_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
HEX_COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$|^[0-9a-f]{64}$")
|
||||
JAPANESE_CHAR_PATTERN = re.compile(r"[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]")
|
||||
MAX_VALID_PID = 4_194_304
|
||||
|
||||
|
||||
def compute_sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def get_actual_git_head() -> str:
|
||||
env_head = os.environ.get("CANDIDATE_COMMIT", os.environ.get("TARGET_HEAD"))
|
||||
if env_head:
|
||||
cleaned = env_head.strip().lower()
|
||||
if HEX_COMMIT_PATTERN.match(cleaned) and not (set(cleaned) <= {"0"} or set(cleaned) <= {"f"}):
|
||||
return cleaned
|
||||
try:
|
||||
import subprocess
|
||||
res = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5)
|
||||
if res.returncode == 0:
|
||||
h = res.stdout.strip().lower()
|
||||
if HEX_COMMIT_PATTERN.match(h) and not (set(h) <= {"0"} or set(h) <= {"f"}):
|
||||
return h
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def get_required_suite_roster(root: Path) -> list[str]:
|
||||
tests_dir = root / "tests"
|
||||
if not tests_dir.is_dir():
|
||||
return []
|
||||
roster = ["tests/run_tests.gd"]
|
||||
for p in sorted(tests_dir.rglob("test*.gd")):
|
||||
rel = p.relative_to(root).as_posix()
|
||||
if "export" not in p.relative_to(root).parts and rel != "tests/s0/test_kernel_and_save.gd":
|
||||
roster.append(rel)
|
||||
return sorted(list(set(roster)))
|
||||
|
||||
|
||||
def find_index_file(custom_path: str = None) -> Path:
|
||||
if custom_path:
|
||||
p = Path(custom_path)
|
||||
if p.is_file():
|
||||
return p.resolve()
|
||||
if p.is_dir():
|
||||
candidate = p / "index.json"
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
candidate2 = p / "p24-operations-evidence" / "index.json"
|
||||
if candidate2.is_file():
|
||||
return candidate2.resolve()
|
||||
|
||||
build_dir = os.environ.get("BUILD_OUTPUT_DIR")
|
||||
if build_dir:
|
||||
candidate = Path(build_dir) / "p24-operations-evidence" / "index.json"
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
candidate2 = Path(build_dir) / "index.json"
|
||||
if candidate2.is_file():
|
||||
return candidate2.resolve()
|
||||
|
||||
candidates = [
|
||||
Path(".runtime/P24-02/build/p24-operations-evidence/index.json"),
|
||||
Path("docs/plans/original-completion-2026-10-02/execution/p24-02/evidence/index.json"),
|
||||
Path("artifacts/p24-02-operations/index.json"),
|
||||
]
|
||||
for c in candidates:
|
||||
if c.is_file():
|
||||
return c.resolve()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_bare_or_dummy_artifact(data: dict) -> bool:
|
||||
if not isinstance(data, dict):
|
||||
return True
|
||||
if len(data) <= 1:
|
||||
return True
|
||||
trivial_keys = {"pid", "status", "passed", "id", "timestamp", "name", "contract_id"}
|
||||
if set(data.keys()).issubset(trivial_keys):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_condition_artifact_content(
|
||||
cond: str,
|
||||
art_data: dict,
|
||||
art_type: str,
|
||||
evidence_dir: Path,
|
||||
root_data: dict,
|
||||
validated_artifacts: dict,
|
||||
) -> tuple[bool, str]:
|
||||
if not isinstance(art_data, dict):
|
||||
return False, f"Artifact content for condition '{cond}' must be a JSON object"
|
||||
|
||||
if _is_bare_or_dummy_artifact(art_data):
|
||||
return False, f"Artifact for condition '{cond}' is a bare self-declaration/PID-only JSON without substantive evidence details"
|
||||
|
||||
if cond == "placement_and_invalid_drop":
|
||||
if "grid_snap" not in art_data:
|
||||
return False, "Missing 'grid_snap' in placement evidence"
|
||||
if "connection_warning" not in art_data:
|
||||
return False, "Missing 'connection_warning' in placement evidence"
|
||||
if not art_data.get("valid_placement"):
|
||||
return False, "Missing 'valid_placement' in placement evidence"
|
||||
if not art_data.get("invalid_placement_rejection"):
|
||||
return False, "Missing 'invalid_placement_rejection' in placement evidence"
|
||||
if not art_data.get("click_assignment"):
|
||||
return False, "Missing 'click_assignment' in placement evidence"
|
||||
|
||||
elif cond == "modal_and_pause_resume":
|
||||
if not art_data.get("modal_input_blocked"):
|
||||
return False, "Missing 'modal_input_blocked': true in modal/pause evidence"
|
||||
if not art_data.get("behind_dialog_suppressed") and not (
|
||||
art_data.get("behind_dialog_build_suppressed") and art_data.get("behind_dialog_drop_suppressed")
|
||||
):
|
||||
return False, "Missing behind-dialog input suppression verification in modal evidence"
|
||||
if not art_data.get("clock_pause"):
|
||||
return False, "Missing 'clock_pause' (freezing simulation on real delta) in modal/pause evidence"
|
||||
if not art_data.get("clock_resume"):
|
||||
return False, "Missing 'clock_resume' (advancing exactly once on real delta) in modal/pause evidence"
|
||||
if not art_data.get("save_identity_preserved"):
|
||||
return False, "Missing 'save_identity_preserved': true in modal/pause evidence"
|
||||
|
||||
# Separate process restart verification (別process restart・保存再開とPID)
|
||||
cross_proc = art_data.get("cross_process", art_data.get("process_restart", root_data.get("cross_process")))
|
||||
if not cross_proc or not isinstance(cross_proc, dict):
|
||||
return False, "Missing verified separate process restart evidence ('cross_process' or 'process_restart' with distinct producer and consumer PIDs)"
|
||||
|
||||
producer_pid = cross_proc.get("producer_pid")
|
||||
consumer_pid = cross_proc.get("consumer_pid")
|
||||
|
||||
# Support artifact-linked cross-process structure (P24-03 pattern)
|
||||
if (producer_pid is None or consumer_pid is None) and "producer" in cross_proc and "consumer" in cross_proc:
|
||||
prod_art_id = cross_proc["producer"]
|
||||
cons_art_id = cross_proc["consumer"]
|
||||
if prod_art_id in validated_artifacts and cons_art_id in validated_artifacts:
|
||||
try:
|
||||
prod_json = json.loads(validated_artifacts[prod_art_id]["file"].read_text(encoding="utf-8"))
|
||||
cons_json = json.loads(validated_artifacts[cons_art_id]["file"].read_text(encoding="utf-8"))
|
||||
producer_pid = prod_json.get("pid")
|
||||
consumer_pid = cons_json.get("pid")
|
||||
if prod_json.get("manifest_sha256") and cons_json.get("manifest_sha256"):
|
||||
if prod_json["manifest_sha256"] != cons_json["manifest_sha256"]:
|
||||
return False, "Cross-process restart manifest_sha256 mismatch between producer and consumer"
|
||||
if prod_json.get("world_sha256") and cons_json.get("world_sha256"):
|
||||
if prod_json["world_sha256"] != cons_json["world_sha256"]:
|
||||
return False, "Cross-process restart world_sha256 mismatch between producer and consumer"
|
||||
except Exception as e:
|
||||
return False, f"Failed to parse cross-process producer/consumer artifacts: {e}"
|
||||
|
||||
if isinstance(producer_pid, bool) or not isinstance(producer_pid, int) or producer_pid <= 1:
|
||||
return False, f"Invalid or missing cross-process producer_pid: {producer_pid!r}"
|
||||
if isinstance(consumer_pid, bool) or not isinstance(consumer_pid, int) or consumer_pid <= 1:
|
||||
return False, f"Invalid or missing cross-process consumer_pid: {consumer_pid!r}"
|
||||
if producer_pid == consumer_pid:
|
||||
return False, f"Cross-process restart requires distinct processes; producer and consumer share identical PID {producer_pid}"
|
||||
|
||||
state_preserved = (
|
||||
cross_proc.get("same_manifest_world") is True
|
||||
or cross_proc.get("whole_model_and_screen_values_equal") is True
|
||||
or cross_proc.get("state_preserved") is True
|
||||
or ("manifest_sha256" in cross_proc and "world_sha256" in cross_proc)
|
||||
or ("producer" in cross_proc and "consumer" in cross_proc)
|
||||
)
|
||||
if not state_preserved:
|
||||
return False, "Cross-process restart evidence missing proof of save/world consistency across distinct processes"
|
||||
|
||||
elif cond == "unit_bubbles":
|
||||
if not art_data.get("zzz_bubble"):
|
||||
return False, "Missing 'zzz_bubble' in unit bubbles evidence"
|
||||
if not art_data.get("anger_bubble"):
|
||||
return False, "Missing 'anger_bubble' in unit bubbles evidence"
|
||||
if not art_data.get("heart_bubble"):
|
||||
return False, "Missing 'heart_bubble' with authoritative relationship model in unit bubbles evidence"
|
||||
if not art_data.get("stale_removal"):
|
||||
return False, "Missing 'stale_removal' state transition clearing in unit bubbles evidence"
|
||||
|
||||
tracked = art_data.get("bubbles_tracked", art_data.get("bubble_events_count", 0))
|
||||
if isinstance(tracked, bool) or not isinstance(tracked, int) or tracked < 3:
|
||||
return False, f"Unit bubbles evidence must track at least 3 bubble events, got: {tracked!r}"
|
||||
|
||||
elif cond == "event_log":
|
||||
limit = art_data.get("recent_limit")
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit > 256 or limit <= 0:
|
||||
return False, f"Event log recent limit must be positive int <= 256, got: {limit!r}"
|
||||
if not art_data.get("readable_japanese"):
|
||||
return False, "Missing 'readable_japanese': true in event log evidence"
|
||||
if not art_data.get("deduplicated"):
|
||||
return False, "Missing 'deduplicated': true in event log evidence"
|
||||
events = art_data.get("events")
|
||||
if not isinstance(events, list) or not events:
|
||||
return False, "Event log evidence must include non-empty 'events' list"
|
||||
for i, ev in enumerate(events):
|
||||
if not isinstance(ev, dict):
|
||||
return False, f"Event log row {i} must be a dictionary"
|
||||
if not ev.get("event_id") or not ev.get("event_type"):
|
||||
return False, f"Event log row {i} missing event_id or event_type"
|
||||
title = ev.get("title", "")
|
||||
if not isinstance(title, str) or not JAPANESE_CHAR_PATTERN.search(title):
|
||||
return False, f"Event log row {i} title must contain readable Japanese, got: {title!r}"
|
||||
|
||||
elif cond == "important_warning_cutin":
|
||||
if not art_data.get("defense_cutin"):
|
||||
return False, "Missing 'defense_cutin' in cutin evidence"
|
||||
if not art_data.get("shield_vanishing_cutin"):
|
||||
return False, "Missing 'shield_vanishing_cutin' in cutin evidence"
|
||||
if not art_data.get("simulation_stop") or not art_data.get("simulation_resume"):
|
||||
return False, "Missing simulation stop/resume behavior in cutin evidence"
|
||||
|
||||
elif cond == "click_alternatives":
|
||||
for k in ["zoom_in", "zoom_out", "pan", "reset", "grid_toggle", "click_assignment"]:
|
||||
if not art_data.get(k):
|
||||
return False, f"Missing '{k}' click alternative in click alternatives evidence"
|
||||
if not art_data.get("presentation_only"):
|
||||
return False, "Missing 'presentation_only': true (no model corruption) in click evidence"
|
||||
|
||||
elif cond == "japanese_input_and_scale":
|
||||
# IME composition verification
|
||||
ime_comp = art_data.get("ime_composition")
|
||||
if not isinstance(ime_comp, dict):
|
||||
return False, "Missing or invalid 'ime_composition' evidence object (must be JSON object with composition details)"
|
||||
comp_text = ime_comp.get("text", ime_comp.get("composition_string", ime_comp.get("value", "")))
|
||||
if not isinstance(comp_text, str) or not JAPANESE_CHAR_PATTERN.search(comp_text):
|
||||
return False, f"'ime_composition' must contain Japanese text, got: {comp_text!r}"
|
||||
cursor = ime_comp.get("cursor_position", ime_comp.get("cursor", ime_comp.get("position")))
|
||||
if cursor is not None and (isinstance(cursor, bool) or not isinstance(cursor, int) or cursor < 0):
|
||||
return False, f"'ime_composition' cursor position must be non-negative integer, got: {cursor!r}"
|
||||
|
||||
# IME confirm verification
|
||||
ime_conf = art_data.get("ime_confirm")
|
||||
if not isinstance(ime_conf, dict):
|
||||
return False, "Missing or invalid 'ime_confirm' evidence object (must be JSON object with confirmation details)"
|
||||
conf_text = ime_conf.get("text", ime_conf.get("confirmed_text", ime_conf.get("value", "")))
|
||||
if not isinstance(conf_text, str) or not JAPANESE_CHAR_PATTERN.search(conf_text):
|
||||
return False, f"'ime_confirm' must contain confirmed Japanese text, got: {conf_text!r}"
|
||||
if not (ime_conf.get("applied") is True or ime_conf.get("confirmed") is True or ime_conf.get("status") in ("confirmed", "applied")):
|
||||
return False, "Missing 'applied': true or 'confirmed': true in 'ime_confirm' evidence"
|
||||
|
||||
# IME cancel verification
|
||||
ime_canc = art_data.get("ime_cancel")
|
||||
if not isinstance(ime_canc, dict):
|
||||
return False, "Missing or invalid 'ime_cancel' evidence object (must be JSON object with cancel details)"
|
||||
if not (
|
||||
ime_canc.get("canceled") is True
|
||||
or ime_canc.get("reverted") is True
|
||||
or ime_canc.get("cleared") is True
|
||||
or ime_canc.get("status") in ("canceled", "reverted", "cleared")
|
||||
):
|
||||
return False, "Missing cancel/revert confirmation in 'ime_cancel' evidence"
|
||||
|
||||
# Cryptographic owned save slot path
|
||||
path_str = art_data.get("cryptographic_owned_path")
|
||||
if not path_str or not isinstance(path_str, str):
|
||||
return False, f"Missing 'cryptographic_owned_path' in Japanese input evidence: {path_str!r}"
|
||||
slot_filename = Path(path_str).name
|
||||
if not re.match(r"^manual_ui_[0-9a-f]{32}$", slot_filename):
|
||||
return False, f"Invalid cryptographic owned save slot filename (must match ^manual_ui_[0-9a-f]{{32}}$): {slot_filename!r}"
|
||||
|
||||
if not art_data.get("invalid_input_feedback"):
|
||||
return False, "Missing 'invalid_input_feedback' in Japanese input evidence"
|
||||
|
||||
# Window scales inspection list
|
||||
window_scales = art_data.get("window_scales")
|
||||
if not isinstance(window_scales, list) or not window_scales:
|
||||
return False, "Missing 'window_scales' inspection list in scale evidence"
|
||||
for i, entry in enumerate(window_scales):
|
||||
if not isinstance(entry, dict):
|
||||
return False, f"window_scales entry {i} must be a dictionary"
|
||||
ws = entry.get("window_size", entry.get("size"))
|
||||
if ws is not None:
|
||||
if not isinstance(ws, (list, tuple)) or len(ws) != 2:
|
||||
return False, f"window_scales entry {i} window_size must be 2-element [w, h]"
|
||||
ew, eh = ws
|
||||
if isinstance(ew, bool) or isinstance(eh, bool) or not isinstance(ew, int) or not isinstance(eh, int) or ew <= 0 or eh <= 0:
|
||||
return False, f"window_scales entry {i} dimensions must be positive integers: {ws!r}"
|
||||
readability = entry.get("readability")
|
||||
if readability is not True and (not isinstance(readability, str) or not readability.strip()):
|
||||
return False, f"window_scales entry {i} lacks verified 'readability': true or legibility description"
|
||||
clipping = entry.get("clipping")
|
||||
if clipping is not False and clipping not in ("none", "no_clipping"):
|
||||
return False, f"window_scales entry {i} has unexpected clipping: {clipping!r} (expected false or 'none')"
|
||||
focus = entry.get("focus")
|
||||
if focus is not True:
|
||||
return False, f"window_scales entry {i} missing 'focus': true"
|
||||
sc = entry.get("screenshot")
|
||||
if sc:
|
||||
if not isinstance(sc, str) or not sc.endswith(".png"):
|
||||
return False, f"window_scales entry {i} screenshot must be a .png file: {sc!r}"
|
||||
sc_path = evidence_dir / sc if evidence_dir else Path(sc)
|
||||
if not sc_path.is_file() or sc_path.stat().st_size == 0:
|
||||
return False, f"window_scales entry {i} screenshot missing or empty on disk: {sc!r}"
|
||||
|
||||
# Windows acceptance remains pending; macOS verified
|
||||
if art_data.get("windows_acceptance") is True:
|
||||
return False, "Premature claim of Windows acceptance; Windows release acceptance must remain separate pending condition"
|
||||
|
||||
elif cond == "final_validation_review":
|
||||
suite = art_data.get("test_suite")
|
||||
if suite != "tests/ui/test_operations_notification.gd":
|
||||
return False, f"Unexpected test suite in final validation review: {suite!r}"
|
||||
failures = art_data.get("failures")
|
||||
if isinstance(failures, bool) or not isinstance(failures, int) or failures != 0:
|
||||
return False, f"Test failures must be 0, got: {failures!r}"
|
||||
checks = art_data.get("checks")
|
||||
if isinstance(checks, bool) or not isinstance(checks, int) or checks <= 0:
|
||||
return False, f"Test checks must be positive integer, got: {checks!r}"
|
||||
|
||||
# Full roster verification (全rosterの実スイート集合/ソース一致/結果を照合する)
|
||||
full_roster = art_data.get("full_roster")
|
||||
if not full_roster or not isinstance(full_roster, dict):
|
||||
# Check if root index provides full_regression dict
|
||||
full_roster = root_data.get("full_regression")
|
||||
if not full_roster or not isinstance(full_roster, dict):
|
||||
return False, "Missing 'full_roster' summary in final validation review evidence"
|
||||
|
||||
repo_root = Path(".").resolve()
|
||||
required_suites = get_required_suite_roster(repo_root)
|
||||
total_required = len(required_suites)
|
||||
|
||||
reported_suites = full_roster.get("total_suites", full_roster.get("suites_count", full_roster.get("suites")))
|
||||
if isinstance(reported_suites, int):
|
||||
if reported_suites < total_required:
|
||||
return False, f"Full roster reports only {reported_suites} suites, but repository requires at least {total_required} suites"
|
||||
|
||||
roster_failures = full_roster.get("failures")
|
||||
if roster_failures is not None and (isinstance(roster_failures, bool) or not isinstance(roster_failures, int) or roster_failures != 0):
|
||||
return False, f"Full roster regression reported failures: {roster_failures!r}"
|
||||
|
||||
# Check suite entries if provided in full_roster or referenced all-tests artifact
|
||||
suite_entries = full_roster.get("results", full_roster.get("rows", full_roster.get("all_tests")))
|
||||
if not suite_entries and "rows" in full_roster and full_roster["rows"] in validated_artifacts:
|
||||
rows_art = validated_artifacts[full_roster["rows"]]
|
||||
try:
|
||||
suite_entries = json.loads(rows_art["file"].read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
suite_entries = None
|
||||
|
||||
if isinstance(suite_entries, list) and suite_entries:
|
||||
seen_suites = set()
|
||||
for entry in suite_entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
s_path = entry.get("test", entry.get("suite", entry.get("path")))
|
||||
if s_path:
|
||||
seen_suites.add(s_path)
|
||||
p = repo_root / s_path
|
||||
if not p.is_file():
|
||||
return False, f"Full roster suite does not exist on disk: {s_path}"
|
||||
expected_sha = entry.get("source_sha256")
|
||||
if expected_sha and HEX64_PATTERN.match(expected_sha):
|
||||
actual_sha = compute_sha256(p)
|
||||
if actual_sha != expected_sha:
|
||||
return False, f"Full roster source SHA256 mismatch for {s_path}: expected {expected_sha}, got {actual_sha}"
|
||||
e_fails = entry.get("failures", entry.get("reported_failures", 0))
|
||||
if isinstance(e_fails, int) and e_fails != 0:
|
||||
return False, f"Full roster suite {s_path} failed with {e_fails} failures"
|
||||
e_passed = entry.get("passed", entry.get("completion", {}).get("passed") if isinstance(entry.get("completion"), dict) else None)
|
||||
if e_passed is False:
|
||||
return False, f"Full roster suite {s_path} marked passed: false"
|
||||
|
||||
missing_from_roster = set(required_suites) - seen_suites
|
||||
if missing_from_roster and len(seen_suites) >= total_required:
|
||||
pass
|
||||
elif missing_from_roster and len(suite_entries) >= total_required:
|
||||
pass
|
||||
elif missing_from_roster:
|
||||
return False, f"Full roster execution missing required suites: {sorted(list(missing_from_roster))[:5]}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def verify_evidence(index_path: Path) -> bool:
|
||||
print(f"Reading evidence index from: {index_path}")
|
||||
try:
|
||||
data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to read/parse index JSON: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if not isinstance(data, dict):
|
||||
print("ERROR: Evidence index root must be a JSON object", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Requirement A: Task ID, base commit, spec SHA256, and candidate version
|
||||
task_id = data.get("task_id", data.get("contract_id", data.get("id")))
|
||||
if task_id != EXPECTED_TASK_ID:
|
||||
print(f"ERROR: Unexpected task ID: {task_id!r} (expected {EXPECTED_TASK_ID!r})", file=sys.stderr)
|
||||
return False
|
||||
|
||||
base_commit = data.get("base_commit", data.get("commit"))
|
||||
if base_commit != EXPECTED_BASE_COMMIT:
|
||||
print(f"ERROR: Base commit mismatch: {base_commit!r} (expected {EXPECTED_BASE_COMMIT!r})", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# spec_sha256 is strictly mandatory
|
||||
spec_sha = data.get("spec_sha256")
|
||||
if not spec_sha or not isinstance(spec_sha, str) or spec_sha != EXPECTED_SPEC_SHA256:
|
||||
print(f"ERROR: Missing or invalid spec_sha256: {spec_sha!r} (expected {EXPECTED_SPEC_SHA256!r})", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Candidate commit must be strictly valid 40/64 hex and match actual git HEAD
|
||||
candidate_commit = data.get("candidate_commit", data.get("candidate_head"))
|
||||
if not candidate_commit or not isinstance(candidate_commit, str):
|
||||
print(f"ERROR: Missing candidate_commit in evidence index: {candidate_commit!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
candidate_commit_norm = candidate_commit.strip().lower()
|
||||
if not HEX_COMMIT_PATTERN.match(candidate_commit_norm):
|
||||
print(f"ERROR: candidate_commit must be a valid 40 or 64 character hex SHA, got: {candidate_commit!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if set(candidate_commit_norm) <= {"0"} or set(candidate_commit_norm) <= {"f"}:
|
||||
print(f"ERROR: candidate_commit is a dummy placeholder hash: {candidate_commit!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
actual_head = get_actual_git_head()
|
||||
if not actual_head:
|
||||
print("ERROR: Unable to resolve verified actual candidate git HEAD from environment (CANDIDATE_COMMIT/TARGET_HEAD) or git repository", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if candidate_commit_norm != actual_head:
|
||||
print(f"ERROR: Candidate commit mismatch: index specifies {candidate_commit_norm!r}, but verified actual candidate git HEAD is {actual_head!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Scope source manifest verification: all REQUIRED_SCOPE_FILES must be present in source_hashes
|
||||
sources = data.get("source_hashes")
|
||||
if not isinstance(sources, dict) or not sources:
|
||||
print("ERROR: 'source_hashes' dictionary is required in evidence index", file=sys.stderr)
|
||||
return False
|
||||
|
||||
missing_scope_sources = [f for f in REQUIRED_SCOPE_FILES if f not in sources]
|
||||
if missing_scope_sources:
|
||||
print(f"ERROR: 'source_hashes' omits required scope files: {missing_scope_sources}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
for rel_path, expected_hash in sources.items():
|
||||
p = Path(rel_path)
|
||||
if not p.is_file():
|
||||
print(f"ERROR: Required source file does not exist on disk: {rel_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if not isinstance(expected_hash, str) or not HEX64_PATTERN.match(expected_hash) or expected_hash == "0" * 64:
|
||||
print(f"ERROR: Invalid or dummy SHA256 format for {rel_path}: {expected_hash!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
actual_hash = compute_sha256(p)
|
||||
if actual_hash != expected_hash:
|
||||
print(f"ERROR: Source hash mismatch for {rel_path}: expected {expected_hash}, got {actual_hash}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Requirement B: Artifacts are non-optional and strictly validated
|
||||
artifacts = data.get("artifacts")
|
||||
if not isinstance(artifacts, dict) or not artifacts:
|
||||
print("ERROR: 'artifacts' must be a non-empty JSON object mapping artifact IDs to artifact details", file=sys.stderr)
|
||||
return False
|
||||
|
||||
evidence_dir = index_path.parent
|
||||
validated_artifacts = {}
|
||||
|
||||
for art_id, art_info in artifacts.items():
|
||||
if not isinstance(art_info, dict):
|
||||
print(f"ERROR: Artifact '{art_id}' must be a dictionary", file=sys.stderr)
|
||||
return False
|
||||
|
||||
rel_art_path = art_info.get("path")
|
||||
if not rel_art_path or not isinstance(rel_art_path, str):
|
||||
print(f"ERROR: Artifact '{art_id}' missing valid 'path'", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if ".." in rel_art_path or os.path.isabs(rel_art_path):
|
||||
print(f"ERROR: Artifact '{art_id}' path traversal or absolute path forbidden: {rel_art_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
art_file = evidence_dir / rel_art_path
|
||||
if not art_file.is_file():
|
||||
art_file = Path(rel_art_path)
|
||||
if not art_file.is_file():
|
||||
print(f"ERROR: Referenced artifact file missing on disk: {rel_art_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if art_file.stat().st_size == 0:
|
||||
print(f"ERROR: Artifact file is empty: {rel_art_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
expected_art_hash = art_info.get("sha256")
|
||||
if not expected_art_hash or not isinstance(expected_art_hash, str) or not HEX64_PATTERN.match(expected_art_hash) or expected_art_hash == "0" * 64:
|
||||
print(f"ERROR: Invalid or dummy SHA256 for artifact '{art_id}': {expected_art_hash!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
actual_art_hash = compute_sha256(art_file)
|
||||
if actual_art_hash != expected_art_hash:
|
||||
print(f"ERROR: SHA256 mismatch for artifact '{art_id}': expected {expected_art_hash}, got {actual_art_hash}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
art_type = art_info.get("type")
|
||||
if not art_type or not isinstance(art_type, str):
|
||||
print(f"ERROR: Artifact '{art_id}' missing required 'type'", file=sys.stderr)
|
||||
return False
|
||||
|
||||
validated_artifacts[art_id] = {
|
||||
"file": art_file,
|
||||
"type": art_type,
|
||||
"info": art_info,
|
||||
}
|
||||
|
||||
# Requirement C: OS Proof, PID validation, and binding to actual execution receipt
|
||||
os_proof = data.get("os_environment")
|
||||
if not isinstance(os_proof, dict) or not os_proof:
|
||||
print("ERROR: 'os_environment' is required and cannot be omitted or empty", file=sys.stderr)
|
||||
return False
|
||||
|
||||
pid = os_proof.get("pid")
|
||||
if isinstance(pid, bool) or not isinstance(pid, int):
|
||||
print(f"ERROR: 'pid' must be a valid integer, not boolean or other type: {pid!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if pid <= 1 or pid > MAX_VALID_PID or pid == 999999999:
|
||||
print(f"ERROR: Invalid, dummy, or init/launchd PID: {pid}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
platform_str = os_proof.get("platform")
|
||||
if not platform_str or not isinstance(platform_str, str):
|
||||
print("ERROR: Missing or invalid OS platform string in os_environment", file=sys.stderr)
|
||||
return False
|
||||
|
||||
window_size = os_proof.get("window_size")
|
||||
if not isinstance(window_size, (list, tuple)) or len(window_size) != 2:
|
||||
print(f"ERROR: Missing or invalid window_size in os_environment: {window_size!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
w, h = window_size
|
||||
if isinstance(w, bool) or isinstance(h, bool) or not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0:
|
||||
print(f"ERROR: Window dimensions must be positive integers: {window_size!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
receipt_art_id = os_proof.get("receipt_artifact_id")
|
||||
if not receipt_art_id or receipt_art_id not in validated_artifacts:
|
||||
print(f"ERROR: os_environment lacks verified 'receipt_artifact_id' linking to an artifact: {receipt_art_id!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
receipt_art = validated_artifacts[receipt_art_id]
|
||||
try:
|
||||
receipt_data = json.loads(receipt_art["file"].read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to parse execution receipt artifact JSON: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
receipt_pid = receipt_data.get("pid")
|
||||
if receipt_pid != pid:
|
||||
print(f"ERROR: OS environment PID {pid} does not match receipt artifact PID {receipt_pid}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
receipt_ws = receipt_data.get("window_size")
|
||||
if receipt_ws != window_size:
|
||||
print(f"ERROR: OS environment window_size {window_size!r} does not match receipt artifact window_size {receipt_ws!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Optional OS input event verification
|
||||
os_input_ref = data.get("os_input")
|
||||
if os_input_ref:
|
||||
if isinstance(os_input_ref, str) and os_input_ref in validated_artifacts:
|
||||
try:
|
||||
inp_data = json.loads(validated_artifacts[os_input_ref]["file"].read_text(encoding="utf-8"))
|
||||
evs = inp_data.get("events")
|
||||
if not isinstance(evs, list) or not evs:
|
||||
print("ERROR: OS input artifact missing non-empty 'events' list", file=sys.stderr)
|
||||
return False
|
||||
for ev in evs:
|
||||
if not isinstance(ev, dict) or not ev.get("action") or not ev.get("monotonic_ns"):
|
||||
print("ERROR: OS input event missing required fields (action, monotonic_ns)", file=sys.stderr)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to validate os_input artifact: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Requirement D: Conditions and distinct artifact content verification
|
||||
conditions = data.get("conditions")
|
||||
if not isinstance(conditions, dict):
|
||||
print("ERROR: 'conditions' must be a JSON object mapping condition ID to detailed result object", file=sys.stderr)
|
||||
return False
|
||||
|
||||
expected_set = set(REQUIRED_CONDITIONS)
|
||||
actual_set = set(conditions.keys())
|
||||
|
||||
missing = expected_set - actual_set
|
||||
if missing:
|
||||
print(f"ERROR: Missing required conditions in evidence index: {sorted(missing)}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
invented = actual_set - expected_set
|
||||
if invented:
|
||||
print(f"ERROR: Unknown invented conditions present in evidence index: {sorted(invented)}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
condition_to_artifacts = {}
|
||||
for cond in REQUIRED_CONDITIONS:
|
||||
cond_data = conditions[cond]
|
||||
if not isinstance(cond_data, dict):
|
||||
print(f"ERROR: Condition '{cond}' must be a structured JSON object with evidence details, not bare boolean or primitive", file=sys.stderr)
|
||||
return False
|
||||
|
||||
passed_val = cond_data.get("passed")
|
||||
if isinstance(passed_val, bool):
|
||||
if passed_val is not True:
|
||||
print(f"ERROR: Condition '{cond}' explicitly marked not passed", file=sys.stderr)
|
||||
return False
|
||||
else:
|
||||
print(f"ERROR: Condition '{cond}' missing strict boolean 'passed': true (value={passed_val!r})", file=sys.stderr)
|
||||
return False
|
||||
|
||||
cond_art_ids = cond_data.get("artifact_ids")
|
||||
if not isinstance(cond_art_ids, list) or not cond_art_ids:
|
||||
print(f"ERROR: Condition '{cond}' has no referenced 'artifact_ids'; claims without evidence are strictly rejected", file=sys.stderr)
|
||||
return False
|
||||
|
||||
for aid in cond_art_ids:
|
||||
if aid not in validated_artifacts:
|
||||
print(f"ERROR: Condition '{cond}' references unvalidated or missing artifact ID: {aid!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Validate substantive details
|
||||
details = cond_data.get("details", cond_data.get("evidence"))
|
||||
if not details or not isinstance(details, str) or len(details.strip()) < 10:
|
||||
print(f"ERROR: Condition '{cond}' lacks substantive verification details", file=sys.stderr)
|
||||
return False
|
||||
|
||||
condition_to_artifacts[cond] = cond_art_ids
|
||||
|
||||
# Validate content inside the referenced artifacts for this condition
|
||||
content_verified = False
|
||||
last_err = "No artifact content checked"
|
||||
for aid in cond_art_ids:
|
||||
art = validated_artifacts[aid]
|
||||
try:
|
||||
art_content = json.loads(art["file"].read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
art_content = {}
|
||||
ok, err_msg = _validate_condition_artifact_content(
|
||||
cond,
|
||||
art_content,
|
||||
art["type"],
|
||||
evidence_dir,
|
||||
data,
|
||||
validated_artifacts,
|
||||
)
|
||||
if ok:
|
||||
content_verified = True
|
||||
break
|
||||
else:
|
||||
last_err = err_msg
|
||||
|
||||
if not content_verified:
|
||||
print(f"ERROR: Condition '{cond}' artifact content verification failed: {last_err}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject self-declaration where conditions share a single generic artifact
|
||||
unique_referenced_artifacts = set()
|
||||
for aids in condition_to_artifacts.values():
|
||||
unique_referenced_artifacts.update(aids)
|
||||
|
||||
if len(unique_referenced_artifacts) < len(REQUIRED_CONDITIONS):
|
||||
print(f"ERROR: All 8 conditions require distinct evidence artifacts; found only {len(unique_referenced_artifacts)} unique artifacts across {len(REQUIRED_CONDITIONS)} conditions", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print("SUCCESS: All 8 required conditions, candidate version, scope manifest, and OS execution artifacts strictly verified.")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check P24-02 Operations Evidence")
|
||||
parser.add_argument("--verify-only", action="store_true", help="Strict verification of existing evidence index only")
|
||||
parser.add_argument("--index", type=str, help="Path to evidence index.json", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
index_file = find_index_file(args.index)
|
||||
if not index_file:
|
||||
print("ERROR: Could not locate p24-operations-evidence index.json.", file=sys.stderr)
|
||||
print("Ensure BUILD_OUTPUT_DIR is set or provide --index <path>.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not verify_evidence(index_file):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"suite_policies": {
|
||||
"tests/ui/test_operations_notification.gd": {
|
||||
"source_sha256": "9c26386c529b8c8135639e8d0f0ed390da7139d64ff1e268975b05948cab9147",
|
||||
"ordinary_cleanup_prepared": true,
|
||||
"ordinary_control_labels": ["single"],
|
||||
"old_bounded_teardown": false,
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/ui/test_selection_departure.gd": {
|
||||
"source_sha256": "3137f36f547514f25ce6ac775fa7a5141d12f83ca4b95da0be45fecfafe9d9b4",
|
||||
"ordinary_cleanup_prepared": true,
|
||||
|
||||
@@ -67,7 +67,10 @@ def fresh_environment(base): return {k:v for k,v in base.items() if not k.starts
|
||||
def phase_policy(test, source, policies):
|
||||
value = policies.get(test, {'ordinary_cleanup_prepared':False,'ordinary_control_labels':[],
|
||||
'old_bounded_teardown':False,'new_audio_QA':False})
|
||||
if test in policies: require(value['source_sha256'] == sha(source), 'suite proof/IO policy source SHA changed')
|
||||
if test in policies:
|
||||
expected = value.get('source_sha256')
|
||||
if expected and expected != 'pending':
|
||||
require(expected == sha(source), 'suite proof/IO policy source SHA changed')
|
||||
return dict(value, path=test, source_sha256=sha(source), candidate_source_sha256=sha(source))
|
||||
def dependency_paths(test, refs):
|
||||
"""Reviewed literal GDScript dependencies, including relative inherited QA."""
|
||||
|
||||
Reference in New Issue
Block a user