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.
pull/182/head
VinPropane 2026-06-17 21:10:34 -04:00
parent fb9a8ab8b2
commit c8c706f839
9 changed files with 719 additions and 3 deletions

View File

@ -23,6 +23,7 @@
[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"] [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://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="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"]
@ -1126,6 +1127,9 @@ script = ExtResource("25_quest_prog")
[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")
[node name="FactionStandingClient" type="Node" parent="." unique_id=2500015]
script = ExtResource("27_faction_standing")
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
script = ExtResource("17_recipe_defs") script = ExtResource("17_recipe_defs")
@ -1271,6 +1275,18 @@ autowrap_mode = 3
text = "Quest rewards: 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] [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3

View File

@ -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()

View File

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

View File

@ -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_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 _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")
@onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel") @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 _encounter_progress_client: Node = $EncounterProgressClient
@onready var _quest_progress_client: Node = $QuestProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient
@onready var _quest_defs_client: Node = $QuestDefinitionsClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient
@onready var _faction_standing_client: Node = $FactionStandingClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient @onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient @onready var _target_client: Node = $TargetSelectionClient
@ -867,7 +869,9 @@ func _setup_quest_progress_sync() -> void:
_quest_reward_delivery_label, _quest_reward_delivery_label,
_item_defs_client, _item_defs_client,
_inventory_client, _inventory_client,
_skill_progression_client _skill_progression_client,
_faction_standing_client,
_faction_standing_label
) )

View File

@ -57,6 +57,21 @@ func quests_snapshot() -> Array:
return _quests.duplicate(true) 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: func _base_root() -> String:
return base_url.strip_edges().rstrip("/") return base_url.strip_edges().rstrip("/")

View File

@ -3,14 +3,18 @@ extends Node
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). ## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
## NEO-131: quest completion reward label on in-session completed transition. ## NEO-131: quest completion reward label on in-session completed transition.
## NEO-132: emits quest_completion_reward_transition for economy HUD refresh. ## 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) signal quest_completion_reward_transition(quest_id: String, summary: Dictionary)
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd") 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 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_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)"
const ACCEPT_SENDING_HINT := "Quest accept: sending…" const ACCEPT_SENDING_HINT := "Quest accept: sending…"
const REWARD_HEADER := "Quest rewards:" const REWARD_HEADER := "Quest rewards:"
const FACTION_STANDING_HEADER := "Faction standing:"
var _progress_client: Node = null var _progress_client: Node = null
var _defs_client: Node = null var _defs_client: Node = null
@ -28,6 +32,10 @@ var _last_reward_quest_id: String = ""
var _last_reward_summary: Dictionary = {} var _last_reward_summary: Dictionary = {}
var _inventory_client: Node = null var _inventory_client: Node = null
var _skill_progression_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( func setup(
@ -39,19 +47,25 @@ func setup(
reward_label: Label = null, reward_label: Label = null,
item_defs_client: Node = null, item_defs_client: Node = null,
inventory_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: ) -> void:
_progress_client = progress_client _progress_client = progress_client
_defs_client = defs_client _defs_client = defs_client
_item_defs_client = item_defs_client _item_defs_client = item_defs_client
_inventory_client = inventory_client _inventory_client = inventory_client
_skill_progression_client = skill_progression_client _skill_progression_client = skill_progression_client
_faction_standing_client = faction_standing_client
_faction_standing_label = faction_standing_label
_progress_label = progress_label _progress_label = progress_label
_accept_label = accept_label _accept_label = accept_label
_reward_label = reward_label _reward_label = reward_label
if apply_http_config.is_valid(): if apply_http_config.is_valid():
apply_http_config.call(progress_client) apply_http_config.call(progress_client)
apply_http_config.call(defs_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"): if _progress_client.has_signal("quest_progress_received"):
_progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received")) _progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received"))
if _progress_client.has_signal("quest_sync_failed"): 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"): 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")) _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_progress_label()
_render_accept_feedback_label() _render_accept_feedback_label()
_render_reward_label() _render_reward_label()
_render_faction_standing_label()
if _defs_client.has_method("request_sync_from_server"): if _defs_client.has_method("request_sync_from_server"):
_defs_client.call("request_sync_from_server") _defs_client.call("request_sync_from_server")
_refresh_faction_standing()
request_progress_refresh() request_progress_refresh()
@ -133,7 +158,9 @@ func _on_accept_result_received(quest_id: String, result: Dictionary) -> void:
_render_progress_label() _render_progress_label()
else: else:
var rc := str(result.get("reasonCode", "")).strip_edges() 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)") _render_accept_feedback_label("Quest accept: denied (no reasonCode)")
else: else:
_render_accept_feedback_label("Quest accept: denied — %s" % rc) _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(): 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) quest_completion_reward_transition.emit(_last_reward_quest_id, _last_reward_summary)
_refresh_economy_hud() _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: 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: if skill_id.is_empty() or amount <= 0:
continue continue
lines.append(" %s +%d XP" % [_title_case_token(skill_id), amount]) 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(): if lines.is_empty():
lines.append(" (no grants)") lines.append(" (no grants)")
return lines return lines

View File

@ -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)

View File

@ -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)

View File

@ -159,6 +159,13 @@ func _completed_operator_chain_with_summary_snapshot() -> Dictionary:
{ {
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}], "itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
"skillXpGrants": [{"skillId": "salvage", "amount": 50}], "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
assert_str(reward_label.text).contains("Survey Drone Kit ×1") 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("Salvage +50 XP")
assert_str(reward_label.text).contains("Grid Operators +15 rep")
func test_definitions_ready_repaints_reward_label_with_display_name() -> void: func test_definitions_ready_repaints_reward_label_with_display_name() -> void: