NEO-153: add contract issue + progress HUD in Godot client.

Shift+C issues prototype contract via contract_client; contract_hud_controller
shows encounter objective, deny copy, and completion reward transition.
Includes scene wiring, GdUnit tests, manual QA, and README.
pull/195/head
VinPropane 2026-06-28 13:49:14 -04:00
parent 7deb10bf21
commit 365e57799a
13 changed files with 1186 additions and 3 deletions

View File

@ -251,6 +251,21 @@ Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md). Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md).
## Contract issue + progress HUD (NEO-153)
- **`POST /game/players/{id}/contracts/issue`** — **`issued`** + optional **`reasonCode`** + optional **`contract`** row (NEO-151); see [server README — Contract issue HTTP](../server/README.md#contract-issue-http-neo-151).
- **`GET /game/players/{id}/contracts`** — active row (01) + recent completed rows with **`encounterTemplateId`** and optional **`completionRewardSummary`** (NEO-151).
- **Scripts:** `scripts/contract_client.gd`, `scripts/contract_hud_controller.gd`; wired from `main.gd` in `_setup_contract_hud()`.
- **HUD:**
- **`UICanvas/HudRootScroll/HudRoot/ContractActiveLabel`** — active contract with **`encounterTemplateId`**; **`completed`** after pocket clear in-session.
- **`UICanvas/HudRootScroll/HudRoot/ContractIssueFeedbackLabel`** — issue success/deny/failure copy.
- **`UICanvas/HudRootScroll/HudRoot/ContractRewardDeliveryLabel`** — grant lines on in-session **`active``completed`** transition (NEO-131 pattern).
- **Issue key:** **Shift+C** — frozen prototype template **`prototype_contract_clear_combat_pocket`**, seed **`prototype_contract_dev_seed`**.
- **Refresh:** boot hydrate + GET after issue POST + encounter progress GET when pocket **`completed`** (alongside inventory/quest refresh). No periodic poll.
- **Deny copy:** readable strings for **`active_contract_exists`**, **`no_eligible_template`**, **`economy_cap_exceeded`**, **`invalid_reward_bundle`**, **`unknown_template`**.
Full checklist: [`docs/manual-qa/NEO-153.md`](../docs/manual-qa/NEO-153.md).
## Faction standing + gate feedback HUD (NEO-142) ## Faction standing + gate feedback HUD (NEO-142)
- **`GET /game/players/{id}/faction-standing`** — server-authoritative per-faction **`standing`** snapshot (NEO-139); see [server README — Faction standing read](../server/README.md#faction-standing-read-neo-139). - **`GET /game/players/{id}/faction-standing`** — server-authoritative per-faction **`standing`** snapshot (NEO-139); see [server README — Faction standing read](../server/README.md#faction-standing-read-neo-139).
@ -474,7 +489,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`contract_client.gd`**, **`contract_hud_controller.gd`** (NEO-153), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.

View File

@ -24,6 +24,7 @@
[ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"] [ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"]
[ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"] [ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"]
[ext_resource type="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"] [ext_resource type="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"]
[ext_resource type="Script" uid="uid://bneo153contract01" path="res://scripts/contract_client.gd" id="28_contract_client"]
[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"] [ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"]
[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"] [ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
@ -1124,6 +1125,9 @@ script = ExtResource("24_enc_prog")
[node name="QuestProgressClient" type="Node" parent="." unique_id=2500013] [node name="QuestProgressClient" type="Node" parent="." unique_id=2500013]
script = ExtResource("25_quest_prog") script = ExtResource("25_quest_prog")
[node name="ContractClient" type="Node" parent="." unique_id=2500016]
script = ExtResource("28_contract_client")
[node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014] [node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014]
script = ExtResource("26_quest_defs") script = ExtResource("26_quest_defs")
@ -1275,6 +1279,38 @@ autowrap_mode = 3
text = "Quest rewards: text = "Quest rewards:
—" —"
[node name="ContractActiveLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000029]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.98, 0.88, 0.62, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Contract:
—"
[node name="ContractIssueFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000030]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.86, 1, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Contract issue: — (Shift+C prototype)"
[node name="ContractRewardDeliveryLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000031]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.78, 0.95, 0.82, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Contract rewards:
—"
[node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028] [node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3

View File

@ -0,0 +1,265 @@
extends Node
## NEO-153: HTTP client for contract list GET (NEO-151) and contract issue POST (NEO-151).
signal contracts_received(snapshot: Dictionary)
signal contracts_sync_failed(reason: String)
signal contract_issue_result_received(result: Dictionary)
signal contract_issue_failed(reason: String)
const SCHEMA_VERSION := 1
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@export var injected_sync_http: Node = null
@export var injected_issue_http: Node = null
var _sync_http: Node
var _issue_http: Node
var _sync_busy: bool = false
var _sync_pending: bool = false
var _issue_busy: bool = false
func _ready() -> void:
_sync_http = _resolve_http(injected_sync_http)
_issue_http = _resolve_http(injected_issue_http)
@warning_ignore("unsafe_method_access")
_sync_http.request_completed.connect(_on_sync_request_completed)
@warning_ignore("unsafe_method_access")
_issue_http.request_completed.connect(_on_issue_request_completed)
func is_issue_busy() -> bool:
return _issue_busy
func request_sync_from_server() -> void:
if _sync_busy:
_sync_pending = true
return
_start_sync_request()
func _start_sync_request() -> void:
_sync_busy = true
var url := "%s/game/players/%s/contracts" % [_base_root(), _player_path_segment()]
var err: Error = _sync_http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("ContractClient: %s" % reason)
_sync_busy = false
contracts_sync_failed.emit(reason)
_try_flush_pending_sync()
## Returns [code]true[/code] when the HTTP POST was queued.
func request_issue(template_id: String, seed_bucket: String) -> bool:
if _issue_busy:
return false
var tid := template_id.strip_edges()
var seed := seed_bucket.strip_edges()
if tid.is_empty() or seed.is_empty():
return false
_issue_busy = true
var payload: Dictionary = {
"schemaVersion": SCHEMA_VERSION,
"playerId": _player_path_segment(),
"templateId": tid,
"seedBucket": seed,
}
var url := "%s/game/players/%s/contracts/issue" % [_base_root(), _player_path_segment()]
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _issue_http.request(
url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)
)
if err != OK:
_issue_busy = false
push_warning("ContractClient: POST failed to start (%s)" % err)
return false
return true
static func active_contract_row(snapshot: Dictionary = {}) -> Dictionary:
var contracts: Variant = snapshot.get("contracts", null)
if contracts == null or not contracts is Array:
return {}
for row_variant in contracts as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("status", "")) == "active":
return row.duplicate(true)
return {}
static func contract_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary:
var iid := instance_id.strip_edges()
if iid.is_empty():
return {}
var contracts: Variant = snapshot.get("contracts", null)
if contracts == null or not contracts is Array:
return {}
for row_variant in contracts as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("contractInstanceId", "")) == iid:
return row.duplicate(true)
return {}
static func completion_reward_summary(instance_id: String, snapshot: Dictionary = {}) -> Dictionary:
var row: Dictionary = contract_row(instance_id, snapshot)
if row.is_empty() or str(row.get("status", "")) != "completed":
return {}
var summary_variant: Variant = row.get("completionRewardSummary", null)
if summary_variant is Dictionary:
return (summary_variant as Dictionary).duplicate(true)
return {}
static func parse_contracts_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
var contracts: Variant = root.get("contracts", null)
if contracts == null or not contracts is Array:
return null
if str(root.get("playerId", "")).strip_edges().is_empty():
return null
return root
static func parse_contract_issue_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
if not root.has("issued"):
return null
return root
static func merge_contract_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary:
var iid := str(row.get("contractInstanceId", "")).strip_edges()
if iid.is_empty():
return snapshot.duplicate(true) if not snapshot.is_empty() else {}
var merged: Dictionary
if snapshot.is_empty():
merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "contracts": []}
else:
merged = snapshot.duplicate(true)
var contracts: Array = []
var contracts_variant: Variant = merged.get("contracts", null)
if contracts_variant is Array:
for row_variant in contracts_variant as Array:
contracts.append(row_variant)
var replaced := false
for i in contracts.size():
var existing: Variant = contracts[i]
if existing is Dictionary and str((existing as Dictionary).get("contractInstanceId", "")) == iid:
contracts[i] = row.duplicate(true)
replaced = true
break
if not replaced:
contracts.append(row.duplicate(true))
merged["contracts"] = contracts
return merged
func _resolve_http(injected: Node) -> Node:
if injected != null:
return injected
var http := HTTPRequest.new()
add_child(http)
http.timeout = 30.0
return http
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
func _try_flush_pending_sync() -> void:
if not _sync_pending or _sync_busy:
return
_sync_pending = false
_start_sync_request()
func _on_sync_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_sync_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("ContractClient: %s" % reason)
contracts_sync_failed.emit(reason)
_try_flush_pending_sync()
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("ContractClient: %s" % reason404)
contracts_sync_failed.emit(reason404)
_try_flush_pending_sync()
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("ContractClient: %s" % reason_code)
contracts_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return
var text := body.get_string_from_utf8()
var snapshot: Variant = parse_contracts_json(text)
if snapshot == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("ContractClient: %s" % reason_json)
contracts_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return
contracts_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()
func _on_issue_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_issue_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("ContractClient: %s" % reason)
contract_issue_failed.emit(reason)
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("ContractClient: %s" % reason404)
contract_issue_failed.emit(reason404)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("ContractClient: %s" % reason_code)
contract_issue_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var parsed: Variant = parse_contract_issue_json(text)
if parsed == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("ContractClient: %s" % reason_json)
contract_issue_failed.emit(reason_json)
return
var data: Dictionary = parsed as Dictionary
if not bool(data.get("issued", false)):
var reason_variant: Variant = data.get("reasonCode", "")
var reason_str: String = reason_variant as String if reason_variant is String else ""
push_warning("contract_issue_denied reasonCode=%s" % reason_str)
contract_issue_result_received.emit(data.duplicate(true))

View File

@ -0,0 +1 @@
uid://bneo153contract01

View File

@ -0,0 +1,329 @@
extends Node
## NEO-153: contract issue + progress HUD wiring (Shift+C prototype issue).
const ContractClient := preload("res://scripts/contract_client.gd")
const FactionStandingClient := preload("res://scripts/faction_standing_client.gd")
const PROTOTYPE_CONTRACT_TEMPLATE_ID := "prototype_contract_clear_combat_pocket"
const PROTOTYPE_CONTRACT_SEED_BUCKET := "prototype_contract_dev_seed"
const ISSUE_IDLE_HINT := "Contract issue: — (Shift+C prototype)"
const ISSUE_SENDING_HINT := "Contract issue: sending…"
const REWARD_HEADER := "Contract rewards:"
var _contract_client: Node = null
var _item_defs_client: Node = null
var _inventory_client: Node = null
var _skill_progression_client: Node = null
var _faction_standing_client: Node = null
var _active_label: Label = null
var _issue_label: Label = null
var _reward_label: Label = null
var _last_snapshot: Dictionary = {}
var _sync_error: String = ""
var _issue_contract_patch: Dictionary = {}
var _previous_status_by_instance: Dictionary = {}
var _contract_was_active_in_session: Dictionary = {}
var _tracked_instance_id: String = ""
var _last_reward_instance_id: String = ""
var _last_reward_summary: Dictionary = {}
func setup(
contract_client: Node,
active_label: Label,
issue_label: Label,
reward_label: Label,
apply_http_config: Callable,
item_defs_client: Node = null,
inventory_client: Node = null,
skill_progression_client: Node = null,
faction_standing_client: Node = null
) -> void:
_contract_client = contract_client
_active_label = active_label
_issue_label = issue_label
_reward_label = reward_label
_item_defs_client = item_defs_client
_inventory_client = inventory_client
_skill_progression_client = skill_progression_client
_faction_standing_client = faction_standing_client
if apply_http_config.is_valid():
apply_http_config.call(contract_client)
if _contract_client.has_signal("contracts_received"):
_contract_client.connect("contracts_received", Callable(self, "_on_contracts_received"))
if _contract_client.has_signal("contracts_sync_failed"):
_contract_client.connect("contracts_sync_failed", Callable(self, "_on_sync_failed"))
if _contract_client.has_signal("contract_issue_result_received"):
_contract_client.connect(
"contract_issue_result_received", Callable(self, "_on_issue_result_received")
)
if _contract_client.has_signal("contract_issue_failed"):
_contract_client.connect("contract_issue_failed", Callable(self, "_on_issue_failed"))
if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"):
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
_render_active_label()
_render_issue_feedback_label(ISSUE_IDLE_HINT)
_render_reward_label()
request_contract_refresh()
func request_contract_refresh() -> void:
if not is_instance_valid(_contract_client):
return
if _contract_client.has_method("request_sync_from_server"):
_contract_client.call("request_sync_from_server")
func try_issue_key_input(event: InputEvent) -> bool:
if not event is InputEventKey:
return false
var k := event as InputEventKey
if not k.pressed or k.echo or not k.shift_pressed:
return false
if k.physical_keycode != KEY_C and k.keycode != KEY_C:
return false
if not is_instance_valid(_contract_client):
return false
if not _contract_client.has_method("request_issue"):
return false
if bool(_contract_client.call("is_issue_busy")):
return true
_render_issue_feedback_label(ISSUE_SENDING_HINT)
var queued: bool = bool(
_contract_client.call(
"request_issue", PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET
)
)
if not queued:
_render_issue_feedback_label("Contract issue: failed — request not queued")
return true
func _on_contracts_received(snapshot: Dictionary) -> void:
_sync_error = ""
_detect_and_apply_completion_transitions(snapshot)
_last_snapshot = snapshot.duplicate(true)
_reapply_issue_contract_patch_if_needed()
_render_active_label()
_render_reward_label()
func _on_sync_failed(reason: String) -> void:
_sync_error = reason
if _issue_contract_patch.is_empty():
_last_snapshot = {}
elif _last_snapshot.is_empty():
_last_snapshot = ContractClient.merge_contract_row_into_snapshot({}, _issue_contract_patch)
_render_active_label()
_render_reward_label()
func _on_issue_result_received(result: Dictionary) -> void:
if bool(result.get("issued", false)):
var contract_variant: Variant = result.get("contract", null)
if contract_variant is Dictionary:
var row: Dictionary = contract_variant as Dictionary
var iid := str(row.get("contractInstanceId", "")).strip_edges()
if not iid.is_empty():
_mark_contract_active_in_session(iid)
_tracked_instance_id = iid
_issue_contract_patch = row.duplicate(true)
_sync_error = ""
_last_snapshot = ContractClient.merge_contract_row_into_snapshot(
_last_snapshot, _issue_contract_patch
)
var display := str(row.get("templateDisplayName", "")).strip_edges()
if display.is_empty():
display = str(row.get("templateId", PROTOTYPE_CONTRACT_TEMPLATE_ID))
_render_issue_feedback_label("Contract issue: %s issued" % display)
_render_active_label()
else:
_render_issue_feedback_label("Contract issue: issued")
else:
var rc := str(result.get("reasonCode", "")).strip_edges()
_render_issue_feedback_label(_format_issue_deny(rc))
request_contract_refresh()
func _on_issue_failed(reason: String) -> void:
_render_issue_feedback_label("Contract issue: failed — %s" % reason)
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
if not _last_reward_instance_id.is_empty():
_render_reward_label()
func _mark_contract_active_in_session(instance_id: String) -> void:
var iid := instance_id.strip_edges()
if not iid.is_empty():
_contract_was_active_in_session[iid] = true
func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void:
var boot_seed := _previous_status_by_instance.is_empty()
var contracts_variant: Variant = snapshot.get("contracts", null)
if contracts_variant == null or not contracts_variant is Array:
return
for row_variant in contracts_variant as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var iid := str(row.get("contractInstanceId", "")).strip_edges()
if iid.is_empty():
continue
var new_status := str(row.get("status", ""))
if new_status == "active":
_mark_contract_active_in_session(iid)
if _tracked_instance_id.is_empty():
_tracked_instance_id = iid
var prev_status := str(_previous_status_by_instance.get(iid, ""))
var can_detect := not boot_seed or bool(_contract_was_active_in_session.get(iid, false))
if can_detect and prev_status == "active" and new_status == "completed":
var summary := ContractClient.completion_reward_summary(iid, snapshot)
if not summary.is_empty():
_last_reward_instance_id = iid
_last_reward_summary = summary.duplicate(true)
_refresh_economy_hud()
_previous_status_by_instance[iid] = new_status
func _reapply_issue_contract_patch_if_needed() -> void:
if _issue_contract_patch.is_empty():
return
_last_snapshot = ContractClient.merge_contract_row_into_snapshot(
_last_snapshot, _issue_contract_patch
)
func _refresh_economy_hud() -> void:
if (
is_instance_valid(_inventory_client)
and _inventory_client.has_method("request_sync_from_server")
):
_inventory_client.call("request_sync_from_server")
if (
is_instance_valid(_skill_progression_client)
and _skill_progression_client.has_method("request_sync_from_server")
):
_skill_progression_client.call("request_sync_from_server")
func _render_active_label() -> void:
if not is_instance_valid(_active_label):
return
var header := "Contract:"
if not _sync_error.is_empty():
_active_label.text = "%s\nerror — %s" % [header, _sync_error]
return
var row: Dictionary = ContractClient.active_contract_row(_last_snapshot)
if row.is_empty() and not _tracked_instance_id.is_empty():
row = ContractClient.contract_row(_tracked_instance_id, _last_snapshot)
if row.is_empty():
_active_label.text = "%s\n" % header
return
var display := str(row.get("templateDisplayName", "")).strip_edges()
if display.is_empty():
display = str(row.get("templateId", ""))
var status := str(row.get("status", ""))
var encounter := str(row.get("encounterTemplateId", "")).strip_edges()
if encounter.is_empty():
encounter = ""
_active_label.text = "%s\n%s: %s — objective %s" % [header, display, status, encounter]
func _render_issue_feedback_label(text: String = ISSUE_IDLE_HINT) -> void:
if is_instance_valid(_issue_label):
_issue_label.text = text
func _render_reward_label() -> void:
if not is_instance_valid(_reward_label):
return
if not _sync_error.is_empty():
_reward_label.text = "%s\n" % REWARD_HEADER
return
if _last_reward_instance_id.is_empty() or _last_reward_summary.is_empty():
_reward_label.text = "%s\n" % REWARD_HEADER
return
var row: Dictionary = ContractClient.contract_row(_last_reward_instance_id, _last_snapshot)
var display := str(row.get("templateDisplayName", "")).strip_edges()
if display.is_empty():
display = str(row.get("templateId", ""))
var lines: PackedStringArray = [REWARD_HEADER, display]
lines.append_array(_format_reward_summary_lines(_last_reward_summary))
_reward_label.text = "\n".join(lines)
func _format_issue_deny(reason_code: String) -> String:
match reason_code:
"active_contract_exists":
return "Contract issue: denied — active contract already issued"
"no_eligible_template":
return "Contract issue: denied — no eligible template (standing/band)"
"economy_cap_exceeded":
return "Contract issue: denied — economy cap exceeded"
"invalid_reward_bundle":
return "Contract issue: denied — invalid reward bundle"
"unknown_template":
return "Contract issue: denied — unknown template"
"":
return "Contract issue: denied (no reasonCode)"
_:
return "Contract issue: denied — %s" % reason_code
func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = PackedStringArray()
var items_variant: Variant = summary.get("itemGrants", null)
if items_variant is Array:
for grant_variant in items_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var item_id := str(grant.get("itemId", "")).strip_edges()
var qty: int = int(grant.get("quantity", 0))
if item_id.is_empty() or qty <= 0:
continue
lines.append(" %s ×%d" % [_item_display_name(item_id), qty])
var skills_variant: Variant = summary.get("skillXpGrants", null)
if skills_variant is Array:
for grant_variant in skills_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var skill_id := str(grant.get("skillId", "")).strip_edges()
var amount: int = int(grant.get("amount", 0))
if skill_id.is_empty() or amount <= 0:
continue
lines.append(" %s +%d XP" % [_title_case_token(skill_id), amount])
var rep_variant: Variant = summary.get("reputationGrants", null)
if rep_variant is Array:
for grant_variant in rep_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var faction_id := str(grant.get("factionId", "")).strip_edges()
var amount: int = int(grant.get("amount", 0))
if faction_id.is_empty() or amount <= 0:
continue
lines.append(" %s +%d rep" % [FactionStandingClient.display_name_for(faction_id), amount])
if lines.is_empty():
lines.append(" (no grants)")
return lines
func _item_display_name(item_id: String) -> String:
if not is_instance_valid(_item_defs_client):
return item_id
if not _item_defs_client.has_method("display_name_for"):
return item_id
return str(_item_defs_client.call("display_name_for", item_id))
static func _title_case_token(token: String) -> String:
var trimmed := token.strip_edges()
if trimmed.is_empty():
return trimmed
return trimmed.substr(0, 1).to_upper() + trimmed.substr(1)

View File

@ -0,0 +1 @@
uid://bneo153contract02

View File

@ -57,6 +57,7 @@ const BREACH_GIG_ID := "breach"
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
const ContractHudController := preload("res://scripts/contract_hud_controller.gd")
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@ -110,6 +111,7 @@ var _gig_error: String = ""
var _last_encounter_progress_snapshot: Dictionary = {} var _last_encounter_progress_snapshot: Dictionary = {}
var _encounter_progress_error: String = "" var _encounter_progress_error: String = ""
var _quest_hud: Node = null var _quest_hud: Node = null
var _contract_hud: Node = null
var _gather_pre_scrap_qty: int = 0 var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = "" var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false var _gather_awaiting_inventory_finalize: bool = false
@ -137,6 +139,9 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel") @onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel")
@onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") @onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel")
@onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") @onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel")
@onready var _contract_active_label: Label = _hud_root.get_node("ContractActiveLabel")
@onready var _contract_issue_feedback_label: Label = _hud_root.get_node("ContractIssueFeedbackLabel")
@onready var _contract_reward_delivery_label: Label = _hud_root.get_node("ContractRewardDeliveryLabel")
@onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel") @onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel")
@onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel")
@onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel")
@ -155,6 +160,7 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _gig_progression_client: Node = $GigProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient
@onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _encounter_progress_client: Node = $EncounterProgressClient
@onready var _quest_progress_client: Node = $QuestProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient
@onready var _contract_client: Node = $ContractClient
@onready var _quest_defs_client: Node = $QuestDefinitionsClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient
@onready var _faction_standing_client: Node = $FactionStandingClient @onready var _faction_standing_client: Node = $FactionStandingClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@ -207,6 +213,7 @@ func _ready() -> void:
_setup_gig_progression_sync() _setup_gig_progression_sync()
_setup_encounter_progress_sync() _setup_encounter_progress_sync()
_setup_quest_progress_sync() _setup_quest_progress_sync()
_setup_contract_hud()
_setup_gather_interact_feedback() _setup_gather_interact_feedback()
_setup_craft_ui() _setup_craft_ui()
@ -749,6 +756,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void:
if str(row.get("state", "")) == "completed": if str(row.get("state", "")) == "completed":
_request_inventory_refresh() _request_inventory_refresh()
_request_quest_progress_refresh() _request_quest_progress_refresh()
_request_contract_refresh()
func _on_encounter_progress_sync_failed(reason: String) -> void: func _on_encounter_progress_sync_failed(reason: String) -> void:
@ -885,6 +893,35 @@ func _try_quest_accept_key_input(event: InputEvent) -> bool:
return false return false
func _setup_contract_hud() -> void:
# NEO-153: contract issue + progress HUD; boot hydrate + refresh after issue/encounter clear.
_contract_hud = ContractHudController.new()
add_child(_contract_hud)
_contract_hud.call(
"setup",
_contract_client,
_contract_active_label,
_contract_issue_feedback_label,
_contract_reward_delivery_label,
Callable(self, "_apply_authority_http_config_to_client"),
_item_defs_client,
_inventory_client,
_skill_progression_client,
_faction_standing_client
)
func _request_contract_refresh() -> void:
if is_instance_valid(_contract_hud):
_contract_hud.call("request_contract_refresh")
func _try_contract_issue_key_input(event: InputEvent) -> bool:
if is_instance_valid(_contract_hud):
return bool(_contract_hud.call("try_issue_key_input", event))
return false
func _render_gather_feedback_label(text: String = "Gather: —") -> void: func _render_gather_feedback_label(text: String = "Gather: —") -> void:
if is_instance_valid(_gather_feedback_label): if is_instance_valid(_gather_feedback_label):
_gather_feedback_label.text = text _gather_feedback_label.text = text
@ -1407,6 +1444,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool:
return true return true
if _try_quest_accept_key_input(event): if _try_quest_accept_key_input(event):
return true return true
if _try_contract_issue_key_input(event):
return true
if _try_inventory_refresh_input(event): if _try_inventory_refresh_input(event):
return true return true
return false return false

