Merge pull request #182 from ViPro-Technologies/NEO-142-client-faction-standing-gate-hud

NEO-142: Client faction standing + gate feedback HUD (Godot)
pull/183/head
VinPropane 2026-06-17 21:38:07 -04:00 committed by GitHub
commit 4f02dc47fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1204 additions and 97 deletions

View File

@ -251,6 +251,21 @@ Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md).
## 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/world/quest-definitions`** — gated quests expose optional **`factionGateRules`** (`factionId`, `minStanding`) for readable accept deny copy (NEO-140).
- **`GET /game/players/{id}/quest-progress`** — completed rows may include **`completionRewardSummary.reputationGrants`** (`factionId`, `amount`) (NEO-140).
- **Scripts:** `scripts/faction_standing_client.gd`, `scripts/quest_hud_controller.gd` (standing label + gate deny + rep reward lines), `scripts/quest_definitions_client.gd` (**`faction_gate_rules_for`**).
- **HUD:**
- **`UICanvas/HudRootScroll/HudRoot/FactionStandingLabel`** — both prototype factions with readable names + standing values; default **0** before first GET.
- **`QuestAcceptFeedbackLabel`** — **`faction_gate_blocked`** renders readable gate copy (e.g. **`Grid Operators standing 15 required (have 0)`**); other deny codes unchanged (NEO-122).
- **`QuestRewardDeliveryLabel`** — adds **`reputationGrants`** lines (e.g. **`Grid Operators +15 rep`**) on completion transition (NEO-131).
- **Refresh:** boot hydrate + faction GET after in-session quest completion transition (rep grants apply on completion). No periodic poll.
- **Errors:** failed faction-standing GET shows **`error — …`** on **`FactionStandingLabel`**; defaults remain visible at **0**.
Full checklist: [`docs/manual-qa/NEO-142.md`](../docs/manual-qa/NEO-142.md).
## End-to-end quest reward loop (NEO-132)
Epic 7 Slice 2 capstone — complete all four prototype quests **in Godot** and verify quest completion bundles (skill XP + item grants) apply **once** with idempotent replay.

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" 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

View File

@ -0,0 +1,158 @@
extends Node
## NEO-142: prototype HTTP client for faction-standing GET (NEO-139).
## Route: [code]GET /game/players/{id}/faction-standing[/code].
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_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,8 @@ func _setup_quest_progress_sync() -> void:
_quest_reward_delivery_label,
_item_defs_client,
_inventory_client,
_skill_progression_client
_skill_progression_client,
{"client": _faction_standing_client, "label": _faction_standing_label}
)

View File

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

View File

@ -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,24 @@ 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_hud: Dictionary = {}
) -> 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_hud.get("client")
_faction_standing_label = faction_standing_hud.get("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 +83,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 +157,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 +243,74 @@ 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:
return FactionStandingClient.display_name_for(faction_id)
func _refresh_economy_hud() -> void:
@ -258,41 +352,39 @@ func _render_progress_label() -> void:
if not is_instance_valid(_progress_label):
return
var header := "Quests:"
var lines: PackedStringArray = [header]
if _last_snapshot.is_empty():
var lines: PackedStringArray = [header]
if not _progress_error.is_empty():
lines.append("error — %s" % _progress_error)
if not _defs_error.is_empty():
lines.append("definitions error — %s" % _defs_error)
lines.append("Loading…")
_progress_label.text = "\n".join(lines)
return
var lines: PackedStringArray = [header]
if not _progress_error.is_empty():
lines.append("sync error — %s" % _progress_error)
if not _defs_error.is_empty():
lines.append("definitions error — %s" % _defs_error)
var defs: Array = _defs_snapshot()
if defs.is_empty():
var quests: Variant = _last_snapshot.get("quests", null)
if quests is Array:
for row_variant in quests as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var qid: String = str(row.get("questId", ""))
lines.append(" %s" % _format_status_line(qid, qid, row))
else:
for def_variant in defs:
if not def_variant is Dictionary:
continue
var def: Dictionary = def_variant
var qid: String = str(def.get("id", ""))
if qid.is_empty():
continue
var row: Dictionary = _quest_row(qid)
var label: String = _display_name(qid)
lines.append(" %s" % _format_status_line(qid, label, row))
if not _progress_error.is_empty():
lines.append("sync error — %s" % _progress_error)
if not _defs_error.is_empty():
lines.append("definitions error — %s" % _defs_error)
var defs: Array = _defs_snapshot()
if defs.is_empty():
var quests: Variant = _last_snapshot.get("quests", null)
if quests is Array:
for row_variant in quests as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var qid: String = str(row.get("questId", ""))
lines.append(" %s" % _format_status_line(qid, qid, row))
else:
for def_variant in defs:
if not def_variant is Dictionary:
continue
var def: Dictionary = def_variant
var qid: String = str(def.get("id", ""))
if qid.is_empty():
continue
var row: Dictionary = _quest_row(qid)
var label: String = _display_name(qid)
lines.append(" %s" % _format_status_line(qid, label, row))
_progress_label.text = "\n".join(lines)
@ -335,6 +427,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

View File

@ -0,0 +1,183 @@
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
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
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 @@
uid://dnyf6txxr40d8

View File

@ -0,0 +1,260 @@
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()
var progress_client := Node.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
add_child(controller)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_progress_client", progress_client)
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)
func test_setup_wires_faction_standing_hud_dictionary() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
var accept_label := Label.new()
var standing_label := Label.new()
var progress := Node.new()
var defs := Node.new()
var faction_client := MockFactionSyncClient.new()
auto_free(controller)
auto_free(progress_label)
auto_free(accept_label)
auto_free(standing_label)
auto_free(progress)
auto_free(defs)
auto_free(faction_client)
add_child(controller)
# Act
controller.call(
"setup",
progress,
defs,
progress_label,
accept_label,
Callable(self, "_noop_http_config"),
null,
null,
null,
null,
{"client": faction_client, "label": standing_label}
)
# Assert
assert_object(controller.get("_faction_standing_client")).is_same(faction_client)
assert_object(controller.get("_faction_standing_label")).is_same(standing_label)
func _noop_http_config(_client: Node) -> void:
pass

View File

@ -0,0 +1 @@
uid://71r1qdmlti84

View File

@ -52,6 +52,16 @@ static func _four_quests_json() -> String:
)
static func _grid_contract_quests_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 _make_client(transport: Node) -> Node:
var c: Node = QuestDefinitionsClient.new()
c.set("injected_http", transport)
@ -82,7 +92,7 @@ func test_request_sync_emits_definitions_ready() -> void:
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("definitions_ready", any())
assert_signal(c).is_emitted("definitions_ready", any())
assert_that(_quests_capture.size()).is_equal(4)
assert_that(transport.last_url).contains("/quest-definitions")
@ -95,9 +105,9 @@ func test_display_name_for_returns_catalog_display_name() -> void:
c.call("request_sync_from_server")
await get_tree().process_frame
# Act
var name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
var display_name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
# Assert
assert_that(name).is_equal("Intro: Salvage Run")
assert_that(display_name).is_equal("Intro: Salvage Run")
func test_display_name_for_falls_back_to_raw_id() -> void:
@ -108,6 +118,35 @@ func test_display_name_for_falls_back_to_raw_id() -> void:
c.call("request_sync_from_server")
await get_tree().process_frame
# Act
var name: String = str(c.call("display_name_for", "unknown_quest_id"))
var display_name: String = str(c.call("display_name_for", "unknown_quest_id"))
# Assert
assert_that(name).is_equal("unknown_quest_id")
assert_that(display_name).is_equal("unknown_quest_id")
func test_faction_gate_rules_for_returns_cached_gate_rows() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _grid_contract_quests_json()
var c := _make_client(transport)
c.call("request_sync_from_server")
await get_tree().process_frame
# Act
var rules: Array = c.call("faction_gate_rules_for", "prototype_quest_grid_contract") as Array
# Assert
assert_that(rules.size()).is_equal(1)
var rule: Dictionary = rules[0]
assert_that(str(rule.get("factionId", ""))).is_equal("prototype_faction_grid_operators")
assert_that(int(rule.get("minStanding", -1))).is_equal(15)
func test_faction_gate_rules_for_returns_empty_when_quest_missing() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _grid_contract_quests_json()
var c := _make_client(transport)
c.call("request_sync_from_server")
await get_tree().process_frame
# Act
var rules: Array = c.call("faction_gate_rules_for", "prototype_quest_gather_intro") as Array
# Assert
assert_that(rules.is_empty()).is_true()

View File

@ -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:
@ -319,7 +327,7 @@ func test_completion_transition_emits_economy_refresh_signal() -> void:
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
await assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any())
assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any())
func test_completion_transition_invokes_economy_refresh_callback() -> void:
@ -352,9 +360,7 @@ func test_boot_completed_does_not_emit_economy_refresh_signal() -> void:
# Act
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
await (assert_signal(controller).wait_until(200).is_not_emitted(
"quest_completion_reward_transition"
))
assert_signal(controller).wait_until(200).is_not_emitted("quest_completion_reward_transition")
func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void:

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); **E7M3-08 quest HTTP projections landed** ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)): world GET `factionGateRules` + quest-progress `completionRewardSummary.reputationGrants` ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); **E7M3-09 faction telemetry hooks landed** ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)): comment-only **`reputation_delta`** + **`faction_gate_blocked`** in ops layers + README ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)). Slice 3 backlog **E7M3-10** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); **E7M3-08 quest HTTP projections landed** ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)): world GET `factionGateRules` + quest-progress `completionRewardSummary.reputationGrants` ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); **E7M3-09 faction telemetry hooks landed** ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)): comment-only **`reputation_delta`** + **`faction_gate_blocked`** in ops layers + README ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); **E7M3-10 client faction HUD landed** ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)): `faction_standing_client.gd`, `FactionStandingLabel`, readable `faction_gate_blocked` deny, `reputationGrants` reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). Slice 3 backlog **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose

File diff suppressed because one or more lines are too long

View File

@ -1,34 +0,0 @@
# Manual QA — NEO-141 (faction telemetry hook sites)
Reference: [implementation plan](../plans/NEO-141-implementation-plan.md), [NEO-136 implementation plan](../plans/NEO-136-implementation-plan.md) (reputation ops), [NEO-137 implementation plan](../plans/NEO-137-implementation-plan.md) (gate ops).
## Preconditions
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`) if exercising Bruno; not required for code review only.
- This story adds **comments only**; faction standing, gate eval, and reward delivery behavior match NEO-136/137/138.
## Code review check
- [ ] Open `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs`.
- [ ] Confirm **`reputation_delta`** comment block immediately after **`auditStore.TryAppend`** returns **`true`**.
- [ ] Confirm **`TODO(E9.M1)`** markers and planned payload fields; **no** new `ILogger` or metrics calls.
- [ ] Open `server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs`.
- [ ] Confirm **`faction_gate_blocked`** comment on standing read-failure deny and threshold deny branches.
- [ ] Confirm **no** duplicate hook comments in **`QuestStateOperations`**, **`QuestAcceptApi`**, **`FactionStandingApi`**, or **`RewardRouterOperations`** for these event names.
- [ ] Open [server README — Faction telemetry hooks (NEO-141)](../../server/README.md#faction-telemetry-hooks-neo-141) — NEO-141 subsection present with event table.
- [ ] Open [server README — Reward telemetry hooks (NEO-130)](../../server/README.md#reward-telemetry-hooks-neo-130) — **`reputation_delta`** stub row removed; cross-link to NEO-141 present.
## Regression (optional)
```bash
cd server && dotnet test NeonSprawl.sln
```
Expect all tests pass (comments-only change).
## Bruno (optional sanity)
From `bruno/neon-sprawl-server/` against a running server:
- **Accept grid contract faction gate deny****`faction_gate_blocked`** deny unchanged.
- **Accept grid contract after operator chain** — rep grant + accept success unchanged.

View File

@ -0,0 +1,43 @@
# NEO-142 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-142 |
| Title | E7M3-10: Client faction standing + gate feedback HUD (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-142/e7m3-10-client-faction-standing-gate-feedback-hud-godot |
| Plan | `docs/plans/NEO-142-implementation-plan.md` |
| Branch | `NEO-142-client-faction-standing-gate-hud` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so quest progress, faction standing, and reward delivery reset.
- **No Bruno/curl** for this checklist — use Godot gameplay only.
- NEO-139 **`GET /game/players/{id}/faction-standing`** and NEO-140 quest HTTP rep/gate projections landed on `main`.
- NEO-122 quest HUD + NEO-131 reward HUD landed on `main`.
## Expected HUD progression
| After | `FactionStandingLabel` | `QuestAcceptFeedbackLabel` |
|-------|------------------------|----------------------------|
| Boot | `Grid Operators: 0`, `Rust Collective: 0` | idle accept hint |
| Operator chain completes | `Grid Operators: 15` | unchanged |
| Accept grid contract before rep (after chain complete, standing still 0) | still `0` until chain rep lands | readable gate deny with required standing |
| Accept grid contract at standing 15 | unchanged | accept succeeds |
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`FactionStandingLabel`** appears below **`QuestRewardDeliveryLabel`** with both factions at **0**.
3. Follow [NEO-123](NEO-123.md) through **`prototype_quest_operator_chain`** completion (or use the operator-chain segment from [NEO-131](NEO-131.md) step 6).
4. Verify **`FactionStandingLabel`** updates to **`Grid Operators: 15`** (Rust Collective still **0**) — may lag **`QuestRewardDeliveryLabel`** rep line by one faction-standing GET round-trip; confirm both within a moment after operator-chain completion.
5. Verify **`QuestRewardDeliveryLabel`** on operator-chain completion includes **`Grid Operators +15 rep`** (alongside item/skill lines when present).
6. Verify readable **`faction_gate_blocked`** copy via GdUnit (`client/test/faction_standing_hud_test.gd`) — normal Godot gameplay completes operator chain with **+15** rep atomically, so live accept of **`prototype_quest_grid_contract`** succeeds at standing **15** rather than denying. Optional server dev setup: `POST …/__dev/quest-fixture` with **`completedQuestIds: [prototype_quest_operator_chain]`** and faction reset (see Bruno seq 10) then attempt grid-contract accept in Godot — expect **`Grid Operators standing 15 required (have 0)`** on **`QuestAcceptFeedbackLabel`**.
7. Accept **`prototype_quest_grid_contract`** when **`Grid Operators: 15`**. Verify accept succeeds.
8. Optional: stop server while Godot running; confirm **`FactionStandingLabel`** shows **`error — …`** while quest labels keep NEO-122 sync error behavior.
## Regression spot-check
- [NEO-122](NEO-122.md) — quest progress + accept HUD unchanged except new label below reward label.
- [NEO-131](NEO-131.md) — reward label transition-only behavior unchanged except rep grant lines.
**Full-flow capstone:** [NEO-143](NEO-143.md) extends this with end-to-end Slice 3 faction reputation verification.

View File

@ -307,7 +307,7 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o
- [x] Hook sites documented in README; no behavior change beyond comments.
**Landed (NEO-141):** comment-only **`reputation_delta`** in **`ReputationOperations.TryApplyDelta`**; **`faction_gate_blocked`** in **`FactionGateOperations.TryEvaluate`** (threshold + standing read failure); router duplicate stub removed; [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141); manual QA [`NEO-141.md`](../../manual-qa/NEO-141.md).
**Landed (NEO-141):** comment-only **`reputation_delta`** in **`ReputationOperations.TryApplyDelta`**; **`faction_gate_blocked`** in **`FactionGateOperations.TryEvaluate`** (threshold + standing read failure); router duplicate stub removed; [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141).
**Client counterpart:** none (infrastructure-only).
@ -332,8 +332,10 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o
**Acceptance criteria**
- [ ] Standing visible after operator-chain completion (+15 Grid Operators).
- [ ] Accepting grid contract below rep shows readable deny copy on HUD.
- [x] Standing visible after operator-chain completion (+15 Grid Operators).
- [x] Accepting grid contract below rep shows readable deny copy on HUD.
**Landed (NEO-142):** `faction_standing_client.gd`, `FactionStandingLabel`, readable `faction_gate_blocked` accept deny, `reputationGrants` reward lines; GdUnit + [`docs/manual-qa/NEO-142.md`](../manual-qa/NEO-142.md); plan [NEO-142](../plans/NEO-142-implementation-plan.md); [client README — Faction standing + gate feedback HUD (NEO-142)](../../client/README.md#faction-standing--gate-feedback-hud-neo-142).
**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140).

View File

@ -25,7 +25,7 @@ Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_bl
| **`faction_gate_blocked` anchor** | **`FactionGateOperations.TryEvaluate`** deny paths only — not duplicate in **`QuestStateOperations.TryAccept`** or **`QuestAcceptApi`** | Backlog lists ops + TryAccept flow; NEO-137 placed hook at eval engine; HTTP maps all gate failures to `faction_gate_blocked` |
| **Standing read failure deny** | **Same event name** with ops **`reasonCode`** in planned payload (`unknown_faction` vs `gate_blocked`) — add comment block on read-failure branch | NEO-137 review: ops may carry `unknown_faction` for telemetry while quest HTTP surfaces `faction_gate_blocked` |
| **Emit timing for `reputation_delta`** | After **`auditStore.TryAppend`** returns **`true`** (standing + audit committed) — not on deny, audit rollback, or compensating revert paths | NEO-136 success path; mirror NEO-49 idempotent “commit site only” |
| **Manual QA doc** | **`docs/manual-qa/NEO-141.md`** — NEO-121/130 comment-only verification pattern | Infrastructure-only server story |
| **Manual QA doc** | **None** — comment-only server story; `dotnet test` covers regression ([NEO-140](NEO-140-implementation-plan.md) precedent; [planning-implementation-docs](../.cursor/rules/planning-implementation-docs.md) skip when AC fully automated) | Infrastructure-only; no user-visible behavior |
## Scope and out-of-scope
@ -36,7 +36,6 @@ Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_bl
- Remove duplicate **`reputation_delta`** stub from **`RewardRouterOperations`**.
- **`server/README.md`** — dedicated **Faction telemetry hooks (NEO-141)** subsection; update **Reward telemetry hooks (NEO-130)** table to cross-link (remove stub row).
- **`E7_M3_FactionReputationLedger.md`**, alignment register, E7M3 backlog updates when implementation completes.
- **`docs/manual-qa/NEO-141.md`**.
**Out of scope:**
@ -57,7 +56,7 @@ Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_bl
- **`FactionGateOperations.TryEvaluate`:** **`faction_gate_blocked`** hooks on threshold deny and standing read-failure deny (ops `reasonCode` in payload note).
- **`RewardRouterOperations`:** duplicate **`reputation_delta`** stub removed — single ops-layer anchor.
- **Docs:** `server/README.md` **Faction telemetry hooks (NEO-141)** subsection; NEO-130 reward table cross-link; E7.M3 module + alignment register + E7M3 backlog updated.
- **Manual QA:** `docs/manual-qa/NEO-141.md`.
- **Manual QA:** none — comment-only server story; `dotnet test` covers regression (manual QA checklists reserved for user-visible stories per project convention).
## Technical approach
@ -138,9 +137,7 @@ sequenceDiagram
## Files to add
| Path | Rationale |
|------|-----------|
| `docs/manual-qa/NEO-141.md` | Manual QA: verify comment blocks + no behavior/API change (NEO-121/130 pattern) |
None — comment-only changes to existing ops files and docs; no new source or test files.
## Files to modify
@ -158,7 +155,7 @@ sequenceDiagram
| File | Coverage |
|------|----------|
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing faction/quest/reward tests unchanged). Manual verification: **`docs/manual-qa/NEO-141.md`**. |
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing faction/quest/reward tests unchanged). |
## Open questions / risks

View File

@ -0,0 +1,246 @@
# NEO-142 — E7M3-10: Client faction standing + gate feedback HUD (Godot)
**Linear:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)
**Branch:** `NEO-142-client-faction-standing-gate-hud`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-10**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-122](NEO-122-implementation-plan.md) — quest HUD + accept feedback; [NEO-131](NEO-131-implementation-plan.md) — completion reward label + transition refresh; [NEO-73](NEO-73-implementation-plan.md) / [NEO-139](NEO-139-implementation-plan.md) — snapshot GET client
**Precursors:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) **`Done`** — `GET /game/players/{id}/faction-standing`; [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) **`Done`** — world **`factionGateRules`** + progress **`completionRewardSummary.reputationGrants`**
**Blocks:** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) — Slice 3 Godot capstone manual QA
**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) + [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) — authoritative GET projections; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md)
## Goal
Player sees faction standing and accept gate denials in Godot without Bruno.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|----------------------|--------|
| **Standing HUD placement** | HudRoot quest stack vs EconomyHudSection? | **HudRoot quest stack** — new **`FactionStandingLabel`** after **`QuestRewardDeliveryLabel`**; keeps quest/rep loop visible with NEO-122/131 labels; economy panel stays inventory/skills/gig. | **Adopted** — HudRoot quest stack |
| **Faction display names** | How to label standing/rep lines without `GET /game/world/faction-definitions`? | **Static prototype map** in client matching `content/factions/prototype_factions.json` freeze; fallback title-case **`id`** token. | **Adopted** (no kickoff question — NEO-139 server README: “join from client content”) |
| **`faction_gate_blocked` copy** | Raw `reasonCode` vs readable gate detail? | **Readable gate detail** — join cached world quest **`factionGateRules`** for denied quest + current standing from faction snapshot (e.g. “Grid Operators standing 15 required (have 0)”); other reason codes keep NEO-122 `denied — {reasonCode}`. | **Adopted** (meets Linear AC; NEO-140 gate projection landed) |
| **Standing refresh** | When to GET faction-standing? | **Boot hydrate** + on **`quest_completion_reward_transition`** (rep grants apply on completion, not accept); coalesce via client pending-sync like skill progression. | **Adopted** (backlog: reuse quest HUD completion signals) |
| **Reward label rep lines** | Where to surface **`reputationGrants`**? | **Extend `_format_reward_summary_lines`** in **`quest_hud_controller.gd`** — same transition-only reward label as NEO-131 item/skill lines. | **Adopted** (backlog explicit) |
## Scope and out-of-scope
**In scope (from Linear + [E7M3-10](E7M3-pre-production-backlog.md#e7m3-10--client-faction-standing--gate-feedback-hud-godot)):**
- **`faction_standing_client.gd`** — GET **`/game/players/{id}/faction-standing`**; parse v1; **`faction_row(faction_id, snapshot)`**; signals **`faction_standing_received`** / **`faction_standing_sync_failed`**; **`request_sync_from_server()`** with pending coalesce.
- **`FactionStandingLabel`** in **`HudRoot`** after **`QuestRewardDeliveryLabel`** — both prototype factions with readable names + standing values.
- Parse + render refresh after quest completion transitions (wire through **`quest_hud_controller.gd`**).
- Surface **`faction_gate_blocked`** on **`QuestAcceptFeedbackLabel`** with readable gate copy (not raw code alone).
- Extend **`quest_hud_controller.gd`** reward formatter for **`reputationGrants`** lines in **`completionRewardSummary`**.
- Optional helper on **`quest_definitions_client.gd`**: **`faction_gate_rules_for(quest_id)`** reading cached world rows (NEO-140 field passes through existing parse).
- GdUnit parse + HUD tests ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-142.md`** — component checklist.
- **`client/README.md`** faction standing + gate feedback subsection.
**Out of scope (from Linear + backlog):**
- Final journal UI; faction art pass.
- **`GET /game/world/faction-definitions`** client (no backlog item).
- Server route/DTO changes.
- Full Slice 3 capstone script (**[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)**).
- Periodic faction-standing poll timer.
**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140).
## Acceptance criteria checklist
- [x] Standing visible after operator-chain completion (+15 Grid Operators).
- [x] Accepting grid contract below rep shows readable deny copy on HUD.
## Implementation reconciliation (shipped)
- **`faction_standing_client.gd`:** GET parse v1, prototype display names, `faction_row` / `standing_for`, pending sync.
- **`quest_definitions_client.gd`:** `faction_gate_rules_for` helper.
- **`quest_hud_controller.gd`:** `FactionStandingLabel` render; boot + completion refresh; readable `faction_gate_blocked` deny via static `FactionStandingClient.display_name_for`; `reputationGrants` reward lines.
- **`main.gd` / `main.tscn`:** `FactionStandingClient` + `FactionStandingLabel` wired into quest HUD setup.
- **Tests:** `faction_standing_client_test.gd`, `faction_standing_hud_test.gd`, `quest_definitions_client_test.gd` (`faction_gate_rules_for`); `quest_reward_hud_test.gd` rep line assertion. **24** targeted GdUnit tests pass.
- **Docs:** `client/README.md`; `docs/manual-qa/NEO-142.md`; E7.M3 module + alignment register + E7M3 backlog updated.
- **Manual QA:** `docs/manual-qa/NEO-142.md` — standing +15, rep reward line, gate deny (GdUnit + optional dev fixture for live deny).
### PR notes (bundled branch)
This branch includes **NEO-142** client HUD work, **NEO-141** story-end manual QA removal (`docs/manual-qa/NEO-141.md`), and **`chore:`** `RewardRouterOperations` collection-expression cleanup — call out all three in the PR description.
## Technical approach
### 1. Server contracts (landed — NEO-139 / NEO-140)
**Faction standing GET** (`schemaVersion` 1):
```json
{
"schemaVersion": 1,
"playerId": "dev-local-1",
"factions": [
{ "id": "prototype_faction_grid_operators", "standing": 15 },
{ "id": "prototype_faction_rust_collective", "standing": 0 }
]
}
```
Consumers key rows by **`id`** — array order is not contractual.
**World quest definition** (grid contract gate row):
```json
{
"id": "prototype_quest_grid_contract",
"factionGateRules": [
{ "factionId": "prototype_faction_grid_operators", "minStanding": 15 }
]
}
```
**Completion reward summary** (operator chain rep line):
```json
{
"reputationGrants": [
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
]
}
```
### 2. `faction_standing_client.gd`
Mirror **`skill_progression_client.gd`**:
| Concern | Implementation |
|---------|----------------|
| URL | `{base}/game/players/{dev_player_id}/faction-standing` |
| Parse | Require `schemaVersion == 1`, non-empty `playerId`, `factions` array of `{ id, standing }` |
| Helpers | **`faction_row(faction_id, snapshot)`**; **`standing_for(faction_id, snapshot) -> int`** default **0** when row missing |
| Display names | **`display_name_for(faction_id)`** — static map for two prototype ids; title-case fallback |
| Errors | 404 → `"HTTP 404 (player unknown)"`; schema mismatch → `"non-JSON body or schemaVersion mismatch"`; emit **`faction_standing_sync_failed`** |
Static display-name map (matches [prototype_factions.json](../../content/factions/prototype_factions.json)):
| `id` | Label |
|------|-------|
| `prototype_faction_grid_operators` | Grid Operators |
| `prototype_faction_rust_collective` | Rust Collective |
Render order: Grid Operators first, Rust Collective second (catalog freeze order).
### 3. HUD wiring (`main.gd` / `main.tscn`)
- Add **`FactionStandingClient`** node sibling to **`QuestProgressClient`**.
- Add **`FactionStandingLabel`** after **`QuestRewardDeliveryLabel`** in **`HudRoot`** (user-adopted placement).
- Boot: apply authority HTTP config; connect client signals; **`request_sync_from_server()`** on setup.
- Extend **`QuestHudController.setup(...)`** with **`faction_standing_client`** + **`faction_standing_label`** (optional params to avoid breaking test harnesses — pass `null` when absent).
### 4. Standing label render (`quest_hud_controller.gd`)
Default idle:
```
Faction standing:
Grid Operators: 0
Rust Collective: 0
```
On sync error, append **`error — {reason}`** under header (NEO-122 pattern). On successful snapshot, paint both factions from GET (not local math).
**Refresh triggers:**
1. **`setup`** → faction client boot GET (via controller or `main` — prefer controller calling **`request_sync_from_server()`** after connect).
2. **`quest_completion_reward_transition`** → faction GET (standing changes on quest completion rep delivery).
3. Do **not** refresh on accept deny alone (standing unchanged on failed accept).
### 5. Accept gate feedback (`faction_gate_blocked`)
In **`_on_accept_result_received`**, when **`accepted == false`** and **`reasonCode == "faction_gate_blocked"`**:
1. Read **`faction_gate_rules_for(quest_id)`** from defs client (first rule sufficient for prototype grid contract).
2. Read current standing from last faction snapshot via **`standing_for(factionId, snapshot)`**.
3. Render e.g. **`Quest accept: denied — Grid Operators standing 15 required (have 0)`**.
4. If gate rules or standing snapshot unavailable, fallback **`Quest accept: denied — faction standing too low (faction_gate_blocked)`**.
Other deny codes unchanged: **`Quest accept: denied — {reasonCode}`**.
### 6. Reward label — `reputationGrants`
Extend **`_format_reward_summary_lines`** after skill XP block:
```gdscript
# reputationGrants: [{ factionId, amount }]
lines.append(" Grid Operators +15 rep")
```
Use **`display_name_for(factionId)`** from faction standing client when wired; else title-case **`factionId`**.
Update **`quest_reward_hud_test.gd`** (or new suite) for operator-chain transition including rep line.
### 7. Flow
```mermaid
sequenceDiagram
participant Main
participant QuestHud as QuestHudController
participant FactionClient as FactionStandingClient
participant QuestClient as QuestProgressClient
participant Server
Main->>QuestHud: setup (quest + faction clients)
QuestHud->>FactionClient: request_sync_from_server (boot)
FactionClient->>Server: GET faction-standing
Server-->>FactionClient: factions[]
FactionClient-->>QuestHud: faction_standing_received
QuestHud->>QuestHud: render FactionStandingLabel
QuestClient->>Server: GET quest-progress (completion)
QuestClient-->>QuestHud: quest_progress_received
QuestHud->>QuestHud: detect completed transition
QuestHud->>FactionClient: request_sync_from_server
QuestHud->>QuestHud: render reward (+ reputationGrants)
QuestClient->>Server: POST accept grid contract (deny)
QuestClient-->>QuestHud: reasonCode faction_gate_blocked
QuestHud->>QuestHud: readable gate deny on accept label
```
## Files to add
| Path | Rationale |
|------|-----------|
| `client/scripts/faction_standing_client.gd` | HTTP GET client for NEO-139 faction-standing snapshot |
| `client/scripts/faction_standing_client.gd.uid` | Godot uid companion (tracked with script) |
| `client/test/faction_standing_client_test.gd` | GdUnit parse, 404, `faction_row` / `display_name_for`, pending sync |
| `client/test/faction_standing_hud_test.gd` | GdUnit standing label render, gate deny copy, completion refresh hook |
| `docs/manual-qa/NEO-142.md` | Godot component checklist (standing +15, grid-contract gate deny) |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/quest_hud_controller.gd` | Wire faction client/label; standing render; gate deny copy; `reputationGrants` reward lines; refresh faction on completion transition |
| `client/scripts/quest_definitions_client.gd` | Add **`faction_gate_rules_for(quest_id)`** helper over cached world rows |
| `client/scripts/main.gd` | `@onready` faction client + label; pass into quest HUD **`setup`**; boot HTTP config |
| `client/scenes/main.tscn` | **`FactionStandingClient`** node + **`FactionStandingLabel`** after reward label |
| `client/test/quest_reward_hud_test.gd` | Assert **`reputationGrants`** line on operator-chain completion transition |
| `client/README.md` | Document faction standing GET, label placement, gate feedback, refresh triggers |
## Tests
| File | Coverage |
|------|----------|
| `client/test/faction_standing_client_test.gd` | **Add:** parse valid snapshot; reject bad schema; 404 sync failed; `faction_row` / `standing_for`; prototype display names; pending sync coalesce |
| `client/test/faction_standing_hud_test.gd` | **Add:** standing label after snapshot (+15 Grid Operators); sync error line; `faction_gate_blocked` readable deny using mock defs + standing; fallback when rules missing |
| `client/test/quest_reward_hud_test.gd` | **Change:** operator-chain completion summary includes **`Grid Operators +15 rep`** line |
Manual verification: **`docs/manual-qa/NEO-142.md`** — server + Godot; complete operator chain → standing **15**; accept grid contract before rep → readable deny.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Standing label shows **0** before any rep (not hidden until +15) | **Show both factions from boot** — AC scenario is “visible after completion” with **15**, not hidden-until-earned | `adopted` |
| Gate deny before operator-chain prerequisite (`prerequisite_incomplete`) | **Keep NEO-122 raw reasonCode** — AC targets **`faction_gate_blocked`** after chain complete | `adopted` |
| Faction GET lags quest completion refresh | **Accept brief stale standing** on same frame; completion handler fires faction GET immediately after transition | `adopted` |
| `main.tscn` ext_resource churn | Add script ext_resource for faction client; match existing client node pattern | `adopted` |
| NEO-143 capstone overlap | NEO-142 = component HUD; NEO-143 = full end-to-end manual QA script | `adopted` |

View File

@ -21,7 +21,7 @@ This branch lands **E7M3-09**: comment-only telemetry anchor sites for future E9
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-09 landed (NEO-141); Responsibilities “Emit telemetry hook sites” satisfied at comment layer. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated with NEO-141 telemetry + README link. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. |
| `docs/manual-qa/NEO-141.md` | **Matches** — NEO-130-style code-review checklist; Bruno optional sanity steps. |
| `docs/manual-qa/NEO-141.md` | **N/A (removed)** — comment-only server story; no user-visible manual QA per [NEO-140](NEO-140-implementation-plan.md) / [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md) skip rule. |
| `docs/plans/NEO-140-implementation-plan.md` | **Matches** — manual QA removal note consistent with story-end convention (bundled in branch). |
| `server/README.md` | **Matches** — Faction telemetry hooks (NEO-141) subsection; NEO-130 table cross-link; no duplicate router stub. |
@ -44,12 +44,7 @@ None.
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln
```
Manual (see `docs/manual-qa/NEO-141.md` — code review checklist):
- Confirm hook comment blocks in `ReputationOperations.cs`, `FactionGateOperations.cs`; no **`reputation_delta`** stub in `RewardRouterOperations.cs`.
- Confirm README NEO-141 subsection and NEO-130 cross-link.
Optional Bruno (unchanged behavior):
Manual verification: `dotnet test NeonSprawl.sln`; optional Bruno gate/rep flows unchanged.
- `quest-progress/Accept grid contract faction gate deny`
- `quest-progress/Accept grid contract after operator chain`

