From c8c706f8391c5cf7ef34e73ac4b6b83af87225ce Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 21:10:34 -0400 Subject: [PATCH] NEO-142: add faction standing client and HUD wiring Introduce faction-standing GET client, standing label in HudRoot, readable faction_gate_blocked accept feedback, reputationGrants reward lines, and GdUnit tests. --- client/scenes/main.tscn | 16 ++ client/scripts/faction_standing_client.gd | 157 +++++++++++++ client/scripts/faction_standing_client.gd.uid | 1 + client/scripts/main.gd | 6 +- client/scripts/quest_definitions_client.gd | 15 ++ client/scripts/quest_hud_controller.gd | 121 +++++++++- client/test/faction_standing_client_test.gd | 182 +++++++++++++++ client/test/faction_standing_hud_test.gd | 216 ++++++++++++++++++ client/test/quest_reward_hud_test.gd | 8 + 9 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 client/scripts/faction_standing_client.gd create mode 100644 client/scripts/faction_standing_client.gd.uid create mode 100644 client/test/faction_standing_client_test.gd create mode 100644 client/test/faction_standing_hud_test.gd diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 0b0f03e..82a18e4 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -23,6 +23,7 @@ [ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_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://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"] [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"] @@ -1126,6 +1127,9 @@ script = ExtResource("25_quest_prog") [node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014] script = ExtResource("26_quest_defs") +[node name="FactionStandingClient" type="Node" parent="." unique_id=2500015] +script = ExtResource("27_faction_standing") + [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] script = ExtResource("17_recipe_defs") @@ -1271,6 +1275,18 @@ autowrap_mode = 3 text = "Quest rewards: —" +[node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.85, 0.88, 0.98, 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 = "Faction standing: + Grid Operators: 0 + Rust Collective: 0" + [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/faction_standing_client.gd b/client/scripts/faction_standing_client.gd new file mode 100644 index 0000000..17fc7d5 --- /dev/null +++ b/client/scripts/faction_standing_client.gd @@ -0,0 +1,157 @@ +extends Node + +## NEO-142: prototype HTTP client for [code]GET /game/players/{id}/faction-standing[/code] (NEO-139). + +signal faction_standing_received(snapshot: Dictionary) +signal faction_standing_sync_failed(reason: String) + +const SCHEMA_VERSION := 1 +const GRID_OPERATORS_ID := "prototype_faction_grid_operators" +const RUST_COLLECTIVE_ID := "prototype_faction_rust_collective" + +const _DISPLAY_NAMES: Dictionary = { + GRID_OPERATORS_ID: "Grid Operators", + RUST_COLLECTIVE_ID: "Rust Collective", +} + +@export var base_url: String = "http://127.0.0.1:5253" +@export var dev_player_id: String = "dev-local-1" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false +var _sync_pending: bool = false + + +func _ready() -> void: + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") + _http.request_completed.connect(_on_request_completed) + + +func request_sync_from_server() -> void: + if _busy: + _sync_pending = true + return + _start_sync_request() + + +func _start_sync_request() -> void: + _busy = true + var url := "%s/game/players/%s/faction-standing" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("FactionStandingClient: %s" % reason) + _busy = false + faction_standing_sync_failed.emit(reason) + _try_flush_pending_sync() + + +func _try_flush_pending_sync() -> void: + if not _sync_pending or _busy: + return + _sync_pending = false + _start_sync_request() + + +static func prototype_faction_ids_in_order() -> Array: + return [GRID_OPERATORS_ID, RUST_COLLECTIVE_ID] + + +static func display_name_for(faction_id: String) -> String: + var fid := faction_id.strip_edges() + if _DISPLAY_NAMES.has(fid): + return str(_DISPLAY_NAMES[fid]) + return _title_case_token(fid) + + +static func standing_for(faction_id: String, snapshot: Dictionary = {}) -> int: + var row: Dictionary = faction_row(faction_id, snapshot) + if row.is_empty(): + return 0 + return int(row.get("standing", 0)) + + +static func faction_row(faction_id: String, snapshot: Dictionary = {}) -> Dictionary: + var factions: Variant = snapshot.get("factions", null) + if factions == null or not factions is Array: + return {} + var fid := faction_id.strip_edges() + for row_variant in factions as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) == fid: + return row + return {} + + +static func parse_faction_standing_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 str(root.get("playerId", "")).strip_edges().is_empty(): + return null + var factions: Variant = root.get("factions", null) + if factions == null or not factions is Array: + return null + return root + + +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) + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("FactionStandingClient: %s" % reason) + faction_standing_sync_failed.emit(reason) + _try_flush_pending_sync() + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("FactionStandingClient: %s" % reason404) + faction_standing_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("FactionStandingClient: %s" % reason_code) + faction_standing_sync_failed.emit(reason_code) + _try_flush_pending_sync() + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_faction_standing_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("FactionStandingClient: %s" % reason_json) + faction_standing_sync_failed.emit(reason_json) + _try_flush_pending_sync() + return + faction_standing_received.emit(snapshot as Dictionary) + _try_flush_pending_sync() diff --git a/client/scripts/faction_standing_client.gd.uid b/client/scripts/faction_standing_client.gd.uid new file mode 100644 index 0000000..f34a18d --- /dev/null +++ b/client/scripts/faction_standing_client.gd.uid @@ -0,0 +1 @@ +uid://bneo142factionst01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index cb6ee87..ccf7232 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -137,6 +137,7 @@ var _dev_pulse_bootstrap_attempted: bool = false @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_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") +@onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel") @@ -155,6 +156,7 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient +@onready var _faction_standing_client: Node = $FactionStandingClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @@ -867,7 +869,9 @@ func _setup_quest_progress_sync() -> void: _quest_reward_delivery_label, _item_defs_client, _inventory_client, - _skill_progression_client + _skill_progression_client, + _faction_standing_client, + _faction_standing_label ) diff --git a/client/scripts/quest_definitions_client.gd b/client/scripts/quest_definitions_client.gd index d1783f0..a20ce32 100644 --- a/client/scripts/quest_definitions_client.gd +++ b/client/scripts/quest_definitions_client.gd @@ -57,6 +57,21 @@ func quests_snapshot() -> Array: return _quests.duplicate(true) +func faction_gate_rules_for(quest_id: String) -> Array: + var qid := quest_id.strip_edges() + for row_variant in _quests: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) != qid: + continue + var rules: Variant = row.get("factionGateRules", null) + if rules is Array: + return (rules as Array).duplicate(true) + return [] + return [] + + func _base_root() -> String: return base_url.strip_edges().rstrip("/") diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index e021d05..fcda339 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -3,14 +3,18 @@ extends Node ## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). ## NEO-131: quest completion reward label on in-session completed transition. ## NEO-132: emits quest_completion_reward_transition for economy HUD refresh. +## NEO-142: faction standing label + readable faction_gate_blocked accept deny. signal quest_completion_reward_transition(quest_id: String, summary: Dictionary) const QuestProgressClient := preload("res://scripts/quest_progress_client.gd") +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") const GATHER_QUEST_ID := "prototype_quest_gather_intro" +const FACTION_GATE_BLOCKED_REASON := "faction_gate_blocked" const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)" const ACCEPT_SENDING_HINT := "Quest accept: sending…" const REWARD_HEADER := "Quest rewards:" +const FACTION_STANDING_HEADER := "Faction standing:" var _progress_client: Node = null var _defs_client: Node = null @@ -28,6 +32,10 @@ var _last_reward_quest_id: String = "" var _last_reward_summary: Dictionary = {} var _inventory_client: Node = null var _skill_progression_client: Node = null +var _faction_standing_client: Node = null +var _faction_standing_label: Label = null +var _last_faction_snapshot: Dictionary = {} +var _faction_standing_error: String = "" func setup( @@ -39,19 +47,25 @@ func setup( reward_label: Label = null, item_defs_client: Node = null, inventory_client: Node = null, - skill_progression_client: Node = null + skill_progression_client: Node = null, + faction_standing_client: Node = null, + faction_standing_label: Label = null ) -> void: _progress_client = progress_client _defs_client = defs_client _item_defs_client = item_defs_client _inventory_client = inventory_client _skill_progression_client = skill_progression_client + _faction_standing_client = faction_standing_client + _faction_standing_label = faction_standing_label _progress_label = progress_label _accept_label = accept_label _reward_label = reward_label if apply_http_config.is_valid(): apply_http_config.call(progress_client) apply_http_config.call(defs_client) + if is_instance_valid(_faction_standing_client): + apply_http_config.call(_faction_standing_client) if _progress_client.has_signal("quest_progress_received"): _progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received")) if _progress_client.has_signal("quest_sync_failed"): @@ -70,11 +84,22 @@ func setup( ) 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")) + if is_instance_valid(_faction_standing_client): + if _faction_standing_client.has_signal("faction_standing_received"): + _faction_standing_client.connect( + "faction_standing_received", Callable(self, "_on_faction_standing_received") + ) + if _faction_standing_client.has_signal("faction_standing_sync_failed"): + _faction_standing_client.connect( + "faction_standing_sync_failed", Callable(self, "_on_faction_standing_sync_failed") + ) _render_progress_label() _render_accept_feedback_label() _render_reward_label() + _render_faction_standing_label() if _defs_client.has_method("request_sync_from_server"): _defs_client.call("request_sync_from_server") + _refresh_faction_standing() request_progress_refresh() @@ -133,7 +158,9 @@ func _on_accept_result_received(quest_id: String, result: Dictionary) -> void: _render_progress_label() else: var rc := str(result.get("reasonCode", "")).strip_edges() - if rc.is_empty(): + if rc == FACTION_GATE_BLOCKED_REASON: + _render_accept_feedback_label(_format_faction_gate_blocked_deny(quest_id)) + elif rc.is_empty(): _render_accept_feedback_label("Quest accept: denied (no reasonCode)") else: _render_accept_feedback_label("Quest accept: denied — %s" % rc) @@ -217,6 +244,85 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: if not _last_reward_quest_id.is_empty() and not _last_reward_summary.is_empty(): quest_completion_reward_transition.emit(_last_reward_quest_id, _last_reward_summary) _refresh_economy_hud() + _refresh_faction_standing() + + +func _refresh_faction_standing() -> void: + if ( + is_instance_valid(_faction_standing_client) + and _faction_standing_client.has_method("request_sync_from_server") + ): + _faction_standing_client.call("request_sync_from_server") + + +func _on_faction_standing_received(snapshot: Dictionary) -> void: + _faction_standing_error = "" + _last_faction_snapshot = snapshot.duplicate(true) + _render_faction_standing_label() + + +func _on_faction_standing_sync_failed(reason: String) -> void: + _faction_standing_error = reason + _render_faction_standing_label() + + +func _render_faction_standing_label() -> void: + if not is_instance_valid(_faction_standing_label): + return + var lines: PackedStringArray = [FACTION_STANDING_HEADER] + if not _faction_standing_error.is_empty(): + lines.append("error — %s" % _faction_standing_error) + for faction_id in FactionStandingClient.prototype_faction_ids_in_order(): + var fid := str(faction_id) + var standing: int = FactionStandingClient.standing_for(fid, _last_faction_snapshot) + var label := FactionStandingClient.display_name_for(fid) + lines.append(" %s: %d" % [label, standing]) + _faction_standing_label.text = "\n".join(lines) + + +func _format_faction_gate_blocked_deny(quest_id: String) -> String: + var rules: Array = _faction_gate_rules_for(quest_id) + if rules.is_empty(): + return ( + "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON + ) + var first_rule: Variant = rules[0] + if not first_rule is Dictionary: + return ( + "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON + ) + var rule: Dictionary = first_rule + var faction_id := str(rule.get("factionId", "")).strip_edges() + var min_standing: int = int(rule.get("minStanding", 0)) + if faction_id.is_empty(): + return ( + "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON + ) + var faction_label := _faction_display_name(faction_id) + var current_standing: int = FactionStandingClient.standing_for( + faction_id, _last_faction_snapshot + ) + return ( + "Quest accept: denied — %s standing %d required (have %d)" + % [faction_label, min_standing, current_standing] + ) + + +func _faction_gate_rules_for(quest_id: String) -> Array: + if not is_instance_valid(_defs_client): + return [] + if not _defs_client.has_method("faction_gate_rules_for"): + return [] + return _defs_client.call("faction_gate_rules_for", quest_id) as Array + + +func _faction_display_name(faction_id: String) -> String: + if ( + is_instance_valid(_faction_standing_client) + and _faction_standing_client.has_method("display_name_for") + ): + return str(_faction_standing_client.call("display_name_for", faction_id)) + return FactionStandingClient.display_name_for(faction_id) func _refresh_economy_hud() -> void: @@ -335,6 +441,17 @@ func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: 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" % [_faction_display_name(faction_id), amount]) if lines.is_empty(): lines.append(" (no grants)") return lines diff --git a/client/test/faction_standing_client_test.gd b/client/test/faction_standing_client_test.gd new file mode 100644 index 0000000..4540ea2 --- /dev/null +++ b/client/test/faction_standing_client_test.gd @@ -0,0 +1,182 @@ +extends GdUnitTestSuite + +## NEO-142: `FactionStandingClient` GET parse + failure signals. + +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") + +var _standing_capture: Dictionary = {} + + +func _capture_standing(snapshot: Dictionary) -> void: + _standing_capture = snapshot + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + 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 + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +class PendingSyncHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var request_count: int = 0 + 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_count += 1 + return OK + + func complete_pending() -> void: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + + +static func _default_standing_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","factions":[' + + '{"id":"prototype_faction_grid_operators","standing":0},' + + '{"id":"prototype_faction_rust_collective","standing":0}' + + "]}" + ) + + +static func _post_operator_chain_standing_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","factions":[' + + '{"id":"prototype_faction_rust_collective","standing":0},' + + '{"id":"prototype_faction_grid_operators","standing":15}' + + "]}" + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = FactionStandingClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_faction_standing_json_reads_grid_operators_row() -> void: + # Arrange + var json := _post_operator_chain_standing_json() + # Act + var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + assert_that( + FactionStandingClient.standing_for( + FactionStandingClient.GRID_OPERATORS_ID, snapshot as Dictionary + ) + ).is_equal(15) + + +func test_parse_faction_standing_json_rejects_bad_schema() -> void: + # Arrange + var json := '{"schemaVersion":2,"playerId":"dev-local-1","factions":[]}' + # Act + var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json) + # Assert + assert_that(snapshot).is_null() + + +func test_standing_for_defaults_to_zero_when_row_missing() -> void: + # Arrange + var snapshot := {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + # Act + var standing: int = FactionStandingClient.standing_for( + FactionStandingClient.GRID_OPERATORS_ID, snapshot + ) + # Assert + assert_that(standing).is_equal(0) + + +func test_display_name_for_prototype_factions() -> void: + # Arrange + # Act + var grid_label := FactionStandingClient.display_name_for( + FactionStandingClient.GRID_OPERATORS_ID + ) + var rust_label := FactionStandingClient.display_name_for( + FactionStandingClient.RUST_COLLECTIVE_ID + ) + # Assert + assert_str(grid_label).is_equal("Grid Operators") + assert_str(rust_label).is_equal("Rust Collective") + + +func test_request_sync_emits_faction_standing_received() -> void: + # Arrange + _standing_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _default_standing_json() + var c := _make_client(transport) + c.connect("faction_standing_received", Callable(self, "_capture_standing")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("faction_standing_received", any()) + assert_that(_standing_capture.has("factions")).is_true() + assert_that(transport.last_url).contains("/faction-standing") + + +func test_http_404_emits_faction_standing_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted( + "faction_standing_sync_failed", "HTTP 404 (player unknown)" + ) + + +func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void: + # Arrange + var transport := PendingSyncHttpTransport.new() + transport.body_json = _default_standing_json() + var c := _make_client(transport) + # Act + c.call("request_sync_from_server") + c.call("request_sync_from_server") + # Assert + assert_that(transport.request_count).is_equal(1) + transport.complete_pending() + assert_that(transport.request_count).is_equal(2) diff --git a/client/test/faction_standing_hud_test.gd b/client/test/faction_standing_hud_test.gd new file mode 100644 index 0000000..c39b1b9 --- /dev/null +++ b/client/test/faction_standing_hud_test.gd @@ -0,0 +1,216 @@ +extends GdUnitTestSuite + +## NEO-142: faction standing label + faction_gate_blocked accept deny HUD tests. + +const QuestHudController := preload("res://scripts/quest_hud_controller.gd") +const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") + +const GRID_CONTRACT_QUEST_ID := "prototype_quest_grid_contract" + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + 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, 200, PackedStringArray(), body_json.to_utf8_buffer() + ) + return OK + + +class MockFactionSyncClient: + extends Node + + var sync_count: int = 0 + + func request_sync_from_server() -> void: + sync_count += 1 + + func display_name_for(faction_id: String) -> String: + return FactionStandingClient.display_name_for(faction_id) + + +static func _post_operator_chain_standing_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "factions": + [ + {"id": FactionStandingClient.GRID_OPERATORS_ID, "standing": 15}, + {"id": FactionStandingClient.RUST_COLLECTIVE_ID, "standing": 0}, + ], + } + + +static func _grid_contract_defs_json() -> String: + return ( + '{"schemaVersion":1,"quests":[' + + '{"id":"prototype_quest_grid_contract","displayName":"Grid Contract",' + + '"prerequisiteQuestIds":["prototype_quest_operator_chain"],' + + '"factionGateRules":[{"factionId":"prototype_faction_grid_operators","minStanding":15}],' + + '"steps":[]}]}' + ) + + +func test_faction_standing_label_paints_grid_operators_standing() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var standing_label := Label.new() + auto_free(controller) + auto_free(standing_label) + add_child(controller) + controller.set("_faction_standing_label", standing_label) + # Act + controller.call("_on_faction_standing_received", _post_operator_chain_standing_snapshot()) + # Assert + assert_str(standing_label.text).contains("Faction standing:") + assert_str(standing_label.text).contains("Grid Operators: 15") + assert_str(standing_label.text).contains("Rust Collective: 0") + + +func test_faction_standing_sync_error_shows_error_line() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var standing_label := Label.new() + auto_free(controller) + auto_free(standing_label) + add_child(controller) + controller.set("_faction_standing_label", standing_label) + # Act + controller.call("_on_faction_standing_sync_failed", "HTTP 503") + # Assert + assert_str(standing_label.text).contains("error — HTTP 503") + assert_str(standing_label.text).contains("Grid Operators: 0") + + +func test_faction_gate_blocked_deny_uses_readable_gate_copy() -> void: + # Arrange + var defs_transport := MockHttpTransport.new() + defs_transport.body_json = _grid_contract_defs_json() + var defs_client: Node = QuestDefinitionsClient.new() + defs_client.set("injected_http", defs_transport) + auto_free(defs_transport) + auto_free(defs_client) + add_child(defs_client) + defs_client.call("request_sync_from_server") + var controller: Node = QuestHudController.new() + var accept_label := Label.new() + auto_free(controller) + auto_free(accept_label) + add_child(controller) + controller.set("_defs_client", defs_client) + controller.set("_accept_label", accept_label) + controller.call( + "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + ) + # Act + var deny_text: String = str( + controller.call("_format_faction_gate_blocked_deny", GRID_CONTRACT_QUEST_ID) + ) + # Assert + assert_str(deny_text).contains("Grid Operators standing 15 required (have 0)") + + +func test_faction_gate_blocked_deny_falls_back_without_gate_rules() -> void: + # Arrange + var controller: Node = QuestHudController.new() + auto_free(controller) + add_child(controller) + # Act + var deny_text: String = str( + controller.call("_format_faction_gate_blocked_deny", GRID_CONTRACT_QUEST_ID) + ) + # Assert + assert_str(deny_text).contains("faction standing too low") + assert_str(deny_text).contains("faction_gate_blocked") + + +func test_accept_result_received_paints_readable_faction_gate_blocked() -> void: + # Arrange + var defs_transport := MockHttpTransport.new() + defs_transport.body_json = _grid_contract_defs_json() + var defs_client: Node = QuestDefinitionsClient.new() + defs_client.set("injected_http", defs_transport) + auto_free(defs_transport) + auto_free(defs_client) + add_child(defs_client) + defs_client.call("request_sync_from_server") + var controller: Node = QuestHudController.new() + var accept_label := Label.new() + auto_free(controller) + auto_free(accept_label) + add_child(controller) + controller.set("_defs_client", defs_client) + controller.set("_accept_label", accept_label) + controller.set("_progress_client", Node.new()) + controller.call( + "_on_faction_standing_received", {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []} + ) + # Act + controller.call( + "_on_accept_result_received", + GRID_CONTRACT_QUEST_ID, + {"schemaVersion": 1, "accepted": false, "reasonCode": "faction_gate_blocked"} + ) + # Assert + assert_str(accept_label.text).contains("Grid Operators standing 15 required (have 0)") + + +func test_completion_transition_requests_faction_standing_refresh() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var faction_client := MockFactionSyncClient.new() + auto_free(controller) + auto_free(faction_client) + add_child(controller) + add_child(faction_client) + controller.set("_faction_standing_client", faction_client) + var active_snapshot := { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": "prototype_quest_operator_chain", + "status": "active", + "currentStepIndex": 3, + "objectiveCounters": {}, + } + ], + } + var completed_snapshot := { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": "prototype_quest_operator_chain", + "status": "completed", + "currentStepIndex": 3, + "objectiveCounters": {}, + "completedAt": "2026-06-07T14:00:00Z", + "completionRewardSummary": + { + "itemGrants": [], + "skillXpGrants": [], + "reputationGrants": + [{"factionId": FactionStandingClient.GRID_OPERATORS_ID, "amount": 15}], + }, + } + ], + } + # Act + controller.call("_on_progress_received", active_snapshot) + controller.call("_on_progress_received", completed_snapshot) + # Assert + assert_int(faction_client.sync_count).is_equal(1) diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd index 8913d79..7921bd0 100644 --- a/client/test/quest_reward_hud_test.gd +++ b/client/test/quest_reward_hud_test.gd @@ -159,6 +159,13 @@ func _completed_operator_chain_with_summary_snapshot() -> Dictionary: { "itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}], "skillXpGrants": [{"skillId": "salvage", "amount": 50}], + "reputationGrants": + [ + { + "factionId": "prototype_faction_grid_operators", + "amount": 15, + } + ], }, } ], @@ -235,6 +242,7 @@ func test_completion_transition_paints_item_grant_with_display_name() -> void: # Assert assert_str(reward_label.text).contains("Survey Drone Kit ×1") assert_str(reward_label.text).contains("Salvage +50 XP") + assert_str(reward_label.text).contains("Grid Operators +15 rep") func test_definitions_ready_repaints_reward_label_with_display_name() -> void: