Compare commits

...

32 Commits

Author SHA1 Message Date
VinPropane 74b6586004
Merge pull request #183 from ViPro-Technologies/NEO-143-playable-faction-reputation-gate-capstone
NEO-143: Playable faction reputation + gate capstone (Godot)
2026-06-17 22:24:10 -04:00
VinPropane 24a70d3566 NEO-143: Add Slice 3 faction capstone manual QA and docs.
Ship NEO-143 capstone checklist extending NEO-132, client README
end-to-end faction loop section, cross-links, and E7.M3 Ready alignment.
2026-06-17 21:43:35 -04:00
VinPropane 0903bf00bd NEO-143: Add Slice 3 faction capstone implementation plan.
Kickoff clarifications adopt docs-primary scope, NEO-132-extended
capstone path, and NEO-142 regression for gate deny verification.
2026-06-17 21:40:03 -04:00
VinPropane 4f02dc47fa
Merge pull request #182 from ViPro-Technologies/NEO-142-client-faction-standing-gate-hud
NEO-142: Client faction standing + gate feedback HUD (Godot)
2026-06-17 21:38:07 -04:00
VinPropane 4b221cdf2a NEO-142: gdformat faction standing and quest reward tests 2026-06-17 21:31:16 -04:00
VinPropane 4022a1c220 NEO-142: satisfy pre-push gdlint for faction HUD wiring
Wrap faction standing HUD deps in a setup dictionary to stay under the function-argument limit, shorten the faction client doc comment for max line length, and add a GdUnit test for setup wiring.
2026-06-17 21:31:09 -04:00
VinPropane 3308fa564f NEO-142: track GdUnit test script uid companions
Stage matching *.gd.uid files for faction standing client and HUD tests so uid:// references stay stable across clones.
2026-06-17 21:30:01 -04:00
VinPropane d4388e25aa NEO-142: rename shadowed name variable in quest defs tests
Avoid SHADOWED_VARIABLE_BASE_CLASS warnings against Node.name.
2026-06-17 21:24:30 -04:00
VinPropane 3a509d560e chore: silence IDE0301 and redundant await linter warnings
Use static empty bundle grant lists in RewardRouterOperations; drop
redundant await on synchronous GdUnit assert_signal in quest defs test.
2026-06-17 21:19:27 -04:00
VinPropane ee918c5412 NEO-142: reconcile docs and close out code review findings
Check off AC in plan/backlog; update E7.M3 module + alignment register;
strike through addressed review suggestions and nits.
2026-06-17 21:16:17 -04:00
VinPropane 00590d5944 NEO-142: review follow-up — static faction display names + gate rules tests
Use FactionStandingClient.display_name_for directly; add GdUnit coverage for
faction_gate_rules_for helper.
2026-06-17 21:16:17 -04:00
VinPropane 187ecc9c0d NEO-142: add code review for faction standing HUD branch 2026-06-17 21:14:49 -04:00
VinPropane e39622893b chore: use collection expressions for empty grant fallbacks
Replace Array.Empty<T>() with [] in RewardRouterOperations (IDE0301).
2026-06-17 21:13:29 -04:00
VinPropane f8a36213a1 NEO-142: clear GDScript linter warnings in faction HUD work
Hoist quest progress label lines binding; drop redundant await on synchronous
GdUnit assert_signal calls.
2026-06-17 21:12:54 -04:00
VinPropane be7878a199 NEO-142: fix GdUnit HTTPClient method enum in faction tests
Use METHOD_GET default params matching existing client test mocks; auto_free
progress client stub to avoid orphan exit code.
2026-06-17 21:11:27 -04:00
VinPropane 84bd46635d NEO-142: document faction HUD and manual QA checklist
Add client README section, NEO-142 manual QA steps, and plan reconciliation.
2026-06-17 21:10:36 -04:00
VinPropane c8c706f839 NEO-142: add faction standing client and HUD wiring
Introduce faction-standing GET client, standing label in HudRoot, readable
faction_gate_blocked accept feedback, reputationGrants reward lines, and GdUnit tests.
2026-06-17 21:10:34 -04:00
VinPropane fb9a8ab8b2 NEO-142: add E7M3-10 client faction HUD implementation plan
Kickoff plan for Godot faction standing label, gate deny feedback, and
reputationGrants reward lines after NEO-139/140 server APIs landed.
2026-06-17 21:08:56 -04:00
VinPropane b4d48f6e36 NEO-141: remove server-only manual QA doc after story end
Comment-only infrastructure story — acceptance fully covered by dotnet
test; align with NEO-140 and planning-implementation-docs skip rule.
2026-06-17 19:36:33 -04:00
VinPropane e102a6b539
Merge pull request #181 from ViPro-Technologies/NEO-141-faction-telemetry-hooks
NEO-141: E7M3-09 faction telemetry hook sites (comment-only)
2026-06-17 19:34:12 -04:00
VinPropane 0a4450a199 NEO-141: address review — backlog wording and class summary
Align E7M3-09 in-scope bullet with ops-only hook anchors; pluralize
FactionGateOperations telemetry remark; mark review findings done.
2026-06-17 19:27:27 -04:00
VinPropane cbd8444da3 NEO-141: add code review for faction telemetry hook sites 2026-06-17 19:26:54 -04:00
VinPropane 3c1ade4185 NEO-141: add manual QA and reconcile module docs
Update E7M3 backlog, alignment register, and implementation plan
for landed faction telemetry hook sites.
2026-06-17 19:25:27 -04:00
VinPropane 73403622f2 NEO-141: consolidate faction telemetry hook comment sites
Complete reputation_delta anchor in ReputationOperations, extend
faction_gate_blocked on both gate deny paths, remove router duplicate
stub, and add README Faction telemetry hooks subsection.
2026-06-17 19:25:25 -04:00
VinPropane 5ab81215ad NEO-141: add faction telemetry hook sites implementation plan
Kickoff plan for E7M3-09 — consolidate comment-only reputation_delta
and faction_gate_blocked anchors from NEO-136/137/138 per NEO-121/130 pattern.
2026-06-17 19:23:25 -04:00
VinPropane ef75111b2a NEO-140: remove server-only manual QA doc after story end
Manual QA checklists are for client-facing stories; Bruno and dotnet
test cover this HTTP projection slice.
2026-06-17 19:21:48 -04:00
VinPropane 960b4900d0
Merge pull request #180 from ViPro-Technologies/NEO-140-e7m3-quest-http-rep-gate-projections
NEO-140: Extend quest HTTP projections for rep + gates (E7M3-08)
2026-06-17 19:19:15 -04:00
VinPropane 7011a56755 NEO-140: address code review — grid-contract rep and wire-level gate test
Add integration coverage for +10 Rust Collective reputationGrants,
JsonDocument omission test for factionGateRules, extend accept Bruno,
fix manual QA link, strike through review findings.
2026-06-17 19:12:33 -04:00
VinPropane 3ba2fd100c NEO-140: add code review for quest HTTP rep + gate projections 2026-06-17 19:11:03 -04:00
VinPropane 1dedc5fc99 NEO-140: reconcile plan, manual QA, and E7.M3 module alignment
Mark acceptance criteria done; document Bruno/curl verification checklist.
2026-06-17 19:08:38 -04:00
VinPropane 2b4a0d1e34 NEO-140: project faction gates and rep grants on quest HTTP GETs
World quest-definitions expose optional factionGateRules; quest-progress
completionRewardSummary maps GrantedReputation to reputationGrants.
2026-06-17 19:08:37 -04:00
VinPropane 367f894b34 NEO-140: add E7M3-08 quest HTTP projection kickoff plan
Document factionGateRules world GET and completionRewardSummary
reputationGrants scope before implementation.
2026-06-17 19:05:54 -04:00
41 changed files with 2228 additions and 63 deletions

View File

@ -68,5 +68,16 @@ tests {
itemId: "survey_drone_kit",
quantity: 1,
});
expect(row.factionGateRules).to.eql([
{ factionId: "prototype_faction_grid_operators", minStanding: 15 },
]);
});
test("non-gated quests omit factionGateRules", function () {
const body = res.getBody();
const gatherIntro = body.quests.find((x) => x.id === "prototype_quest_gather_intro");
expect(gatherIntro.factionGateRules).to.equal(undefined);
const operatorChain = body.quests.find((x) => x.id === "prototype_quest_operator_chain");
expect(operatorChain.factionGateRules).to.equal(undefined);
});
}

View File

@ -38,5 +38,11 @@ tests {
expect(body.quest).to.be.an("object");
expect(body.quest.questId).to.equal("prototype_quest_grid_contract");
expect(body.quest.status).to.equal("completed");
expect(body.quest.completionRewardSummary).to.be.an("object");
expect(body.quest.completionRewardSummary.itemGrants).to.eql([]);
expect(body.quest.completionRewardSummary.skillXpGrants).to.eql([]);
expect(body.quest.completionRewardSummary.reputationGrants).to.eql([
{ factionId: "prototype_faction_rust_collective", amount: 10 },
]);
});
}

View File

@ -139,6 +139,7 @@ tests {
expect(row.completionRewardSummary.skillXpGrants).to.eql([
{ skillId: "salvage", amount: 25 },
]);
expect(row.completionRewardSummary.reputationGrants).to.eql([]);
});
test("second GET completionRewardSummary matches first GET", function () {

View File

@ -0,0 +1,67 @@
meta {
name: GET quest progress after operator chain complete
type: http
seq: 12
}
docs {
NEO-140: complete operator chain via HTTP quest flow, GET quest-progress asserts completionRewardSummary.reputationGrants (+15 Grid Operators).
Pre-request runs operator-chain-quest-flow-helper (gather/combat/refine intros + chain steps).
}
script:pre-request {
const { completeOperatorChainQuestFlow, CHAIN_QUEST_ID } = require("./scripts/operator-chain-quest-flow-helper");
await completeOperatorChainQuestFlow(bru);
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const response = await axios.get(
`${baseUrl}/game/players/${playerId}/quest-progress`,
{ validateStatus: () => true },
);
if (response.status !== 200) {
throw new Error(`quest-progress GET failed: ${response.status}`);
}
const row = response.data.quests.find((x) => x.questId === CHAIN_QUEST_ID);
if (!row || row.status !== "completed" || !row.completionRewardSummary) {
throw new Error(`expected operator chain completed with summary, got ${JSON.stringify(row)}`);
}
bru.setVar(
"firstOperatorChainCompletionRewardSummary",
JSON.stringify(row.completionRewardSummary),
);
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
body: none
auth: none
}
tests {
test("operator chain row is completed with reputationGrants in completionRewardSummary", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_operator_chain");
expect(row).to.be.an("object");
expect(row.status).to.equal("completed");
expect(row.completionRewardSummary).to.be.an("object");
expect(row.completionRewardSummary.itemGrants).to.eql([
{ itemId: "survey_drone_kit", quantity: 1 },
]);
expect(row.completionRewardSummary.skillXpGrants).to.eql([
{ skillId: "salvage", amount: 50 },
]);
expect(row.completionRewardSummary.reputationGrants).to.eql([
{ factionId: "prototype_faction_grid_operators", amount: 15 },
]);
});
test("second GET completionRewardSummary matches first GET", function () {
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_operator_chain");
const firstSummary = JSON.parse(bru.getVar("firstOperatorChainCompletionRewardSummary"));
expect(row.completionRewardSummary).to.eql(firstSummary);
});
}

View File

@ -251,6 +251,45 @@ 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 faction reputation loop (NEO-143)
Epic 7 Slice 3 capstone — complete the NEO-132 four-quest reward path **in Godot**, earn Grid Operators rep from operator chain, accept faction-gated **`prototype_quest_grid_contract`** once, and verify Rust Collective rep on hand-in.
**Flow:** fresh server restart → NEO-132 four-quest path → standing **15****Shift+Q** grid contract accept/complete → Rust Collective **+10** → Godot restart idempotency.
| Step | Input / trigger | HUD / server outcome |
|------|-----------------|----------------------|
| Boot | Godot **F5** + fresh server | Five quests **`not started`**; faction standing **0** / **0** |
| Operator chain | **Shift+Q** + chain objectives (NEO-132) | Operator Chain **`completed`**; **`Grid Operators: 15`**; reward label includes **`Grid Operators +15 rep`**; bag has **`survey_drone_kit`** |
| Grid contract | **Shift+Q** after operator chain | Accept succeeds at standing **15**; quest **`completed`** immediately (kit held); **`Rust Collective: 10`**; reward label **`Rust Collective +10 rep`** |
| Idempotency | Stop Godot + **F5** (server still running) | Five quests still **`completed`**; faction standing unchanged; reward label **`—`** |
| Duplicate accept | **Shift+Q** on finished quests | **`no eligible quest`**; standing unchanged |
**Gate deny (component QA):** readable **`faction_gate_blocked`** copy on **`QuestAcceptFeedbackLabel`** is verified in [NEO-142](../docs/manual-qa/NEO-142.md) — not the main capstone success path (operator chain grants **+15** atomically on completion).
**Cross-links:** [NEO-133](../docs/plans/NEO-133-implementation-plan.md) content freeze · [NEO-136](../docs/plans/NEO-136-implementation-plan.md)[NEO-138](../docs/plans/NEO-138-implementation-plan.md) rep apply · [NEO-139](../docs/plans/NEO-139-implementation-plan.md) standing GET · [NEO-140](../docs/plans/NEO-140-implementation-plan.md) gate/rep projections · [NEO-142](../docs/manual-qa/NEO-142.md) faction HUD · [NEO-132](../docs/manual-qa/NEO-132.md) reward capstone · [NEO-123](../docs/manual-qa/NEO-123.md) onboarding flow.
**Scripts:** `faction_standing_client.gd`, `quest_hud_controller.gd`, `quest_progress_client.gd`, `quest_definitions_client.gd` — wired from `main.gd`.
**Preconditions:** **Server restart** before capstone run resets quest progress, faction standing, reward delivery, inventory, and skill progression.
Full capstone checklist: [`docs/manual-qa/NEO-143.md`](../docs/manual-qa/NEO-143.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)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-08** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Status** | Ready**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). **E7M3-11 client capstone landed** ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)): playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** 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

@ -113,9 +113,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready |
**E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`NEO-132` manual QA](../../manual-qa/NEO-132.md), [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132), plan [NEO-132](../../plans/NEO-132-implementation-plan.md). Epic 7 Slice 2 client capstone complete.
| E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | In Progress |
| E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Ready |
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. Client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143).
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete.
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
### Epic 8 — Social / Guild

View File

@ -108,6 +108,7 @@ Follow [NEO-123](NEO-123.md) for accept order, anchors, material math, and quest
- Quest completion bundles are **idempotent** server-side (`{playerId}:quest_complete:{questId}`); Godot restart must not replay grant copy on **`QuestRewardDeliveryLabel`** (NEO-131 transition-only).
- Economy HUD auto-refreshes on quest completion transition (NEO-132); no manual **I** required for reward verification.
- Epic 7 Slice 2 AC: idempotent reward delivery; replays cannot double-claim ([epic_07 Slice 2](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing)).
- **Faction standing + grid contract:** defer to [NEO-143](NEO-143.md) Slice 3 capstone (extends this checklist through **`prototype_quest_grid_contract`**).
## Acceptance

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 [NEO-132](NEO-132.md) with end-to-end Slice 3 faction reputation verification (operator chain **+15** → grid contract **+10** → idempotency). Gate deny remains component QA here (step 6).

View File

@ -0,0 +1,118 @@
# NEO-143 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-143 |
| Title | E7M3-11: Playable faction reputation + gate capstone (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-143/e7m3-11-playable-faction-reputation-gate-capstone-godot |
| Plan | `docs/plans/NEO-143-implementation-plan.md` |
| Branch | `NEO-143-playable-faction-reputation-gate-capstone` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress, faction standing, **`IRewardDeliveryStore`**, inventory, skill progression, encounter progress, NPC HP, and resource-node depletion reset.
- **No Bruno/curl** for this checklist — accept uses **Q** / **Shift+Q**; gameplay uses **R**, **Tab** + **1**, and Economy HUD **Craft** buttons only.
- NEO-133NEO-142 faction spine and NEO-132 reward capstone landed on `main`.
- **`Economy HUD`** toggle **on** for inventory + skill verification.
## Faction + quest freeze (E7M3-01)
| Quest id | Display name | Rep grant on completion | Expected standing after |
|----------|--------------|-------------------------|-------------------------|
| `prototype_quest_operator_chain` | Operator Chain | **Grid Operators +15** (plus item/skill bundle — see NEO-132) | Grid Operators **15**, Rust Collective **0** |
| `prototype_quest_grid_contract` | Grid Contract | **Rust Collective +10** | Grid Operators **15**, Rust Collective **10** |
**Gate (component QA — not main capstone path):** accept **`prototype_quest_grid_contract`** at Grid Operators standing **0** with operator-chain prerequisite met ⇒ **`faction_gate_blocked`** with readable copy. Verified in [NEO-142](NEO-142.md) GdUnit (`client/test/faction_standing_hud_test.gd`) and optional dev fixture — organic Godot gameplay grants **+15** on operator-chain completion, so live capstone accept succeeds at standing **15**.
## Quest roster (five ids, catalog order)
| Quest id | Display name |
|----------|--------------|
| `prototype_quest_combat_intro` | Intro: Clear the Pocket |
| `prototype_quest_gather_intro` | Intro: Salvage Run |
| `prototype_quest_grid_contract` | Grid Contract |
| `prototype_quest_operator_chain` | Operator Chain |
| `prototype_quest_refine_intro` | Intro: Refine Stock |
**Shift+Q after operator chain:** only **`prototype_quest_grid_contract`** is eligible (**`not_started`**, prerequisite **`completed`**). Grid contract has one step — **`inventory_has_item`** **`survey_drone_kit` ×1** — satisfied from operator-chain reward; expect **`completed`** immediately on accept (no extra player action).
## Expected HUD progression
| Phase | `FactionStandingLabel` | `QuestProgressLabel` | `QuestRewardDeliveryLabel` |
|-------|------------------------|----------------------|----------------------------|
| Boot | Grid Operators **0**, Rust Collective **0** | Five quests **`not started`** | `Quest rewards:` / `—` |
| Operator chain **`completed`** | Grid Operators **15**, Rust Collective **0** | Four intros + operator chain **`completed`**; grid contract **`not started`** | Operator chain grants + **`Grid Operators +15 rep`** |
| Grid contract **`completed`** | Grid Operators **15**, Rust Collective **10** | All **five** **`completed`** | Grid Contract + **`Rust Collective +10 rep`** |
| Godot restart | Unchanged | All five still **`completed`** | `—` (transition-only) |
| Duplicate accepts | Unchanged | Unchanged | Unchanged |
## Checklist
Follow [NEO-132](NEO-132.md) steps **119** for the four-quest reward path (accept order, anchors, material math, reward + economy verification through operator chain). **Extend** with faction + fifth-quest steps below.
### Boot (faction baseline)
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`FactionStandingLabel`** shows **`Grid Operators: 0`**, **`Rust Collective: 0`**; **`QuestProgressLabel`** lists **five** quests all **`not started`**.
### Slice 2 reward path (inherit NEO-132)
3. Complete [NEO-132](NEO-132.md) steps **319** — four intros + operator chain with reward/economy checks at each completion.
### Faction standing after operator chain
4. Verify **`FactionStandingLabel`**: **`Grid Operators: 15`**, **`Rust Collective: 0`** — may lag reward label 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).
6. Verify **`InventoryLabel`** still lists **`survey_drone_kit`**.
7. Verify **`QuestProgressLabel`**: grid contract **`not started`**; four prior quests **`completed`**.
### Grid contract accept + complete (faction-gated success path)
8. Press **Shift+Q** to accept **`prototype_quest_grid_contract`**. Verify **`QuestAcceptFeedbackLabel`** shows accept success (not **`faction_gate_blocked`**).
9. Verify **`Grid Contract: completed`** on **`QuestProgressLabel`** (instant complete — kit already held).
10. Verify **`QuestRewardDeliveryLabel`**: `Grid Contract` + **`Rust Collective +10 rep`**.
11. Verify **`FactionStandingLabel`**: **`Rust Collective: 10`** (Grid Operators still **15**).
### Capstone snapshot (record for idempotency)
12. **Record** final state:
- **`FactionStandingLabel`**: Grid Operators **15**, Rust Collective **10**
- **`QuestProgressLabel`**: all **five** quests **`completed`**
- **`SkillProgressionLabel`** / **`InventoryLabel`**: match NEO-132 step 20 totals
### Idempotency (no duplicate rep delivery)
13. **Godot restart:** stop Godot (**Shift+F5**); **F5** again **without** stopping the server.
14. Verify all five quests still **`completed`**; **`QuestRewardDeliveryLabel`** is **`—`** (transition-only).
15. Verify **`FactionStandingLabel`** counts **match step 12** exactly.
16. Press **Shift+Q**. Verify **`Quest accept: no eligible quest`** (or equivalent deny).
17. Verify faction standing and economy HUD counts **still match step 12** — no duplicate rep grants.
### Regression
18. Component faction HUD + gate deny: [NEO-142 manual QA](NEO-142.md) (GdUnit gate deny; optional dev fixture).
19. Slice 2 reward idempotency: [NEO-132 manual QA](NEO-132.md).
20. Slice 1 onboarding chain: [NEO-123 manual QA](NEO-123.md).
### Server AC (optional — not Godot gameplay)
```bash
cd /path/to/neon-sprawl && dotnet test NeonSprawl.sln \
--filter "FullyQualifiedName~ReputationOperationsTests|FullyQualifiedName~FactionGateOperationsTests|FullyQualifiedName~RewardRouterOperationsTests"
```
Expect all pass — auditable **`ReputationDelta`** apply + fail-closed gate evaluation. Content CI (`scripts/validate_content.py`) and server startup catalog cross-ref deny unknown faction ids at load (tamper fail-closed).
## Notes
- **Gate deny** is not exercised on the main capstone success path — see [NEO-142](NEO-142.md) step 6.
- Rep grants apply on quest **completion**, not accept; faction standing refreshes after in-session completion transition (NEO-142).
- Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper ([epic_07 Slice 3](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production)).
## Acceptance
- [ ] Steps 117 completable in one session without Bruno/curl.
- [ ] Operator chain grants Grid Operators **+15**; grid contract grants Rust Collective **+10** — visible on HUD.
- [ ] Faction-gated grid contract accept succeeds at standing **15** and completes with kit in bag.
- [ ] Godot restart + duplicate accepts leave faction standing unchanged (no double-claim).
- [ ] Epic 7 Slice 3 AC satisfied in Godot (gate deny regression via NEO-142; server AC via automated tests).

View File

@ -296,7 +296,7 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o
**In scope**
- Comment-only **`reputation_delta`** in **`ReputationOperations`** (successful apply).
- Comment-only **`faction_gate_blocked`** in **`FactionGateOperations`** / **`TryAccept`** deny path.
- Comment-only **`faction_gate_blocked`** in **`FactionGateOperations.TryEvaluate`** (threshold + standing read failure deny paths).
- `server/README.md` hook list.
**Out of scope**
@ -305,7 +305,9 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o
**Acceptance criteria**
- [ ] Hook sites documented in README; no behavior change beyond comments.
- [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).
**Client counterpart:** none (infrastructure-only).
@ -330,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).
@ -354,8 +358,8 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o
**Acceptance criteria**
- [ ] Human completes **`docs/manual-qa/NEO-143.md`** with server + client.
- [ ] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (unknown faction denied at startup).
- [ ] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production).
- [x] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (unknown faction denied at startup) — server automated tests pass; gate deny component QA in [NEO-142](../manual-qa/NEO-142.md).
- [x] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production).
**Client counterpart:** this story **is** the Slice 3 client capstone.

View File

@ -0,0 +1,179 @@
# NEO-140 — E7M3-08: Extend quest HTTP projections for rep + gates
**Linear:** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)
**Branch:** `NEO-140-e7m3-quest-http-rep-gate-projections`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-08**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-129](NEO-129-implementation-plan.md) — `completionRewardSummary` nested grant lines from `RewardDeliveryEvent`; [NEO-115](NEO-115-implementation-plan.md) — world GET definition projection
**Precursors:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) **`Done`** — `RewardDeliveryEvent.GrantedReputation` at commit time; [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) **`Done`** — standing GET for client cross-check
**Blocks:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) (Godot HUD gate + rep reward labels)
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — parses world **`factionGateRules`** and quest-progress **`completionRewardSummary.reputationGrants`**; blocked until this story lands
## Goal
Client can see gate requirements and completion rep summary without local math.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-08 backlog scope](E7M3-pre-production-backlog.md#e7m3-08--extend-quest-http-projections-for-rep-grants--gate-rules), and NEO-129/NEO-138 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| **`factionGateRules` JSON shape** | **`{ factionId, minStanding }`** per row — flat mirror of content schema | `content/schemas/faction-gate-rule.schema.json`; `FactionGateRuleRow` |
| **`factionGateRules` omission** | **Omit property** when quest has no rules (`JsonIgnore WhenWritingNull`) | Backlog “optional projection”; NEO-115 objective optional-field precedent |
| **`factionGateRules` order** | **Preserve catalog load order** from `QuestDefRow.FactionGateRules` | Same as prerequisite ids / step order in world GET |
| **Faction display names on gates** | **Not projected**`factionId` only | NEO-139 deferred `GET /game/world/faction-definitions`; client joins standing GET or future catalog |
| **`reputationGrants` in summary** | **Required array** on `completionRewardSummary` when summary is present; **empty `[]`** when delivery had no rep rows | NEO-129: `itemGrants` / `skillXpGrants` always present when summary exists |
| **Rep line field names** | **`factionId`**, **`amount`** — mirror content `reputation-grant-row.schema.json` and `RewardReputationGrantApplied` | NEO-138 store snapshot; content schema |
| **Rep line source** | **`RewardDeliveryEvent.GrantedReputation`** at commit time (not live standing recompute) | NEO-129 maps delivery event, not bundle definition |
| **Rep line order** | **Preserve commit-time grant order** from delivery event | `MapItemGrants` / `MapSkillXpGrants` XML doc precedent |
| **Schema version** | **Stay at 1** for both world quest-definitions and quest-progress | Additive optional fields; no breaking rename |
| **`QuestAcceptApi`** | **No separate accept-specific changes** — shared `QuestProgressApi.MapQuestProgressRow` / `MapCompletionRewardSummary` picks up rep lines automatically | Backlog “if applicable”; accept embeds progress row via existing mapper |
| **Bruno scope** | **Extend** `quest-definitions/Get quest definitions.bru` (grid-contract gate row) + **add** quest-progress GET bru asserting operator-chain **`reputationGrants`** (+15 Grid Operators) | Backlog “Bruno assertions on grid-contract quest row”; closes NEO-138 deferral on summary shape |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Extend **`GET /game/world/quest-definitions`** with optional **`factionGateRules`** per quest.
- Extend **`GET …/quest-progress`** **`completionRewardSummary`** with **`reputationGrants`** lines (mirror items/skill XP).
- **`QuestAcceptApi`** embedded quest rows inherit updated mapper (no extra accept-only DTO work).
- Integration tests + Bruno assertions on **`prototype_quest_grid_contract`** gate row and operator-chain rep summary.
- `server/README.md` quest + faction sections.
**Out of scope:**
- Godot parse/HUD (NEO-142), telemetry ingest (NEO-141).
- **`GET /game/world/faction-definitions`** (no backlog item).
- Player-specific gate pass/fail on world GET (gates evaluated only on accept; standing via NEO-139 GET).
- Enriching accept deny responses with failing rule detail (NEO-137 adopted: `reasonCode` only).
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — standing label + gate feedback + reward label rep lines after NEO-139/NEO-140.
## Acceptance criteria checklist
- [x] Gated quest definition includes **`factionGateRules`** in world GET.
- [x] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered.
- [x] `dotnet test` covers grid-contract gate projection and operator-chain rep summary.
- [x] Bruno asserts grid-contract **`factionGateRules`** and operator-chain **`reputationGrants`**.
## Implementation reconciliation (shipped)
- **World GET:** `QuestDefinitionsWorldApi` maps `QuestDefRow.FactionGateRules` → optional `factionGateRules` on `QuestDefinitionJson` (`QuestFactionGateRuleJson`: `factionId`, `minStanding`); omitted when empty.
- **Quest progress GET:** `QuestProgressApi.MapCompletionRewardSummary` maps `RewardDeliveryEvent.GrantedReputation``reputationGrants` (`QuestReputationGrantJson`: `factionId`, `amount`); required array when summary present.
- **Accept POST:** inherits updated mapper via `QuestProgressApi.MapQuestProgressRow` — no accept-specific changes.
- **Tests:** `QuestDefinitionsWorldApiTests` grid-contract gate row + wire-level `factionGateRules` omission; `QuestProgressApiTests` operator-chain +15 rep, grid-contract +10 rep, gather-intro empty rep; **823** tests green.
- **Manual QA:** none — server-only HTTP projections; Bruno + `dotnet test` cover verification (manual QA checklists reserved for client-facing stories per project convention).
## Technical approach
### 1. World quest definitions — `factionGateRules`
**DTOs** (`QuestDefinitionsListDtos.cs`):
| Type | JSON | Notes |
|------|------|-------|
| **`QuestFactionGateRuleJson`** | `factionId`, `minStanding` | New grant-line type |
| **`QuestDefinitionJson`** | optional **`factionGateRules`** | `JsonIgnore(WhenWritingNull)`; non-null when `QuestDefRow.FactionGateRules.Count > 0` |
**Mapper** (`QuestDefinitionsWorldApi.cs`):
- When mapping each `QuestDefRow`, if `d.FactionGateRules.Count > 0`, set `FactionGateRules` to mapped list; else leave null (omitted in JSON).
- Preserve rule order from catalog.
**Prototype assertion target:**
```json
{
"id": "prototype_quest_grid_contract",
"factionGateRules": [
{ "factionId": "prototype_faction_grid_operators", "minStanding": 15 }
]
}
```
Non-gated quests (gather intro, operator chain, etc.) omit **`factionGateRules`**.
### 2. Quest progress — `completionRewardSummary.reputationGrants`
**DTOs** (`QuestProgressListDtos.cs`):
| Type | JSON | Notes |
|------|------|-------|
| **`QuestReputationGrantJson`** | `factionId`, `amount` | New line type |
| **`QuestCompletionRewardSummaryJson`** | add **`reputationGrants`** | Required when summary present; empty array allowed |
**Mapper** (`QuestProgressApi.cs`):
- Extend `MapCompletionRewardSummary` to map `deliveryEvent.GrantedReputation``reputationGrants` via new `MapReputationGrants` helper (commit-time order preserved).
- `QuestAcceptApi.MapResponse` unchanged — already calls `QuestProgressApi.MapQuestProgressRow`.
**Prototype assertion targets:**
| Quest | `reputationGrants` when completed |
|-------|----------------------------------|
| **`prototype_quest_gather_intro`** | `[]` |
| **`prototype_quest_operator_chain`** | `[{ factionId: prototype_faction_grid_operators, amount: 15 }]` |
| **`prototype_quest_grid_contract`** | `[{ factionId: prototype_faction_rust_collective, amount: 10 }]` (after hand-in flow lands in tests/Bruno) |
### 3. Docs
- **`server/README.md`**: extend quest-definitions table with **`factionGateRules`**; extend quest-progress **`completionRewardSummary`** table with **`reputationGrants`** source (`GrantedReputation`).
- On ship: update [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) status line and [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row.
### 4. Flow (read paths only)
```mermaid
sequenceDiagram
participant Client
participant WorldAPI as QuestDefinitionsWorldApi
participant ProgressAPI as QuestProgressApi
participant Registry as IQuestDefinitionRegistry
participant Delivery as IRewardDeliveryStore
Client->>WorldAPI: GET /game/world/quest-definitions
WorldAPI->>Registry: GetDefinitionsInIdOrder()
WorldAPI-->>Client: quests[] (+ factionGateRules on gated rows)
Client->>ProgressAPI: GET /game/players/{id}/quest-progress
ProgressAPI->>Delivery: TryGet per completed quest
ProgressAPI-->>Client: quests[] (+ completionRewardSummary.reputationGrants)
```
## Files to add
| Path | Rationale |
|------|-----------|
| `bruno/neon-sprawl-server/quest-progress/Get quest progress after operator chain complete.bru` | Bruno spine asserting operator-chain **`completionRewardSummary.reputationGrants`** (+15 Grid Operators); mirrors gather-intro complete bru pattern |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs` | Add `QuestFactionGateRuleJson`; optional `factionGateRules` on `QuestDefinitionJson` |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs` | Map `QuestDefRow.FactionGateRules` into world GET JSON |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Add `QuestReputationGrantJson`; extend `QuestCompletionRewardSummaryJson` |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Map `GrantedReputation``reputationGrants` in `MapCompletionRewardSummary` |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs` | Assert grid-contract `factionGateRules`; assert non-gated quests omit property |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Extend operator-chain summary helper (+15 rep); extend equality helper; optional grid-contract rep assertion |
| `bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru` | Assert `prototype_quest_grid_contract.factionGateRules` freeze row |
| `server/README.md` | Document new projection fields on both GET routes |
## Tests
| File | Coverage |
|------|----------|
| `QuestDefinitionsWorldApiTests.cs` | **Add/change:** grid-contract row includes `[{ factionId: prototype_faction_grid_operators, minStanding: 15 }]`; gather intro row has no `factionGateRules` in deserialized model (null / omitted) |
| `QuestProgressApiTests.cs` | **Change:** `AssertOperatorChainCompletionRewardSummary` expects `reputationGrants` +15 Grid Operators; `AssertCompletionRewardSummariesEqual` compares rep lines; gather-intro summary asserts empty `reputationGrants` |
| `bruno/.../quest-definitions/Get quest definitions.bru` | Grid-contract gate rule assertion |
| `bruno/.../quest-progress/Get quest progress after operator chain complete.bru` | **New** — operator-chain complete → GET asserts rep grant line |
Manual verification: run Bruno quest-definitions + new quest-progress bru against local server after operator-chain flow helper.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Existing clients ignore unknown `reputationGrants` field | **Additive field only**`schemaVersion` stays 1; NEO-142 is first consumer | `adopted` |
| Grid-contract completion rep Bruno needs full hand-in flow | ~~**Operator-chain Bruno first** (primary AC); grid-contract rep assertion in integration test using existing quest wiring helpers~~ | `adopted`**done:** integration test + accept bru assert +10 Rust Collective |
| `QuestDefinitionsWorldApiTests` missing grid-contract row coverage today | **Add grid-contract gate assertions** in same test method | `adopted` |

View File

@ -0,0 +1,167 @@
# NEO-141 — E7M3-09: Faction telemetry hook sites (comment-only)
**Linear:** [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)
**Branch:** `NEO-141-faction-telemetry-hooks`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-09**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-121](NEO-121-implementation-plan.md) / [NEO-130](NEO-130-implementation-plan.md) — comment-only engine anchors + `TODO(E9.M1)`; engine-only (no duplicate API-layer hooks)
**Precursors:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) **`Done`** — `ReputationOperations.TryApplyDelta` + partial `reputation_delta` hook comment; [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) **`Done`** — `FactionGateOperations.TryEvaluate` + `faction_gate_blocked` hook comment; [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) **`Done`** — router rep apply + duplicate router stub comment (to consolidate here)
**Blocks:** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) (E7M3-11 client capstone — module telemetry vocabulary complete)
**Client counterpart:** none (infrastructure-only per [E7M3-09](E7M3-pre-production-backlog.md#e7m3-09--faction-telemetry-hook-sites-comment-only))
## Goal
Anchor future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_blocked`** on the server-authoritative faction path without ingest or behavior change.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-09 backlog scope](E7M3-pre-production-backlog.md#e7m3-09--faction-telemetry-hook-sites-comment-only), and NEO-121/130 comment-only telemetry precedents settle scope. Partial hook comments landed in NEO-136/137/138; this story **consolidates and documents** them.
| Topic | Decision | Evidence |
|-------|----------|----------|
| **Runtime behavior** | Comments-only + `TODO(E9.M1)` — no ingest, no `ILogger` | NEO-121/130 precedent |
| **`reputation_delta` anchor** | **`ReputationOperations.TryApplyDelta`** only — after successful audit append | Backlog E7M3-09; NEO-121 engine-only precedent |
| **Router duplicate stub** | **Remove** `reputation_delta` comment from **`RewardRouterOperations`**; cross-link in README | NEO-130 reward table already notes “consolidated in NEO-141”; single authoritative ops layer |
| **`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** | **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
**In scope (from Linear + backlog):**
- Complete comment-only **`reputation_delta`** hook in **`ReputationOperations`** (successful apply path — add `TODO(E9.M1)` + planned payload fields).
- Confirm / extend comment-only **`faction_gate_blocked`** in **`FactionGateOperations`** (threshold deny + standing read failure deny).
- 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.
**Out of scope:**
- E9.M1 ingest pipeline; runtime telemetry emit.
- `ILogger` / metrics / dev-only log lines.
- Duplicate hooks in **`QuestAcceptApi`**, **`QuestProgressApi`**, **`FactionStandingApi`**, or HTTP routes.
- Godot client (NEO-142/143).
**Client counterpart:** none.
## Acceptance criteria checklist
- [x] Hook sites documented in README; no behavior change beyond comments.
## Implementation reconciliation (shipped)
- **`ReputationOperations.TryApplyDelta`:** complete **`reputation_delta`** comment block after audit append success (`TODO(E9.M1)` + planned payload fields).
- **`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:** none — comment-only server story; `dotnet test` covers regression (manual QA checklists reserved for user-visible stories per project convention).
## Technical approach
### 1. Current state (precursor partial work)
| Location | State today | NEO-141 action |
|----------|-------------|----------------|
| **`ReputationOperations.TryApplyDelta`** | One-line hook comment after audit append; **missing** `TODO(E9.M1)` + payload | **Complete** comment block |
| **`FactionGateOperations.TryEvaluate`** | Full hook on threshold deny (`standing < minStanding`); read-failure deny **without** hook | **Add** hook on read-failure deny with ops `reasonCode` in payload note |
| **`RewardRouterOperations`** | Duplicate `reputation_delta` stub after each rep row apply | **Remove** stub; router delegates to ops |
| **`server/README.md`** | `reputation_delta` only as NEO-130 stub row | **Add** NEO-141 subsection; **update** NEO-130 table |
### 2. Hook site A — `reputation_delta`
**Anchor:** **`ReputationOperations.TryApplyDelta`**, immediately after **`auditStore.TryAppend(row)`** returns **`true`**.
- Comment block names future E9.M1 event **`reputation_delta`**.
- **`TODO(E9.M1): catalog emit`** — once per successful auditable apply (standing mutation + audit row committed).
- **Not** on: zero delta deny, standing store deny, audit append failure + compensating standing revert, or caller rollback paths.
- Planned payload fields from **`ReputationDeltaRow`**: `playerId`, `factionId`, `appliedDelta`, `newStanding`, `sourceKind`, `sourceId`, `deltaId`, `appliedAt`.
- Callers include **`RewardRouterOperations`** (quest completion), future admin tools — all funnel through this ops layer.
### 3. Hook site B — `faction_gate_blocked`
**Anchor:** **`FactionGateOperations.TryEvaluate`** deny returns only.
**Branch B1 — threshold fail** (`read.Standing < rule.MinStanding`): hook comment **already present** verify `TODO(E9.M1)` + payload fields match NEO-121 style.
**Branch B2 — standing read fail** (`!read.Success`): **add** parallel hook comment before deny return.
- Same event name **`faction_gate_blocked`**; planned payload includes ops **`reasonCode`** (`unknown_faction`, etc.), `playerId`, `factionId`, `minStanding`, optional `currentStanding` (null when unknown).
- **`QuestStateOperations.TryAccept`** maps all gate failures to HTTP **`faction_gate_blocked`** regardless of ops reason — E9.M1 can use ops payload for drill-down.
- Optional quest context (`questId`) deferred to E9.M1 wiring at **`TryAccept`** call site if needed — not duplicated at quest layer in prototype.
### 4. README consolidation
Add **`### Faction telemetry hooks (NEO-141)`** after [FactionGateOperations (NEO-137)](../../server/README.md#factiongateoperations-neo-137):
| Event | Anchor | When |
|-------|--------|------|
| **`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). |
Update [Reward telemetry hooks (NEO-130)](../../server/README.md#reward-telemetry-hooks-neo-130): remove **`reputation_delta`** stub row; add one-line pointer to NEO-141 (quest rep grants emit via ops, not router duplicate).
### 5. Relationship to adjacent hooks
| Event | Layer | Story |
|-------|-------|-------|
| **`quest_accept_denied`** | **`QuestStateOperations.TryAccept`** | NEO-121 (generic accept deny — includes gate denies at HTTP level) |
| **`faction_gate_blocked`** | **`FactionGateOperations.TryEvaluate`** | NEO-141 (faction-specific gate eval detail) |
| **`reward_delivery`** | **`RewardRouterOperations`** | NEO-130 |
| **`reputation_delta`** | **`ReputationOperations.TryApplyDelta`** | NEO-141 |
Gate deny on accept may correlate **`quest_accept_denied`** (if added for gate path) vs **`faction_gate_blocked`** — E9.M1 can sample both or dedupe by correlation id.
### 6. Flow (comment anchors only)
```mermaid
sequenceDiagram
participant Accept as QuestStateOperations.TryAccept
participant Gate as FactionGateOperations
participant Router as RewardRouterOperations
participant Rep as ReputationOperations
Accept->>Gate: TryEvaluate(rules)
alt gate deny
Gate-->>Accept: fail (hook: faction_gate_blocked)
else gate pass
Accept->>Accept: TryActivate (hook: quest_start NEO-121)
end
Note over Router,Rep: quest completion path
Router->>Rep: TryApplyDelta per rep grant
Rep-->>Router: success (hook: reputation_delta)
Router->>Router: TryRecord delivery (hook: reward_delivery NEO-130)
```
## Files to add
None — comment-only changes to existing ops files and docs; no new source or test files.
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs` | Complete `reputation_delta` hook block (`TODO(E9.M1)` + planned payload fields) after audit append success |
| `server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs` | Add `faction_gate_blocked` hook on standing read-failure deny; verify threshold branch comment completeness |
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | Remove duplicate `reputation_delta` stub comment (consolidate to ops layer) |
| `server/README.md` | Add **Faction telemetry hooks (NEO-141)** subsection; update NEO-130 reward table cross-link |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-09 NEO-141 telemetry hooks landed |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M3 row: E7M3-09 hook sites |
| `docs/plans/E7M3-pre-production-backlog.md` | E7M3-09 checkboxes + landed note |
## Tests
| 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). |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Duplicate `reputation_delta` if both router and ops had hooks | **Remove router stub** — single ops anchor | `adopted` |
| Gate deny emits both `quest_accept_denied` and `faction_gate_blocked` | **Expected** — different granularity; E9.M1 correlates by time/player/quest | `adopted` |
| `unknown_faction` on gate eval in prototype | Rare (catalog-validated rules); still document hook on read-failure deny for E9.M1 payload fidelity | `adopted` |
| E7.M3 module Ready vs In Progress | Leave **In Progress** until client capstone NEO-143; only note E7M3-09 complete in register | `adopted` |

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

@ -0,0 +1,164 @@
# NEO-143 — E7M3-11: Playable faction reputation + gate capstone (Godot)
**Linear:** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)
**Branch:** `NEO-143-playable-faction-reputation-gate-capstone`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-11**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-132](NEO-132-implementation-plan.md) — Slice 2 capstone docs-primary; [NEO-142](NEO-142-implementation-plan.md) — faction HUD component landed
**Precursors:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) **`Done`** — standing label, gate deny copy, rep reward lines; [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)[NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) server faction spine **landed**
**Server counterpart:** NEO-133NEO-141 authoritative faction/rep/gate stack; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md)
## Goal
Prove Epic 7 Slice 3 acceptance **in Godot**: earn rep from operator chain, accept gated faction quest once; gates fail closed.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|----------------------|--------|
| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; fix integration gaps only if capstone QA fails on `main` — [NEO-132](NEO-132-implementation-plan.md) precedent; NEO-142 HUD already landed. | **Adopted** — docs-primary |
| **Capstone path** | Extend NEO-132 full flow vs shorter faction-only path? | **Extend full [NEO-132](NEO-132-implementation-plan.md) four-quest flow** + fifth quest **`prototype_quest_grid_contract`** accept/complete — backlog E7M3-11 explicit session; grid contract auto-completes on accept when **`survey_drone_kit`** is already in bag. | **Adopted** — extend NEO-132 |
| **Gate deny verification** | How to satisfy backlog “retry accept gate before rep (denied)” when operator chain grants **+15** atomically? | **Regression to [NEO-142](NEO-142-implementation-plan.md)** (GdUnit + optional dev fixture); capstone main checklist is Godot **success path** only. | **Adopted** — NEO-142 regression |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **Session baseline:** fresh **server restart** before Godot **F5** — resets quest progress, faction standing, reward delivery, inventory, skills, encounter state, and resource nodes; **no Bruno/curl** in main capstone checklist.
- **Economy HUD:** toggle **on** for inventory + skill verification (NEO-132 precedent).
- **Five-quest HUD:** **`QuestProgressLabel`** iterates cached quest-definitions catalog — **`prototype_quest_grid_contract`** appears automatically (no hardcoded four-quest list).
- **Shift+Q after operator chain:** catalog order (`id` ordinal) picks **`prototype_quest_grid_contract`** first among eligible **`not_started`** quests (prerequisite operator chain **`completed`**).
- **Server Slice 3 AC** (auditable deltas; tamper fail-closed): verified via **automated regression** reference in manual QA — **`ReputationOperationsTests`**, content CI gates, startup catalog cross-ref — not re-proven in Godot gameplay (NEO-130 / NEO-135 precedent).
## Scope and out-of-scope
**In scope (from Linear + [E7M3-11](E7M3-pre-production-backlog.md#e7m3-11--playable-faction-reputation--gate-capstone-godot)):**
- **`docs/manual-qa/NEO-143.md`**: numbered **single-session** capstone — extend [NEO-132](../manual-qa/NEO-132.md) four-quest + reward flow through faction standing **15****`prototype_quest_grid_contract`** accept/complete → Rust Collective **+10**; Godot restart idempotency for faction standing + quest statuses.
- **`client/README.md`**: **End-to-end faction reputation loop (NEO-143)** section — integration flow table; cross-links NEO-133NEO-142 and Slice 1/2 capstones.
- **Module alignment** (on story completion): [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) status **Ready**, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row, [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M3 note, [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) E7M3-11 checkboxes; Epic 7 Slice 3 client capstone complete.
- **Integration fixes only if capstone QA fails** on current `main` wiring (faction standing stale, grid-contract accept/complete gap, reward label missing Rust Collective line, etc.).
**Out of scope (from Linear + backlog):**
- Contract generator (E7.M4); zone travel gates (E4.M1); Bruno-only proof.
- New server HTTP routes, store logic, or gate evaluation changes.
- Live gate-deny step in main capstone checklist (deferred to NEO-142 component QA).
- **`GET /game/world/faction-definitions`** client.
**Client counterpart:** this story **is** the Slice 3 client capstone.
## Acceptance criteria checklist
- [ ] Human completes **`docs/manual-qa/NEO-143.md`** with server + client.
- [x] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (server tests + NEO-142 gate deny regression).
- [x] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production).
## Implementation reconciliation (shipped)
- **`docs/manual-qa/NEO-143.md`** — five-quest capstone extending NEO-132 with faction standing **15** → grid contract **+10** → idempotency.
- **`client/README.md`** — **End-to-end faction reputation loop (NEO-143)** section with flow table and cross-links.
- **`docs/manual-qa/NEO-142.md`**, **`docs/manual-qa/NEO-132.md`** — cross-links to NEO-143 Slice 3 capstone.
- **`docs/plans/E7M3-pre-production-backlog.md`**, **`E7_M3_FactionReputationLedger.md`**, **`documentation_and_implementation_alignment.md`**, **`module_dependency_register.md`** — E7M3-11 landed; E7.M3 **Ready**; Epic 7 Slice 3 client capstone complete.
- **Server regression:** 36 faction-related tests pass (`ReputationOperationsTests`, `FactionGateOperationsTests`, `RewardRouterOperationsTests`).
- **No client code changes** — capstone docs-primary; NEO-142 HUD wiring sufficient on `main`.
## Technical approach
### 1. Faction + quest freeze reference (E7M3-01)
| Milestone | Quest / action | Rep / standing outcome | Expected HUD |
|-----------|----------------|------------------------|--------------|
| Operator chain **`completed`** | `completionRewardBundle.reputationGrants` | **Grid Operators +15** | **`FactionStandingLabel`**: `Grid Operators: 15`; **`QuestRewardDeliveryLabel`**: `Grid Operators +15 rep` |
| Grid contract accept + complete | `inventory_has_item` **`survey_drone_kit` ×1** (held from operator chain reward) | **Rust Collective +10** on completion | **`FactionStandingLabel`**: `Rust Collective: 10`; **`QuestRewardDeliveryLabel`**: `Rust Collective +10 rep` |
| Gate (component) | Accept grid contract at standing **0** with operator-chain prereq met | **`faction_gate_blocked`** | Readable deny on **`QuestAcceptFeedbackLabel`** — verified in [NEO-142](../manual-qa/NEO-142.md) GdUnit, not main capstone |
**Final standing snapshot (one session):** Grid Operators **15**, Rust Collective **10**.
**Quest roster (five ids, catalog order):** combat intro → gather intro → **grid contract** → operator chain → refine intro.
### 2. Capstone manual QA script (`docs/manual-qa/NEO-143.md`)
**Preconditions:** Same as [NEO-132](../manual-qa/NEO-132.md) — fresh server restart, Godot gameplay only, **`Economy HUD`** on, NEO-142 faction HUD landed on `main`.
**Flow:** Inherit [NEO-132](../manual-qa/NEO-132.md) steps **119** (four quests + reward/economy verification through operator chain). **Extend** with faction + fifth-quest steps:
1. After operator chain completion — assert **`FactionStandingLabel`** **`Grid Operators: 15`**, **`Rust Collective: 0`**; reward label includes **`Grid Operators +15 rep`**.
2. **`Shift+Q`** accept **`prototype_quest_grid_contract`** — accept succeeds; quest **`completed`** (kit already in bag; **`inventory_has_item`** wiring on accept).
3. Assert **`QuestRewardDeliveryLabel`**: `Grid Contract` + **`Rust Collective +10 rep`**.
4. Assert **`FactionStandingLabel`**: **`Rust Collective: 10`** (Grid Operators still **15**).
5. Assert **`QuestProgressLabel`**: all **five** quests **`completed`**.
6. **Capstone snapshot** — record final faction standing + five-quest statuses.
7. **Idempotency** — Godot restart (server still running): five quests still **`completed`**; faction standing unchanged; reward label **`—`**; duplicate **Shift+Q** deny; standing counts unchanged.
8. **Regression pointers:** [NEO-142](../manual-qa/NEO-142.md) gate deny (GdUnit + optional fixture); [NEO-132](../manual-qa/NEO-132.md) reward idempotency; [NEO-123](../manual-qa/NEO-123.md) onboarding chain.
**Server AC (non-Godot):** optional regression subsection — `dotnet test` filters for **`ReputationOperationsTests`**, **`FactionGateOperationsTests`**, **`RewardRouterOperationsTests`** (rep grants); content **`validate_content.py`** / startup fail-fast for unknown faction cross-refs.
### 3. Client README (`client/README.md`)
Add **End-to-end faction reputation loop (NEO-143)** section after [Faction standing + gate feedback HUD (NEO-142)](../../client/README.md#faction-standing--gate-feedback-hud-neo-142), mirroring NEO-132 capstone section:
- One-line Epic 7 Slice 3 capstone goal.
- Flow table: fresh restart → NEO-132 four-quest path → standing **15** → grid contract accept/complete → Rust Collective **+10** → idempotency.
- Cross-links: NEO-133 content freeze, NEO-136NEO-138 rep apply, NEO-139 standing GET, NEO-140 gate/rep projections, NEO-142 HUD, NEO-132 reward capstone.
- Pointer to **`docs/manual-qa/NEO-143.md`**.
- Note: gate deny proof lives in NEO-142 component QA.
### 4. Module / backlog alignment (on land)
When capstone QA passes:
- [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — E7M3-11 AC checkboxes.
- [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) — module status **Ready**; Slice 3 client capstone note.
- [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) — E7.M3 row: NEO-143 landed; Epic 7 Slice 3 client capstone complete.
- [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) — E7.M3 **Ready** when capstone lands.
### 5. Integration fixes (conditional)
Run capstone QA on `main` (NEO-142 merged). If any step fails:
| Symptom | Likely fix surface |
|---------|-------------------|
| **`FactionStandingLabel`** stale after grid contract complete | `quest_hud_controller.gd` — faction GET on completion transition (NEO-142 wiring) |
| Grid contract accept denied at standing **15** | Server gate/standing bug — unlikely if NEO-138 Bruno + tests pass |
| Grid contract stays **`active`** after accept with kit in bag | Server **`inventory_has_item`** wiring on accept — file server issue |
| **`QuestRewardDeliveryLabel`** missing Rust Collective line | `quest_hud_controller.gd` rep formatter — unlikely if NEO-142 GdUnit passes |
| Five-quest progress label missing grid contract row | `quest_definitions_client.gd` cache / boot GET — unlikely |
Document any fix in **Implementation reconciliation** and add regression note to manual QA.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-143-implementation-plan.md` | This plan. |
| `docs/manual-qa/NEO-143.md` | Single-session five-quest faction capstone extending NEO-132 with standing + grid-contract verification + idempotency. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/README.md` | **End-to-end faction reputation loop (NEO-143)** section with flow table and cross-links. |
| `docs/manual-qa/NEO-142.md` | Cross-link to NEO-143 as full-flow Slice 3 capstone superset. |
| `docs/manual-qa/NEO-132.md` | Cross-link to NEO-143 faction extension; note standing/grid-contract verification defers to NEO-143. |
| `docs/plans/E7M3-pre-production-backlog.md` | E7M3-11 AC checkboxes when capstone lands. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Module status **Ready** + capstone landed note on completion. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M3 row: NEO-143 landed; Slice 3 complete. |
| `docs/decomposition/modules/module_dependency_register.md` | E7.M3 **Ready** when capstone lands. |
**Conditional (only if capstone QA fails):** `client/scripts/quest_hud_controller.gd` or `client/scripts/main.gd` — minimal gap-fill; document paths in reconciliation.
## Tests
| Test file | What it covers |
|-----------|----------------|
| *(client unchanged expected)* | NEO-142 GdUnit (`faction_standing_client_test.gd`, `faction_standing_hud_test.gd`) covers standing parse, gate deny copy, rep reward lines. Capstone proof is **`docs/manual-qa/NEO-143.md`** human QA. |
| *(server unchanged)* | Faction spine covered by **`ReputationOperationsTests`**, **`FactionGateOperationsTests`**, **`RewardRouterOperationsTests`**, **`QuestAcceptApiTests`**, content **`validate_content.py`**. Optional regression command in manual QA notes. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Gate deny in capstone vs NEO-142** | Main NEO-143 checklist is success path; deny proof stays NEO-142 component QA (user adopted). | **adopted** |
| **Grid contract instant complete on accept** | Document expected behavior — **`inventory_has_item`** evaluated on **`TryAccept`**; no extra player action after **Shift+Q**. | **adopted** |
| **E7.M3 Ready timing** | Bump module + register to **Ready** only when NEO-143 capstone QA passes (NEO-141 deferred this). | **adopted** |
| **Five-quest Shift+Q ordering** | After operator chain, only grid contract is eligible **`not_started`** — no catalog-order ambiguity. | **adopted** |
| **Integration gaps on `main`** | Run capstone QA before doc-only land; expect pass with NEO-142 merged. | **adopted** — no client code changes; server tests pass |

View File

@ -0,0 +1,57 @@
# Code review — NEO-140 (E7M3-08)
**Date:** 2026-06-17
**Scope:** Branch `NEO-140-e7m3-quest-http-rep-gate-projections` — commits `367f894``1dedc5f` vs `main`
**Base:** `main`
**Follow-up:** Suggestions and actionable nits below are **done** (strikethrough + **Done.** / **Addressed.** / **Deferred**).
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-08**: additive HTTP projections so clients can read faction gate rules on world quest definitions and reputation grant lines on quest-progress `completionRewardSummary`, without local standing math. Implementation mirrors **NEO-129** / **NEO-115** patterns — optional `factionGateRules` on world GET (omitted when empty), required `reputationGrants` array when a completion summary exists (sourced from `RewardDeliveryEvent.GrantedReputation` at commit time). Shared mappers mean **QuestAcceptApi** embedded rows pick up rep lines automatically. Integration tests extend existing quest API suites; Bruno adds operator-chain completion assertions and extends quest-definitions / gather-intro bru. **821** server tests pass locally. Server-only story; Godot HUD remains **NEO-142**. Low merge risk — read-only projections, schema version stays **1**.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-140-implementation-plan.md` | **Matches** — kickoff decisions, AC checklist, reconciliation section, and file list align with the shipped diff. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-08 server scope; client counterpart NEO-142 explicitly deferred. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-08 landed (NEO-140). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated with quest HTTP projection 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-140.md` | **Removed post-merge** — server-only story; manual QA reserved for client-facing work (story-end decision). |
| `server/README.md` | **Matches** — Quest-definitions and quest-progress sections document new fields with NEO-140 cross-links. |
## Blocking issues
None.
## Suggestions
1. ~~**Grid-contract completion rep (+10 Rust Collective) still untested end-to-end.** Plan §prototype targets and open-questions table adopt deferring grid-contract `reputationGrants` Bruno until a hand-in flow helper exists; operator-chain coverage is the primary AC and is solid. When grid-contract completion lands in Bruno or integration helpers, add an assertion for `[{ factionId: prototype_faction_rust_collective, amount: 10 }]` so the third freeze row in the plan table is locked like operator-chain.~~ **Done.** `QuestProgressApiTests.GetQuestProgress_ShouldReturnGridContractReputationGrant_WhenAcceptedAfterOperatorChain`; `Accept grid contract after operator chain.bru` asserts embedded `completionRewardSummary.reputationGrants`.
2. ~~**Optional wire-level omission test for `factionGateRules`.** `QuestDefinitionsWorldApiTests` asserts deserialized `FactionGateRules` is null on non-gated quests; Bruno asserts JSON `undefined` for gather intro and operator chain. A small integration test that reads raw JSON (or `JsonDocument`) and confirms the property key is absent on non-gated rows would close the loop without Bruno — low priority given Bruno coverage.~~ **Done.** `GetQuestDefinitions_ShouldOmitFactionGateRulesJsonProperty_WhenQuestHasNoGates` uses `JsonDocument` to assert key absence on gather intro and operator chain.
## Nits
- ~~Nit: `docs/manual-qa/NEO-140.md` links client follow-up as `[NEO-142](NEO-142.md)` — relative path may not resolve from `docs/manual-qa/`; prefer `NEO-142.md` (same folder) or `../manual-qa/NEO-142.md` from other docs.~~ **Done.** Linear issue link until `NEO-142.md` exists.
- ~~Nit: `Get quest progress after operator chain complete.bru` pre-request performs a quest-progress GET before the named GET (mirrors gather-intro bru idempotency pattern) — fine; seq **12** places it after gather-intro complete (**8**); confirm collection order if Bruno runs fail mid-spine.~~ **Addressed.** Pre-request helpers reset quest/inventory state per bru; seq order does not share mutable spine between these requests.
- Nit: Client fixture JSON in `client/test/quest_progress_client_test.gd` (`_completed_with_reward_summary_json`) omits `reputationGrants` — acceptable until **NEO-142**; Godot client ignores unknown keys today. **Deferred** to NEO-142.
## Verification
```bash
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln
```
Manual (Bruno — no separate manual QA doc for this server-only story):
- `cd server/NeonSprawl.Server && dotnet run`
- Bruno: `quest-definitions/Get quest definitions`
- Bruno: `quest-progress/Get quest progress after gather intro complete`
- Bruno: `quest-progress/Get quest progress after operator chain complete`
- Regression: `faction-standing/Get faction standing after operator chain`, `quest-progress/Accept grid contract after operator chain`

View File

@ -0,0 +1,50 @@
# Code review — NEO-141 (E7M3-09)
**Date:** 2026-06-17
**Scope:** Branch `NEO-141-faction-telemetry-hooks` — commits `5ab8121``3c1ade4` vs `main`
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-09**: comment-only telemetry anchor sites for future E9.M1 catalog events **`reputation_delta`** and **`faction_gate_blocked`**, consolidating partial stubs from NEO-136/137/138 into single ops-layer hooks. **`ReputationOperations.TryApplyDelta`** gains a full NEO-121-style block after audit append success; **`FactionGateOperations.TryEvaluate`** adds the missing read-failure deny hook and tightens threshold-branch payload notes; **`RewardRouterOperations`** duplicate **`reputation_delta`** stub is removed. **`server/README.md`** adds a dedicated NEO-141 subsection and cross-links NEO-130. Docs (plan, module, alignment register, backlog, manual QA) are reconciled. The branch also includes **NEO-140 story-end cleanup** (remove `docs/manual-qa/NEO-140.md`, plan/review wording). No runtime, wire, or test behavior changes — **823** server tests pass. Low merge risk.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-141-implementation-plan.md` | **Matches** — kickoff decisions, AC checklist, reconciliation section, and file list align with the shipped diff. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-09 AC checked and landed note correct; in-scope bullets align with ops-only anchors. |
| `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` | **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. |
## Blocking issues
None.
## Suggestions
1. ~~**Align E7M3-09 backlog in-scope bullet with shipped anchor.** [E7M3-pre-production-backlog.md](../plans/E7M3-pre-production-backlog.md) §E7M3-09 still lists “`**TryAccept`** deny path” alongside **`FactionGateOperations`**, but the adopted plan and code place **`faction_gate_blocked`** only in **`FactionGateOperations.TryEvaluate`** (quest layer maps HTTP reason without duplicating the hook). Update the in-scope bullet to ops-only wording so future readers do not expect a **`QuestStateOperations`** comment block.~~ **Done.** In-scope bullet now reads **`FactionGateOperations.TryEvaluate`** (threshold + standing read failure deny paths).
## Nits
- ~~Nit: **`FactionGateOperations`** class summary says “deny return path below” (singular) while two deny branches now carry hooks — consider “deny return paths” for accuracy.~~ **Done.**
- Nit: Branch bundles **NEO-140** manual QA removal (`ef75111`) with **NEO-141** work — acceptable hygiene; PR description should mention both if opened as one branch.
## Verification
```bash
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln
```
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

@ -1,5 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using NeonSprawl.Server.Game.Quests;
using Xunit;
@ -74,5 +75,43 @@ public class QuestDefinitionsWorldApiTests
Assert.Equal("inventory_has_item", terminalObjective.Kind);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId);
Assert.Equal(1, terminalObjective.Quantity);
Assert.Null(operatorChain.FactionGateRules);
var gridContract = body.Quests.Single(q => q.Id == PrototypeE7M1QuestCatalogRules.GridContractQuestId);
Assert.NotNull(gridContract.FactionGateRules);
Assert.Single(gridContract.FactionGateRules!);
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractGateFactionId, gridContract.FactionGateRules![0].FactionId);
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractMinStanding, gridContract.FactionGateRules![0].MinStanding);
Assert.Null(gatherIntro.FactionGateRules);
}
[Fact]
public async Task GetQuestDefinitions_ShouldOmitFactionGateRulesJsonProperty_WhenQuestHasNoGates()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/quest-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
foreach (var quest in doc.RootElement.GetProperty("quests").EnumerateArray())
{
var id = quest.GetProperty("id").GetString();
if (id is "prototype_quest_gather_intro" or PrototypeE7M1QuestCatalogRules.ChainQuestId)
{
Assert.False(quest.TryGetProperty("factionGateRules", out _));
}
if (id == PrototypeE7M1QuestCatalogRules.GridContractQuestId)
{
Assert.True(quest.TryGetProperty("factionGateRules", out var rules));
Assert.Equal(JsonValueKind.Array, rules.ValueKind);
Assert.Equal(1, rules.GetArrayLength());
}
}
}
}

View File

@ -23,6 +23,8 @@ public sealed class QuestProgressApiTests
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
@ -244,6 +246,28 @@ public sealed class QuestProgressApiTests
AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnGridContractReputationGrant_WhenAcceptedAfterOperatorChain()
{
// Arrange — operator-chain kit satisfies grid contract inventory_has_item; accept delivers +10 Rust Collective rep.
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
CompleteOperatorChain(deps);
Assert.True(TryAccept(deps, GridContractQuestId).Success);
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == GridContractQuestId);
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
AssertGridContractCompletionRewardSummary(row.CompletionRewardSummary);
}
[Fact]
public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet()
{
@ -291,6 +315,7 @@ public sealed class QuestProgressApiTests
Assert.Single(summary.SkillXpGrants);
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
Assert.Equal(25, summary.SkillXpGrants[0].Amount);
Assert.Empty(summary.ReputationGrants);
}
private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
@ -302,6 +327,19 @@ public sealed class QuestProgressApiTests
Assert.Single(summary.SkillXpGrants);
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
Assert.Equal(50, summary.SkillXpGrants[0].Amount);
Assert.Single(summary.ReputationGrants);
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractGateFactionId, summary.ReputationGrants[0].FactionId);
Assert.Equal(15, summary.ReputationGrants[0].Amount);
}
private static void AssertGridContractCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
{
Assert.NotNull(summary);
Assert.Empty(summary!.ItemGrants);
Assert.Empty(summary.SkillXpGrants);
Assert.Single(summary.ReputationGrants);
Assert.Equal(RustCollectiveFactionId, summary.ReputationGrants[0].FactionId);
Assert.Equal(10, summary.ReputationGrants[0].Amount);
}
private static void AssertCompletionRewardSummariesEqual(
@ -323,6 +361,13 @@ public sealed class QuestProgressApiTests
Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId);
Assert.Equal(first.SkillXpGrants[i].Amount, second.SkillXpGrants[i].Amount);
}
Assert.Equal(first.ReputationGrants.Count, second.ReputationGrants.Count);
for (var i = 0; i < first.ReputationGrants.Count; i++)
{
Assert.Equal(first.ReputationGrants[i].FactionId, second.ReputationGrants[i].FactionId);
Assert.Equal(first.ReputationGrants[i].Amount, second.ReputationGrants[i].Amount);
}
}
private static void CompleteOperatorChain(QuestWiringTestDependencies deps)

View File

@ -5,7 +5,7 @@ namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Evaluates <see cref="FactionGateRuleRow"/> minimum-standing gates (NEO-137).
/// Quest accept wiring: <see cref="QuestStateOperations.TryAccept"/>.
/// NEO-141 telemetry hook site: deny return path below.
/// NEO-141 telemetry hook site: deny return paths below.
/// </summary>
public static class FactionGateOperations
{
@ -30,13 +30,16 @@ public static class FactionGateOperations
var read = standingStore.TryGetStanding(normalizedPlayerId, factionId);
if (!read.Success)
{
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `faction_gate_blocked` ---
// TODO(E9.M1): catalog emit — ops reasonCode (e.g. unknown_faction), playerId, factionId, minStanding, currentStanding (null).
// No ingest or ILogger here (comments-only).
return Deny(read.ReasonCode, factionId, rule.MinStanding, null);
}
if (read.Standing < rule.MinStanding)
{
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `faction_gate_blocked` ---
// TODO(E9.M1): catalog emit — playerId, factionId, minStanding, currentStanding, gate context.
// TODO(E9.M1): catalog emit — ops reasonCode (gate_blocked), playerId, factionId, minStanding, currentStanding.
// No ingest or ILogger here (comments-only).
return Deny(
FactionGateEvaluateReasonCodes.GateBlocked,

View File

@ -67,6 +67,10 @@ public static class ReputationOperations
if (auditStore.TryAppend(row))
{
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` ---
// TODO(E9.M1): catalog emit — once per successful auditable apply (standing + audit committed).
// Not on deny paths, audit append failure + compensating revert, or caller rollback paths.
// Planned payload fields: playerId, factionId, appliedDelta, newStanding, sourceKind, sourceId, deltaId, appliedAt.
// No ingest or ILogger here (comments-only).
return new ReputationApplyOutcome(
true,
null,

View File

@ -29,6 +29,21 @@ public sealed class QuestDefinitionJson
[JsonPropertyName("steps")]
public required IReadOnlyList<QuestStepDefinitionJson> Steps { get; init; }
/// <summary>Minimum-standing gates for quest accept; omitted when the quest has no rules (NEO-140).</summary>
[JsonPropertyName("factionGateRules")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<QuestFactionGateRuleJson>? FactionGateRules { get; init; }
}
/// <summary>One faction gate rule in the read-only quest definition projection (NEO-140).</summary>
public sealed class QuestFactionGateRuleJson
{
[JsonPropertyName("factionId")]
public required string FactionId { get; init; }
[JsonPropertyName("minStanding")]
public int MinStanding { get; init; }
}
/// <summary>One step in the read-only quest definition projection.</summary>

View File

@ -20,6 +20,7 @@ public static class QuestDefinitionsWorldApi
DisplayName = d.DisplayName,
PrerequisiteQuestIds = d.PrerequisiteQuestIds,
Steps = MapSteps(d.Steps),
FactionGateRules = MapFactionGateRules(d.FactionGateRules),
});
}
@ -34,6 +35,27 @@ public static class QuestDefinitionsWorldApi
return app;
}
private static List<QuestFactionGateRuleJson>? MapFactionGateRules(IReadOnlyList<FactionGateRuleRow> rules)
{
if (rules.Count == 0)
{
return null;
}
var list = new List<QuestFactionGateRuleJson>(rules.Count);
foreach (var rule in rules)
{
list.Add(
new QuestFactionGateRuleJson
{
FactionId = rule.FactionId,
MinStanding = rule.MinStanding,
});
}
return list;
}
private static List<QuestStepDefinitionJson> MapSteps(IReadOnlyList<QuestStepDefRow> steps)
{
var list = new List<QuestStepDefinitionJson>(steps.Count);

View File

@ -132,6 +132,7 @@ public static class QuestProgressApi
{
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation),
};
}
@ -169,6 +170,24 @@ public static class QuestProgressApi
return summary;
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedReputation"/>.</summary>
private static List<QuestReputationGrantJson> MapReputationGrants(
IReadOnlyList<RewardReputationGrantApplied> grants)
{
var summary = new List<QuestReputationGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestReputationGrantJson
{
FactionId = grant.FactionId,
Amount = grant.Amount,
});
}
return summary;
}
private static QuestProgressRowJson NotStartedRow(string questId) =>
new()
{

View File

@ -45,7 +45,7 @@ public sealed class QuestProgressRowJson
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
}
/// <summary>Item + skill XP grants applied on first-time quest completion (NEO-129).</summary>
/// <summary>Item, skill XP, and reputation grants applied on first-time quest completion (NEO-129, NEO-140).</summary>
public sealed class QuestCompletionRewardSummaryJson
{
[JsonPropertyName("itemGrants")]
@ -53,6 +53,9 @@ public sealed class QuestCompletionRewardSummaryJson
[JsonPropertyName("skillXpGrants")]
public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; }
[JsonPropertyName("reputationGrants")]
public required IReadOnlyList<QuestReputationGrantJson> ReputationGrants { get; init; }
}
/// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
@ -74,3 +77,13 @@ public sealed class QuestSkillXpGrantJson
[JsonPropertyName("amount")]
public required int Amount { get; init; }
}
/// <summary>One reputation grant line in <see cref="QuestCompletionRewardSummaryJson"/> (NEO-140).</summary>
public sealed class QuestReputationGrantJson
{
[JsonPropertyName("factionId")]
public required string FactionId { get; init; }
[JsonPropertyName("amount")]
public required int Amount { get; init; }
}

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)
@ -220,9 +223,6 @@ public static class RewardRouterOperations
var appliedAmount = repOutcome.NewStanding - repOutcome.PreviousStanding;
appliedReputation.Add(new RewardReputationGrantApplied(grant.FactionId, appliedAmount));
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` ---
// TODO(E9.M1): catalog emit — once per applied rep row on TryRecord success only (not rollback paths).
}
var deliveredAt = timeProvider.GetUtcNow();

View File

@ -134,6 +134,17 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
### Faction telemetry hooks (NEO-141)
Comment-only hook sites for future E9.M1 catalog events ([Epic 7 Slice 3](../../docs/decomposition/modules/E7_M3_FactionReputationLedger.md#related-implementation-slices)). **`TODO(E9.M1)`** — no production ingest. Quest accept and reward router delegate to these ops layers only (no duplicate API-layer or router-layer hooks for these names). Quest rep grants emit **`reputation_delta`** via **`ReputationOperations`** — see [Reward telemetry hooks (NEO-130)](#reward-telemetry-hooks-neo-130) cross-link.
| Event | Anchor | When |
|-------|--------|------|
| **`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). |
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).
- **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent).
@ -245,7 +256,7 @@ On success, **Information** logs include the resolved quests directory path, dis
## Quest definitions (NEO-115)
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/quest-definitions"
@ -310,9 +321,9 @@ Completed rows cannot regress to active without a reset API (none in prototype).
| Field | When present |
|-------|----------------|
| **`completedAt`** | **`status: completed`** |
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`) and **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`). Omitted when not completed or when no delivery record exists. |
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`), **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`), and **`GrantedReputation`** → **`reputationGrants`** (`factionId`, `amount`). Omitted when not completed or when no delivery record exists. |
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`** and empty **`itemGrants`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`**, empty **`itemGrants`**, and empty **`reputationGrants`**. Operator chain completion adds **`reputationGrants: [{ factionId: prototype_faction_grid_operators, amount: 15 }]`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); rep projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`, `Get quest progress after operator chain complete.bru`.
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).
@ -408,9 +419,10 @@ Comment-only hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`*
| Event | Anchor | When |
|-------|--------|------|
| **`reward_delivery`** | **`TryDeliverQuestCompletion`** | After **`TryRecord`** returns **`true`** on first-time delivery (not idempotent **`TryGet`** replay or race-loser paths). |
| **`reputation_delta`** | **`TryDeliverQuestCompletion`** (stub) | After each successful reputation row apply (NEO-138); consolidated in NEO-141. |
| **`unlock_granted`** | **`TryDeliverQuestCompletion`** (stub) | Future **`UnlockGrant`** apply from bundle rows — prototype bundles have item + skill XP only; no runtime unlock apply in Slice 2. |
**`reputation_delta`** from quest bundle rep rows emits via **`ReputationOperations.TryApplyDelta`** ([NEO-141](#faction-telemetry-hooks-neo-141)) — not duplicated in the router.
**`perk_unlock`** side effects from skill XP grants use **`PerkUnlockEngine`** ([NEO-49](#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49)) — distinct from content **`UnlockGrant`** rows. Manual QA: [`docs/manual-qa/NEO-130.md`](../../docs/manual-qa/NEO-130.md); plan: [NEO-130 implementation plan](../../docs/plans/NEO-130-implementation-plan.md).
## Encounter definitions (NEO-103)