View File

@ -0,0 +1,54 @@
# Code review — NEO-142 (E7M3-10)
**Date:** 2026-06-17
**Scope:** Branch `NEO-142-client-faction-standing-gate-hud` — commits `fb9a8ab``e396228` vs `main`
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-10**: Godot client faction standing HUD and readable **`faction_gate_blocked`** accept feedback, wired through existing quest HUD patterns (NEO-122 / NEO-131). New **`faction_standing_client.gd`** mirrors **`skill_progression_client.gd`** (GET parse, pending-sync coalesce, 404 handling). **`quest_hud_controller.gd`** renders **`FactionStandingLabel`**, refreshes standing on boot + in-session completion transitions, formats **`reputationGrants`** reward lines, and builds readable gate deny copy from cached **`factionGateRules`** + last standing snapshot. **`quest_definitions_client.gd`** adds **`faction_gate_rules_for`**. GdUnit coverage spans client parse/sync, HUD render/deny/refresh, and operator-chain rep reward line. Docs: implementation plan, **`client/README.md`** faction subsection, **`docs/manual-qa/NEO-142.md`**. The branch also bundles **NEO-141 story-end cleanup** (remove **`docs/manual-qa/NEO-141.md`**, plan/README wording) and a small unrelated **`RewardRouterOperations`** collection-expression refactor. **24** targeted GdUnit tests pass locally. Low merge risk — read-only client projections; no server contract changes beyond doc/chore.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-142-implementation-plan.md` | **Matches** — kickoff decisions, file list, technical approach, AC checklist, and **Implementation reconciliation (shipped)** align with the diff; PR bundled-commit note added. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-10 scope bullets match; E7M3-10 AC checked + landed note. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — module intent and contracts satisfied; Status line notes **E7M3-10 landed (NEO-142)**; **E7M3-11** capstone pending. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row includes NEO-142 client HUD landed note + README/manual QA links. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. |
| `docs/manual-qa/NEO-142.md` | **Matches** — component checklist, honest gate-deny testing note, standing-label refresh lag note on step 4. |
| `client/README.md` | **Matches****Faction standing + gate feedback HUD (NEO-142)** subsection documents GET endpoints, label placement, refresh triggers, and error pattern. Does not yet include **End-to-end faction reputation loop** capstone (E7M3-11 / NEO-143 scope per backlog). |
| `docs/plans/NEO-141-implementation-plan.md` | **Matches** — bundled story-end manual QA removal consistent with NEO-140 precedent. |
## Blocking issues
None.
## Suggestions
1. ~~**Update decomposition tracking when NEO-142 closes.** After manual QA, extend **`E7_M3_FactionReputationLedger.md`** Status and **`documentation_and_implementation_alignment.md`** E7.M3 row with **E7M3-10 landed (NEO-142)** — client faction standing GET + HUD + gate deny + rep reward lines; link **`client/README.md`** faction subsection and **`docs/manual-qa/NEO-142.md`**.~~ **Done.**
2. ~~**Reconcile plan AC at story end.** Check off **`docs/plans/NEO-142-implementation-plan.md`** acceptance criteria and E7M3-10 backlog AC after Godot manual verification; mark **Implementation reconciliation** complete (same pattern as NEO-140/141 reviews).~~ **Done.**
3. ~~**PR description should call bundled commits.** If opening one PR from this branch, mention NEO-142 HUD work plus NEO-141 manual-QA removal and the **`RewardRouterOperations`** collection-expression chore so reviewers are not surprised by server diff noise.~~ **Done.** Noted in plan **PR notes (bundled branch)** section.
## Nits
- ~~Nit: **`quest_hud_controller.gd`** **`_faction_display_name`** probes an instance **`display_name_for`** on **`FactionStandingClient`**, but the script exposes **`display_name_for`** as **`static`** only — the instance branch is likely dead; fallback static call is correct either way.~~ **Done.** **`_faction_display_name`** calls **`FactionStandingClient.display_name_for`** directly.
- Nit: **`_title_case_token`** is duplicated in **`faction_standing_client.gd`** and **`quest_hud_controller.gd`** — acceptable for prototype; consolidate only if a shared helper already exists elsewhere.
- ~~Nit: No dedicated GdUnit for **`quest_definitions_client.faction_gate_rules_for`** — gate rules are exercised indirectly via **`faction_standing_hud_test.gd`**; optional direct parse test if the helper grows.~~ **Done.** **`quest_definitions_client_test.gd`** adds **`faction_gate_rules_for`** coverage.
- ~~Nit: Brief standing-label staleness on the same frame as completion (reward label shows **`+15 rep`** before faction GET returns) is adopted plan behavior; manual QA step 4 should confirm label catches up within one round-trip.~~ **Done.** Step 4 notes one GET round-trip lag.
## Verification
```bash
cd /Users/don/neon-sprawl/client && godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
-a test/faction_standing_client_test.gd -a test/faction_standing_hud_test.gd -a test/quest_reward_hud_test.gd
```
Manual verification: **`docs/manual-qa/NEO-142.md`** — fresh server + Godot **F5**; operator chain → **`Grid Operators: 15`** + reward rep line; optional dev fixture for live **`faction_gate_blocked`** deny copy.

View File

@ -18,6 +18,9 @@ public static class RewardRouterOperations
private static readonly RewardItemGrantApplied[] EmptyItemGrants = [];
private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = [];
private static readonly RewardReputationGrantApplied[] EmptyReputationGrants = [];
private static readonly IReadOnlyList<RewardGrantRow> EmptyBundleItemGrantRows = [];
private static readonly IReadOnlyList<QuestSkillXpGrantRow> EmptyBundleSkillXpGrantRows = [];
private static readonly IReadOnlyList<ReputationGrantRow> EmptyBundleReputationGrantRows = [];
/// <summary>
/// Applies <paramref name="bundle"/> item, skill XP, and reputation rows for a quest completion and records
@ -56,9 +59,9 @@ public static class RewardRouterOperations
return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
var itemGrants = bundle?.ItemGrants ?? Array.Empty<RewardGrantRow>();
var skillGrants = bundle?.SkillXpGrants ?? Array.Empty<QuestSkillXpGrantRow>();
var reputationGrants = bundle?.ReputationGrants ?? Array.Empty<ReputationGrantRow>();
var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows;
var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows;
var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows;
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId);
if (itemGrants.Count > 0)

View File

@ -143,7 +143,7 @@ Comment-only hook sites for future E9.M1 catalog events ([Epic 7 Slice 3](../../
| **`reputation_delta`** | **`ReputationOperations.TryApplyDelta`** | After successful audit append (auditable apply committed). |
| **`faction_gate_blocked`** | **`FactionGateOperations.TryEvaluate`** | On any rule deny (threshold or standing read failure). |
Manual QA: [`docs/manual-qa/NEO-141.md`](../../docs/manual-qa/NEO-141.md); plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan.md).
Plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan.md).
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined):
- **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (idempotent when absent).