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
parent
7deb10bf21
commit
365e57799a
|
|
@ -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 (0–1) + 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bneo153contract01
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bneo153contract02
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bneo153ctest01
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bneo153ctest02
|
||||||
|
|
@ -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 1–5 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.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue