Record original-alignment implementation and traceability checkpoint

Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
This commit is contained in:
Takeshi
2026-09-30 23:52:24 +09:00
parent 6077e02ebe
commit 5dde0c5ec2
490 changed files with 54051 additions and 809 deletions
+418
View File
@@ -0,0 +1,418 @@
class_name DefenseManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Player controls for an incident that already exists. Threat creation,
## combat balancing, skill authoring and automatic combat belong to Runtime.
signal status_requested(message: String, success: bool)
const KEY_PREFIX := "ui.defense."
const COMMANDS := [&"register_defense_combatant", &"register_defense_asset", &"start_defense_raid", &"set_defense_retreat_policy", &"begin_next_defense_wave", &"retreat_defenders"]
const STATE_NAMES := ["準備中", "戦闘中", "次の襲撃待ち", "撃退", "突破された", "撤退", "準備終了"]
const SOURCE_NAMES := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"}
const POLICIES := [&"HOLD", &"MANUAL", &"THRESHOLD", &"PROTECT_AND_RETREAT"]
const POLICY_LABELS := ["持ち場を守る", "自分で撤退を指示", "体力が減ったら撤退", "体力・施設の損傷で撤退"]
var selected_raid_id: StringName = &""
var selected_staff_id: StringName = &""
var selected_objective_id: StringName = &""
var raid_option: OptionButton
var staff_option: OptionButton
var objective_option: OptionButton
var policy_option: OptionButton
var threshold_spin: SpinBox
var summary_label: Label
var defenders_label: Label
var assets_label: Label
var staff_label: Label
var objective_label: Label
var result_label: Label
var register_staff_button: Button
var register_objective_button: Button
var start_button: Button
var policy_button: Button
var next_wave_button: Button
var retreat_button: Button
var confirmation: ConfirmationDialog
var _runtime: Variant
var _style_button: Callable
var _report: Dictionary = {}
var _raid_labels: Dictionary = {}
var _pending: Dictionary = {}
var _queue_instance_id := 0
var _sequence := 1
var _built := false
var _confirmation_context: Dictionary = {}
var _policy_drafts: Dictionary = {}
var _refreshing := false
func configure(runtime: Variant, style_button := Callable()) -> void:
_runtime = runtime
_style_button = style_button
func _ready() -> void:
name = "DefenseManagementPanel"
size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 6)
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
refresh()
func _build_interface() -> void:
raid_option = _option("DefenseRaidOption")
raid_option.fit_to_longest_item = false
raid_option.clip_text = true
raid_option.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
raid_option.item_selected.connect(func(index: int) -> void: select_raid(StringName(raid_option.get_item_metadata(index))))
add_child(raid_option)
summary_label = _label("", "DefenseSummary")
add_child(summary_label)
defenders_label = _label("", "DefenseParticipants")
add_child(defenders_label)
assets_label = _label("", "DefenseAssets")
add_child(assets_label)
add_child(HSeparator.new())
add_child(_label("参加する従業員"))
staff_option = _option("DefenseStaffOption")
staff_option.item_selected.connect(func(index: int) -> void:
selected_staff_id = StringName(staff_option.get_item_metadata(index))
_refresh_controls())
add_child(staff_option)
staff_label = _label("", "DefenseStaffStatus")
add_child(staff_label)
register_staff_button = _button("防衛に参加", "DefenseRegisterStaff", _register_staff)
add_child(register_staff_button)
add_child(_label("防衛する施設"))
objective_option = _option("DefenseObjectiveOption")
objective_option.item_selected.connect(func(index: int) -> void:
selected_objective_id = StringName(objective_option.get_item_metadata(index))
_refresh_controls())
add_child(objective_option)
objective_label = _label("", "DefenseObjectiveStatus")
add_child(objective_label)
register_objective_button = _button("防衛対象に登録", "DefenseRegisterObjective", _register_objective)
add_child(register_objective_button)
add_child(HSeparator.new())
add_child(_label("撤退の方針"))
policy_option = _option("DefenseRetreatPolicy")
for i: int in POLICIES.size():
policy_option.add_item(POLICY_LABELS[i])
policy_option.set_item_metadata(i, POLICIES[i])
policy_option.item_selected.connect(func(_index: int) -> void: _store_policy_draft())
add_child(policy_option)
var threshold_row := HBoxContainer.new()
threshold_row.add_child(_label("撤退する残量(%)"))
threshold_spin = SpinBox.new()
threshold_spin.name = "DefenseRetreatThreshold"
threshold_spin.min_value = 0
threshold_spin.max_value = 100
threshold_spin.step = 1
threshold_spin.value_changed.connect(func(_value: float) -> void: _store_policy_draft())
threshold_row.add_child(threshold_spin)
add_child(threshold_row)
policy_button = _button("撤退方針を適用", "DefenseApplyPolicy", _apply_policy)
add_child(policy_button)
var actions := HBoxContainer.new()
start_button = _button("防衛を開始", "DefenseStart", func() -> void: _submit(&"start_defense_raid"))
next_wave_button = _button("次の襲撃に対応", "DefenseNextWave", func() -> void: _submit(&"begin_next_defense_wave"))
retreat_button = _button("撤退する", "DefenseRetreat", _request_retreat_confirmation)
for button: Button in [start_button, next_wave_button, retreat_button]:
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
actions.add_child(button)
add_child(actions)
result_label = _label("", "DefenseResult")
add_child(result_label)
confirmation = ConfirmationDialog.new()
confirmation.name = "DefenseConfirmation"
confirmation.ok_button_text = "実行する"
confirmation.cancel_button_text = "戻る"
confirmation.confirmed.connect(_confirm_operation)
confirmation.canceled.connect(func() -> void: _confirmation_context.clear())
add_child(confirmation)
confirmation.get_label().add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(confirmation.get_label(), LayerSkin.active_layer(self))
if _style_button.is_valid():
for button: Button in [confirmation.get_ok_button(), confirmation.get_cancel_button()]:
_style_button.call(button)
func select_raid(raid_id: StringName) -> void:
selected_raid_id = raid_id
selected_staff_id = &""
selected_objective_id = &""
_confirmation_context.clear()
if confirmation != null: confirmation.hide()
refresh()
func refresh() -> void:
if not _built:
return
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue != null and queue.get_instance_id() != _queue_instance_id:
_queue_instance_id = queue.get_instance_id()
_policy_drafts.clear()
_confirmation_context.clear()
confirmation.hide()
for option: OptionButton in [raid_option, staff_option, objective_option, policy_option]:
option.get_popup().hide()
_synchronize_commands(queue)
_report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {}
_raid_labels = _build_raid_labels(_report.get(&"raids", []))
selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &""))
selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id)
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id)
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id)
var row: Dictionary = _report.get(&"selected", {})
var draft: Dictionary = _policy_drafts.get(selected_raid_id, row)
var policy := StringName(draft.get(&"retreat_policy", &"HOLD"))
_refreshing = true
policy_option.select(maxi(POLICIES.find(policy), 0))
threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0)))
_refreshing = false
summary_label.text = "進行中・保存済みの防衛戦はありません。" if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"]
var names: Array[String] = []
for defender: Dictionary in _report.get(&"defenders", []):
var employee := _row(&"staff", &"staff_id", defender[&"actor_id"])
names.append("%s 体力%d/%d・戦闘%d" % [employee.get(&"display_name", defender[&"actor_id"]), defender[&"health"], defender[&"max_health"], defender[&"combat"]])
defenders_label.text = "参加者:" + ("、".join(names) if not names.is_empty() else "未登録")
names.clear()
for asset: Dictionary in _report.get(&"assets", []):
var facility := _row(&"objectives", &"facility_id", asset[&"asset_id"])
names.append("%s 耐久%d/%d" % [facility.get(&"display_name", asset[&"asset_id"]), asset[&"integrity"], asset[&"max_integrity"]])
assets_label.text = "対象:" + ("、".join(names) if not names.is_empty() else "未登録")
_refresh_controls()
func _refresh_controls() -> void:
var row: Dictionary = _report.get(&"selected", {})
var state := int(row.get(&"state", -1))
var pending := _has_pending(selected_raid_id)
var blocked := row.is_empty() or pending or not bool(_report.get(&"can_control", false))
var staff := _row(&"staff", &"staff_id", selected_staff_id)
var objective := _row(&"objectives", &"facility_id", selected_objective_id)
staff_label.text = "従業員がいません。" if staff.is_empty() else "体力%d/100・戦闘%d\n%s" % [staff[&"health"], staff[&"combat"], "参加できます。参加中は配属を変更できません。" if staff[&"can_register"] else staff[&"reason"]]
objective_label.text = "防衛対象がありません。" if objective.is_empty() else "耐久%d/10000\n%s" % [objective[&"durability"], "登録できます。" if objective[&"can_register"] else objective[&"reason"]]
register_staff_button.disabled = blocked or staff.is_empty() or not bool(staff.get(&"can_register", false))
register_objective_button.disabled = blocked or objective.is_empty() or not bool(objective.get(&"can_register", false))
start_button.visible = state == DefenseService.RaidState.PREPARING
start_button.disabled = blocked or not (row.get(&"missing_objectives", []) as Array).is_empty()
next_wave_button.visible = state == DefenseService.RaidState.BETWEEN_WAVES
next_wave_button.disabled = blocked
retreat_button.visible = state in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]
retreat_button.disabled = blocked
var terminal := state in DefenseService.TERMINAL_STATES or state < 0
policy_option.disabled = blocked or terminal
var chosen_policy := StringName(policy_option.get_item_metadata(policy_option.selected))
threshold_spin.editable = not blocked and not terminal and chosen_policy in [&"THRESHOLD", &"PROTECT_AND_RETREAT"]
policy_button.disabled = blocked or terminal
staff_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or staff.is_empty()
objective_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or objective.is_empty()
if pending: result_label.text = "指示を受理しました。次の時間更新で反映します。"
func _register_staff() -> void:
var staff := _row(&"staff", &"staff_id", selected_staff_id)
if staff.is_empty() or not bool(staff.get(&"can_register", false)): return
_submit(&"register_defense_combatant", {&"actor_id": selected_staff_id, &"side": &"DEFENDER", &"combat": int(staff[&"combat"])})
func _register_objective() -> void:
var facility := _row(&"objectives", &"facility_id", selected_objective_id)
if facility.is_empty() or not bool(facility.get(&"can_register", false)): return
# Plain objectives have no defense-device bonus. Omit numeric overrides and
# use the existing Runtime command defaults and current live durability.
_submit(&"register_defense_asset", {&"asset_id": selected_objective_id, &"kind": &"OBJECTIVE"})
func _apply_policy() -> void:
_submit(&"set_defense_retreat_policy", {&"policy": policy_option.get_item_metadata(policy_option.selected), &"threshold": int(threshold_spin.value)})
func _store_policy_draft() -> void:
if _refreshing or selected_raid_id.is_empty() or _has_pending(selected_raid_id): return
_policy_drafts[selected_raid_id] = {&"retreat_policy": policy_option.get_item_metadata(policy_option.selected), &"defender_retreat_threshold": int(threshold_spin.value)}
_refresh_controls()
func _request_retreat_confirmation() -> void:
refresh()
var row: Dictionary = _report.get(&"selected", {})
if _has_pending(selected_raid_id) or selected_raid_id.is_empty() or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: return
_confirmation_context = {&"raid_id": selected_raid_id, &"queue_instance_id": _queue_instance_id}
confirmation.title = "撤退の確認"
confirmation.dialog_text = "撤退すると防衛戦は終了します。参加者の状態と施設の損傷が反映されます。"
confirmation.popup_centered(Vector2i(430, 170))
func _confirm_operation() -> void:
var context := _confirmation_context.duplicate()
_confirmation_context.clear()
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue == null or queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)) or context.get(&"raid_id", &"") != selected_raid_id:
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
refresh()
return
refresh()
if context.get(&"raid_id", &"") != selected_raid_id:
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
return
var row: Dictionary = _report.get(&"selected", {})
if _has_pending(selected_raid_id) or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]:
_set_result("現在の防衛戦では撤退を指示できません。", false)
return
_submit(&"retreat_defenders")
func _submit(command_type: StringName, patch: Dictionary = {}) -> void:
if selected_raid_id.is_empty() or _has_pending(selected_raid_id) or command_type not in COMMANDS: return
var payload := patch.duplicate(true)
payload[&"raid_id"] = selected_raid_id
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
_sequence += 1
var result: Variant = _runtime.call(&"submit_command", command_type, payload, &"player", selected_raid_id, 75, -1, key)
if result == null or not result.has_method(&"is_success"):
_set_result("防衛の指示に応答がありません。", false)
return
var queue: Variant = _runtime.get(&"command_queue")
var queued := false
for command: Dictionary in queue.create_snapshot().get("pending", []):
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
if result.is_success() and queued:
_pending[int(result.command_id)] = payload
else:
_show_result(result, payload)
refresh()
func _show_result(result: Variant, payload: Dictionary) -> void:
if result.is_success() and payload.has(&"policy"):
_policy_drafts.erase(StringName(payload.get(&"raid_id", &"")))
_set_result("防衛の指示を反映しました。" if result.is_success() else "指示を反映できませんでした。体調・業務中の状態・対象施設を確認してください。", result.is_success())
func _synchronize_commands(queue: Variant) -> void:
_pending.clear()
for metadata: Dictionary in queue.get_accepted_command_metadata():
var key := String(metadata.get("idempotency_key", ""))
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
for command: Dictionary in queue.create_snapshot().get("pending", []):
if StringName(command.get("command_type", &"")) in COMMANDS:
_pending[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
func _on_command_completed(result: Variant) -> void:
if not _pending.has(int(result.command_id)): return
var payload: Dictionary = _pending[int(result.command_id)]
_pending.erase(int(result.command_id))
_show_result(result, payload)
refresh()
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree(): refresh()
func _has_pending(raid_id: StringName) -> bool:
for payload: Dictionary in _pending.values():
if payload.get(&"raid_id", &"") == raid_id: return true
return false
func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Dictionary:
for row: Dictionary in _report.get(key, []):
if row.get(id_key, &"") == entity_id: return row
return {}
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName) -> StringName:
if option.get_popup().visible: return selected
option.clear()
if rows.is_empty():
option.add_item("対象なし")
option.set_item_metadata(0, &"")
return &""
var index := 0
for row: Dictionary in rows:
var id := StringName(row[id_key])
var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id))
option.add_item(display_name)
if id_key == &"raid_id": option.set_item_tooltip(option.item_count - 1, display_name)
option.set_item_metadata(option.item_count - 1, id)
if id == selected: index = option.item_count - 1
option.select(index)
return StringName(option.get_item_metadata(index))
## Cause labels are display-only. Never infer cause from an internal ID or
## source_id, and never use the label as an option's metadata or command target.
func _build_raid_labels(rows: Array) -> Dictionary:
var totals: Dictionary = {}
for row: Dictionary in rows:
var base := _raid_base_label(row)
totals[base] = int(totals.get(base, 0)) + 1
var seen: Dictionary = {}
var labels: Dictionary = {}
for row: Dictionary in rows:
var base := _raid_base_label(row)
seen[base] = int(seen.get(base, 0)) + 1
labels[StringName(row[&"raid_id"])] = base + ("(%d)" % int(seen[base]) if int(totals[base]) > 1 else "")
return labels
func _raid_base_label(row: Dictionary) -> String:
var cause := String(SOURCE_NAMES.get(String(row.get(&"source_kind", "")), "防衛戦"))
var raw_minute: Variant = row.get(&"created_at", null)
if typeof(raw_minute) != TYPE_INT or int(raw_minute) < 0:
return cause + " · 時刻不明"
var minute := int(raw_minute)
return "%s · %d日目 %02d:%02d" % [cause, minute / 1440 + 1, minute % 1440 / 60, minute % 60]
func _set_result(message: String, success: bool) -> void:
result_label.text = message
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
status_requested.emit(message, success)
func _label(text_value: String, node_name := "") -> Label:
var label := Label.new()
if not node_name.is_empty(): label.name = node_name
label.text = text_value
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, LayerSkin.active_layer(self))
return label
func _option(node_name: String) -> OptionButton:
var option := OptionButton.new()
option.name = node_name
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if _style_button.is_valid(): _style_button.call(option)
return option
func _button(text_value: String, node_name: String, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = text_value
button.custom_minimum_size.y = 30
button.pressed.connect(callback)
if _style_button.is_valid(): _style_button.call(button)
return button