View File

@ -0,0 +1,221 @@
extends GdUnitTestSuite
## NEO-153: `ContractClient` GET parse, issue POST, and failure signals.
const ContractClient := preload("res://scripts/contract_client.gd")
const INSTANCE_ID := "ci_test_instance"
const TEMPLATE_ID := "prototype_contract_clear_combat_pocket"
const ENCOUNTER_ID := "prototype_combat_pocket"
var _contracts_capture: Dictionary = {}
var _issue_capture: Dictionary = {}
var _issue_failed_capture: String = ""
func _capture_contracts(snapshot: Dictionary) -> void:
_contracts_capture = snapshot
func _capture_issue(result: Dictionary) -> void:
_issue_capture = result
func _capture_issue_failed(reason: String) -> void:
_issue_failed_capture = reason
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var last_method: int = HTTPClient.METHOD_GET
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
last_url = url
last_method = method
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
static func _active_contracts_json() -> String:
var body := (
'{"schemaVersion":1,"playerId":"dev-local-1","contracts":'
+ '[{"contractInstanceId":"%s","templateId":"%s",'
+ '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",'
+ '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",'
+ '"issuedAt":"2026-06-28T12:00:00Z"}]}'
)
return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID]
static func _completed_with_summary_json() -> String:
var body := (
'{"schemaVersion":1,"playerId":"dev-local-1","contracts":'
+ '[{"contractInstanceId":"%s","templateId":"%s",'
+ '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"completed",'
+ '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",'
+ '"issuedAt":"2026-06-28T12:00:00Z","completedAt":"2026-06-28T13:00:00Z",'
+ '"completionRewardSummary":{"itemGrants":[{"itemId":"scrap_metal_bulk","quantity":5}],'
+ '"skillXpGrants":[{"skillId":"salvage","amount":15}]}}]}'
)
return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID]
static func _issue_success_json() -> String:
var body := (
'{"schemaVersion":1,"issued":true,"contract":'
+ '{"contractInstanceId":"%s","templateId":"%s",'
+ '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",'
+ '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",'
+ '"issuedAt":"2026-06-28T12:00:00Z"}}'
)
return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID]
static func _issue_deny_json(reason_code: String) -> String:
return '{"schemaVersion":1,"issued":false,"reasonCode":"%s"}' % reason_code
func _make_client(sync_transport: Node, issue_transport: Node = null) -> Node:
var c: Node = ContractClient.new()
c.set("injected_sync_http", sync_transport)
var issue_http: Node = issue_transport if issue_transport != null else sync_transport
c.set("injected_issue_http", issue_http)
auto_free(sync_transport)
if issue_transport != null and issue_transport != sync_transport:
auto_free(issue_transport)
auto_free(c)
add_child(c)
return c
func test_parse_active_contract_row_includes_encounter_id() -> void:
# Arrange
var json := _active_contracts_json()
# Act
var snapshot: Variant = ContractClient.parse_contracts_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var row: Dictionary = ContractClient.active_contract_row(snapshot as Dictionary)
assert_that(str(row.get("status", ""))).is_equal("active")
assert_that(str(row.get("encounterTemplateId", ""))).is_equal(ENCOUNTER_ID)
func test_parse_completed_row_has_completion_reward_summary() -> void:
# Arrange
var json := _completed_with_summary_json()
# Act
var snapshot: Variant = ContractClient.parse_contracts_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var summary: Dictionary = ContractClient.completion_reward_summary(
INSTANCE_ID, snapshot as Dictionary
)
var items: Variant = summary.get("itemGrants", null)
assert_that(items is Array).is_true()
assert_that((items as Array).size()).is_equal(1)
func test_parse_contracts_json_rejects_schema_mismatch() -> void:
# Arrange
var json := '{"schemaVersion":99,"playerId":"dev-local-1","contracts":[]}'
# Act
var snapshot: Variant = ContractClient.parse_contracts_json(json)
# Assert
assert_that(snapshot).is_null()
func test_request_sync_emits_contracts_received() -> void:
# Arrange
_contracts_capture = {}
var transport := MockHttpTransport.new()
transport.body_json = _active_contracts_json()
var c := _make_client(transport, NoopHttpTransport.new())
c.connect("contracts_received", Callable(self, "_capture_contracts"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("contracts_received", any())
assert_that(_contracts_capture.get("playerId", "")).is_equal("dev-local-1")
assert_that(transport.last_url).contains("/contracts")
func test_http_404_emits_contracts_sync_failed() -> void:
# Arrange
_issue_failed_capture = ""
var transport := MockHttpTransport.new()
transport.response_code = 404
var c := _make_client(transport, NoopHttpTransport.new())
c.connect("contracts_sync_failed", Callable(self, "_capture_issue_failed"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("contracts_sync_failed", any())
assert_that(_issue_failed_capture).contains("404")
func test_issue_success_emits_contract_issue_result_received() -> void:
# Arrange
_issue_capture = {}
var sync := NoopHttpTransport.new()
var issue := MockHttpTransport.new()
issue.body_json = _issue_success_json()
var c := _make_client(sync, issue)
c.connect("contract_issue_result_received", Callable(self, "_capture_issue"))
monitor_signals(c)
# Act
c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed")
# Assert
await assert_signal(c).is_emitted("contract_issue_result_received", any())
assert_bool(_issue_capture.get("issued", false)).is_true()
assert_that(issue.last_url).contains("/contracts/issue")
assert_that(issue.last_method).is_equal(HTTPClient.METHOD_POST)
func test_issue_deny_emits_result_with_reason_code() -> void:
# Arrange
_issue_capture = {}
var sync := NoopHttpTransport.new()
var issue := MockHttpTransport.new()
issue.body_json = _issue_deny_json("economy_cap_exceeded")
var c := _make_client(sync, issue)
c.connect("contract_issue_result_received", Callable(self, "_capture_issue"))
monitor_signals(c)
# Act
c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed")
# Assert
await assert_signal(c).is_emitted("contract_issue_result_received", any())
assert_bool(_issue_capture.get("issued", true)).is_false()
assert_that(str(_issue_capture.get("reasonCode", ""))).is_equal("economy_cap_exceeded")

View File

@ -0,0 +1 @@
uid://bneo153ctest01

View File

@ -0,0 +1,220 @@
extends GdUnitTestSuite
## NEO-153: contract HUD controller issue key, active label, reward transition.
const ContractHudController := preload("res://scripts/contract_hud_controller.gd")
const ContractClient := preload("res://scripts/contract_client.gd")
const INSTANCE_ID := "ci_test_instance"
const ENCOUNTER_ID := "prototype_combat_pocket"
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var response_code: int = 200
var body_json: String = ""
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
class PendingIssueHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
return OK
static func _active_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"contracts":
[
{
"contractInstanceId": INSTANCE_ID,
"templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID,
"templateDisplayName": "Clear Combat Pocket (Repeat)",
"status": "active",
"encounterTemplateId": ENCOUNTER_ID,
"seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET,
"issuedAt": "2026-06-28T12:00:00Z",
}
],
}
static func _completed_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"contracts":
[
{
"contractInstanceId": INSTANCE_ID,
"templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID,
"templateDisplayName": "Clear Combat Pocket (Repeat)",
"status": "completed",
"encounterTemplateId": ENCOUNTER_ID,
"seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET,
"issuedAt": "2026-06-28T12:00:00Z",
"completedAt": "2026-06-28T13:00:00Z",
"completionRewardSummary":
{
"itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}],
"skillXpGrants": [{"skillId": "salvage", "amount": 15}],
},
}
],
}
func _make_contract_client(sync_transport: Node, issue_transport: Node = null) -> Node:
var c: Node = ContractClient.new()
c.set("injected_sync_http", sync_transport)
var issue_http: Node = issue_transport if issue_transport != null else sync_transport
c.set("injected_issue_http", issue_http)
auto_free(sync_transport)
if issue_transport != null and issue_transport != sync_transport:
auto_free(issue_transport)
auto_free(c)
add_child(c)
return c
func _build_controller(sync_transport: Node, issue_transport: Node) -> Dictionary:
var controller: Node = ContractHudController.new()
var contract: Node = _make_contract_client(sync_transport, issue_transport)
var active_label := Label.new()
var issue_label := Label.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(active_label)
auto_free(issue_label)
auto_free(reward_label)
add_child(controller)
await get_tree().process_frame
controller.call(
"setup",
contract,
active_label,
issue_label,
reward_label,
Callable(self, "_noop_http_config")
)
await get_tree().process_frame
return {
"controller": controller,
"contract": contract,
"active_label": active_label,
"issue_label": issue_label,
"reward_label": reward_label,
}
func _noop_http_config(_client: Node) -> void:
pass
func test_try_issue_key_input_handles_shift_c() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingIssueHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.shift_pressed = true
event.keycode = KEY_C
# Act
var handled: bool = bool(built["controller"].call("try_issue_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["issue_label"].text).contains("sending")
func test_active_label_shows_encounter_objective_id() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
built["controller"].call("_on_contracts_received", _active_snapshot())
# Assert
assert_str(built["active_label"].text).contains(ENCOUNTER_ID)
assert_str(built["active_label"].text).contains("active")
func test_issue_deny_shows_economy_cap_copy() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
built["controller"].call(
"_on_issue_result_received", {"issued": false, "reasonCode": "economy_cap_exceeded"}
)
# Assert
assert_str(built["issue_label"].text).contains("economy cap exceeded")
func test_issue_deny_shows_active_contract_exists_copy() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
built["controller"].call(
"_on_issue_result_received", {"issued": false, "reasonCode": "active_contract_exists"}
)
# Assert
assert_str(built["issue_label"].text).contains("active contract already issued")
func test_reward_label_populates_on_active_to_completed_transition() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
built["controller"].call("_on_contracts_received", _active_snapshot())
# Act
built["controller"].call("_on_contracts_received", _completed_snapshot())
# Assert
assert_str(built["reward_label"].text).contains("Contract rewards:")
assert_str(built["reward_label"].text).contains("Salvage +15 XP")

View File

@ -0,0 +1 @@
uid://bneo153ctest02

View File

@ -0,0 +1,46 @@
# NEO-153 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-153 |
| Title | E7M4-10: Client contract issue + progress HUD (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-153/e7m4-10-client-contract-issue-progress-hud-godot |
| Plan | `docs/plans/NEO-153-implementation-plan.md` |
| Branch | `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` |
## Preconditions
- **Fresh dev player:** restart server so contract instance state is clean (or use dev fixture reset if a prior contract exists).
- **Server deps:** NEO-151 **`POST …/contracts/issue`** and **`GET …/contracts`** on `main`.
- **No Bruno/curl** for this checklist — issue and progress use Godot **Shift+C** and combat pocket clear only.
## Expected contract HUD progression
| After | `ContractActiveLabel` | `ContractIssueFeedbackLabel` | `ContractRewardDeliveryLabel` |
|-------|----------------------|------------------------------|-------------------------------|
| Boot | **`Contract: —`** | **`Contract issue: — (Shift+C prototype)`** | **`Contract rewards: —`** |
| **Shift+C** issue | **`Clear Combat Pocket (Repeat): active — objective prototype_combat_pocket`** | **`Contract issue: Clear Combat Pocket (Repeat) issued`** | **`—`** |
| Defeat ×3 NPCs (combat pocket) | **`…: completed — objective prototype_combat_pocket`** | unchanged | **`scrap_metal_bulk ×5`** + **`Salvage +15 XP`** (display names when item defs loaded) |
| Duplicate **Shift+C** while active | unchanged active line | **`Contract issue: denied — active contract already issued`** | unchanged |
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm contract labels appear in **`HudRootScroll`** below quest reward labels.
3. Press **Shift+C**. Verify **`ContractActiveLabel`** shows **`active`** with **`prototype_combat_pocket`** objective id and issue feedback shows success.
4. Defeat all three combat pocket NPCs (hotbar casts). Verify encounter **`completed (3/3)`** and contract GET refresh shows **`completed`** on **`ContractActiveLabel`**.
5. Verify **`ContractRewardDeliveryLabel`** lists contract completion grants (**`scrap_metal_bulk ×5`**, **`Salvage +15 XP`**) after pocket clear — not on boot alone.
6. Press **Shift+C** again while contract still active (before clear) or after issuing once — verify readable deny when **`active_contract_exists`**.
7. Optional: stop server while Godot running, press **Shift+C** — verify **`Contract: error — …`** or **`Contract issue: failed — …`** on HUD.
## Notes
- Prototype flow is **issue then clear** the bound encounter. Issuing after the pocket is already cleared leaves the contract **`active`** until a future matching clear (NEO-149 v1 edge case).
- Fixed seed **`prototype_contract_dev_seed`** — repeat re-issue after complete requires **NEO-154** capstone (seed rotation / fixture reset).
- Encounter + quest HUD regression: [NEO-110 manual QA](NEO-110.md), [NEO-122 manual QA](NEO-122.md).
## Acceptance
- [ ] Steps 15 completable in one session without Bruno/curl.
- [ ] Active contract shows encounter objective id after **Shift+C** issue.
- [ ] Completed contract shows reward summary line on HUD after pocket clear.

View File

@ -52,8 +52,16 @@ Player can **issue** and **track** an active contract in Godot without Bruno —
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Active contract visible with encounter objective id after issue. - [x] Active contract visible with encounter objective id after issue.
- [ ] Completed contract shows reward summary line on HUD. - [x] Completed contract shows reward summary line on HUD.
## Implementation reconciliation (shipped)
- **`contract_client.gd`:** GET/POST parse v1; **`active_contract_row`**, **`completion_reward_summary`**; separate sync/issue HTTP guards.
- **`contract_hud_controller.gd`:** **Shift+C** prototype issue; active/completed label with **`encounterTemplateId`**; deny copy; in-session reward transition + economy refresh.
- **`main.tscn` / `main.gd`:** **`ContractClient`** node + three HUD labels; encounter-complete contract refresh hook.
- **Tests:** `contract_client_test.gd` (7 cases), `contract_hud_controller_test.gd` (5 cases) — GdUnit green.
- **Docs:** `client/README.md` contract HUD section; `docs/manual-qa/NEO-153.md`.
## Technical approach ## Technical approach