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
commit
4f02dc47fa
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo142factionst01
|
||||
|
|
@ -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}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,16 +352,14 @@ func _render_progress_label() -> void:
|
|||
if not is_instance_valid(_progress_label):
|
||||
return
|
||||
var header := "Quests:"
|
||||
if _last_snapshot.is_empty():
|
||||
var lines: PackedStringArray = [header]
|
||||
if _last_snapshot.is_empty():
|
||||
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]
|
||||
else:
|
||||
if not _progress_error.is_empty():
|
||||
lines.append("sync error — %s" % _progress_error)
|
||||
if not _defs_error.is_empty():
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dnyf6txxr40d8
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://71r1qdmlti84
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue