Compare commits
32 Commits
72668e1cab
...
74b6586004
| Author | SHA1 | Date |
|---|---|---|
|
|
74b6586004 | |
|
|
24a70d3566 | |
|
|
0903bf00bd | |
|
|
4f02dc47fa | |
|
|
4b221cdf2a | |
|
|
4022a1c220 | |
|
|
3308fa564f | |
|
|
d4388e25aa | |
|
|
3a509d560e | |
|
|
ee918c5412 | |
|
|
00590d5944 | |
|
|
187ecc9c0d | |
|
|
e39622893b | |
|
|
f8a36213a1 | |
|
|
be7878a199 | |
|
|
84bd46635d | |
|
|
c8c706f839 | |
|
|
fb9a8ab8b2 | |
|
|
b4d48f6e36 | |
|
|
e102a6b539 | |
|
|
0a4450a199 | |
|
|
cbd8444da3 | |
|
|
3c1ade4185 | |
|
|
73403622f2 | |
|
|
5ab81215ad | |
|
|
ef75111b2a | |
|
|
960b4900d0 | |
|
|
7011a56755 | |
|
|
3ba2fd100c | |
|
|
1dedc5fc99 | |
|
|
2b4a0d1e34 | |
|
|
367f894b34 |
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"]
|
||||
[ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"]
|
||||
[ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"]
|
||||
[ext_resource type="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"]
|
||||
[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"]
|
||||
[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
|
||||
|
||||
|
|
@ -1126,6 +1127,9 @@ script = ExtResource("25_quest_prog")
|
|||
[node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014]
|
||||
script = ExtResource("26_quest_defs")
|
||||
|
||||
[node name="FactionStandingClient" type="Node" parent="." unique_id=2500015]
|
||||
script = ExtResource("27_faction_standing")
|
||||
|
||||
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
|
|
@ -1271,6 +1275,18 @@ autowrap_mode = 3
|
|||
text = "Quest rewards:
|
||||
—"
|
||||
|
||||
[node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.85, 0.88, 0.98, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 22
|
||||
autowrap_mode = 3
|
||||
text = "Faction standing:
|
||||
Grid Operators: 0
|
||||
Rust Collective: 0"
|
||||
|
||||
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
extends Node
|
||||
|
||||
## NEO-142: prototype HTTP client for faction-standing GET (NEO-139).
|
||||
## Route: [code]GET /game/players/{id}/faction-standing[/code].
|
||||
|
||||
signal faction_standing_received(snapshot: Dictionary)
|
||||
signal faction_standing_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const GRID_OPERATORS_ID := "prototype_faction_grid_operators"
|
||||
const RUST_COLLECTIVE_ID := "prototype_faction_rust_collective"
|
||||
|
||||
const _DISPLAY_NAMES: Dictionary = {
|
||||
GRID_OPERATORS_ID: "Grid Operators",
|
||||
RUST_COLLECTIVE_ID: "Rust Collective",
|
||||
}
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _sync_pending: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
_sync_pending = true
|
||||
return
|
||||
_start_sync_request()
|
||||
|
||||
|
||||
func _start_sync_request() -> void:
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/faction-standing" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("FactionStandingClient: %s" % reason)
|
||||
_busy = false
|
||||
faction_standing_sync_failed.emit(reason)
|
||||
_try_flush_pending_sync()
|
||||
|
||||
|
||||
func _try_flush_pending_sync() -> void:
|
||||
if not _sync_pending or _busy:
|
||||
return
|
||||
_sync_pending = false
|
||||
_start_sync_request()
|
||||
|
||||
|
||||
static func prototype_faction_ids_in_order() -> Array:
|
||||
return [GRID_OPERATORS_ID, RUST_COLLECTIVE_ID]
|
||||
|
||||
|
||||
static func display_name_for(faction_id: String) -> String:
|
||||
var fid := faction_id.strip_edges()
|
||||
if _DISPLAY_NAMES.has(fid):
|
||||
return str(_DISPLAY_NAMES[fid])
|
||||
return _title_case_token(fid)
|
||||
|
||||
|
||||
static func standing_for(faction_id: String, snapshot: Dictionary = {}) -> int:
|
||||
var row: Dictionary = faction_row(faction_id, snapshot)
|
||||
if row.is_empty():
|
||||
return 0
|
||||
return int(row.get("standing", 0))
|
||||
|
||||
|
||||
static func faction_row(faction_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var factions: Variant = snapshot.get("factions", null)
|
||||
if factions == null or not factions is Array:
|
||||
return {}
|
||||
var fid := faction_id.strip_edges()
|
||||
for row_variant in factions as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == fid:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
static func parse_faction_standing_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if str(root.get("playerId", "")).strip_edges().is_empty():
|
||||
return null
|
||||
var factions: Variant = root.get("factions", null)
|
||||
if factions == null or not factions is Array:
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
static func _title_case_token(token: String) -> String:
|
||||
var trimmed := token.strip_edges()
|
||||
if trimmed.is_empty():
|
||||
return trimmed
|
||||
return trimmed.substr(0, 1).to_upper() + trimmed.substr(1)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("FactionStandingClient: %s" % reason)
|
||||
faction_standing_sync_failed.emit(reason)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("FactionStandingClient: %s" % reason404)
|
||||
faction_standing_sync_failed.emit(reason404)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("FactionStandingClient: %s" % reason_code)
|
||||
faction_standing_sync_failed.emit(reason_code)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_faction_standing_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("FactionStandingClient: %s" % reason_json)
|
||||
faction_standing_sync_failed.emit(reason_json)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
faction_standing_received.emit(snapshot as Dictionary)
|
||||
_try_flush_pending_sync()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo142factionst01
|
||||
|
|
@ -137,6 +137,7 @@ var _dev_pulse_bootstrap_attempted: bool = false
|
|||
@onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel")
|
||||
@onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel")
|
||||
@onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel")
|
||||
@onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel")
|
||||
@onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel")
|
||||
@onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel")
|
||||
@onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel")
|
||||
|
|
@ -155,6 +156,7 @@ var _dev_pulse_bootstrap_attempted: bool = false
|
|||
@onready var _encounter_progress_client: Node = $EncounterProgressClient
|
||||
@onready var _quest_progress_client: Node = $QuestProgressClient
|
||||
@onready var _quest_defs_client: Node = $QuestDefinitionsClient
|
||||
@onready var _faction_standing_client: Node = $FactionStandingClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
|
|
@ -867,7 +869,8 @@ func _setup_quest_progress_sync() -> void:
|
|||
_quest_reward_delivery_label,
|
||||
_item_defs_client,
|
||||
_inventory_client,
|
||||
_skill_progression_client
|
||||
_skill_progression_client,
|
||||
{"client": _faction_standing_client, "label": _faction_standing_label}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,21 @@ func quests_snapshot() -> Array:
|
|||
return _quests.duplicate(true)
|
||||
|
||||
|
||||
func faction_gate_rules_for(quest_id: String) -> Array:
|
||||
var qid := quest_id.strip_edges()
|
||||
for row_variant in _quests:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) != qid:
|
||||
continue
|
||||
var rules: Variant = row.get("factionGateRules", null)
|
||||
if rules is Array:
|
||||
return (rules as Array).duplicate(true)
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ extends Node
|
|||
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
|
||||
## NEO-131: quest completion reward label on in-session completed transition.
|
||||
## NEO-132: emits quest_completion_reward_transition for economy HUD refresh.
|
||||
## NEO-142: faction standing label + readable faction_gate_blocked accept deny.
|
||||
|
||||
signal quest_completion_reward_transition(quest_id: String, summary: Dictionary)
|
||||
|
||||
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
|
||||
const FactionStandingClient := preload("res://scripts/faction_standing_client.gd")
|
||||
const GATHER_QUEST_ID := "prototype_quest_gather_intro"
|
||||
const FACTION_GATE_BLOCKED_REASON := "faction_gate_blocked"
|
||||
const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)"
|
||||
const ACCEPT_SENDING_HINT := "Quest accept: sending…"
|
||||
const REWARD_HEADER := "Quest rewards:"
|
||||
const FACTION_STANDING_HEADER := "Faction standing:"
|
||||
|
||||
var _progress_client: Node = null
|
||||
var _defs_client: Node = null
|
||||
|
|
@ -28,6 +32,10 @@ var _last_reward_quest_id: String = ""
|
|||
var _last_reward_summary: Dictionary = {}
|
||||
var _inventory_client: Node = null
|
||||
var _skill_progression_client: Node = null
|
||||
var _faction_standing_client: Node = null
|
||||
var _faction_standing_label: Label = null
|
||||
var _last_faction_snapshot: Dictionary = {}
|
||||
var _faction_standing_error: String = ""
|
||||
|
||||
|
||||
func setup(
|
||||
|
|
@ -39,19 +47,24 @@ func setup(
|
|||
reward_label: Label = null,
|
||||
item_defs_client: Node = null,
|
||||
inventory_client: Node = null,
|
||||
skill_progression_client: Node = null
|
||||
skill_progression_client: Node = null,
|
||||
faction_standing_hud: Dictionary = {}
|
||||
) -> void:
|
||||
_progress_client = progress_client
|
||||
_defs_client = defs_client
|
||||
_item_defs_client = item_defs_client
|
||||
_inventory_client = inventory_client
|
||||
_skill_progression_client = skill_progression_client
|
||||
_faction_standing_client = faction_standing_hud.get("client")
|
||||
_faction_standing_label = faction_standing_hud.get("label")
|
||||
_progress_label = progress_label
|
||||
_accept_label = accept_label
|
||||
_reward_label = reward_label
|
||||
if apply_http_config.is_valid():
|
||||
apply_http_config.call(progress_client)
|
||||
apply_http_config.call(defs_client)
|
||||
if is_instance_valid(_faction_standing_client):
|
||||
apply_http_config.call(_faction_standing_client)
|
||||
if _progress_client.has_signal("quest_progress_received"):
|
||||
_progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received"))
|
||||
if _progress_client.has_signal("quest_sync_failed"):
|
||||
|
|
@ -70,11 +83,22 @@ func setup(
|
|||
)
|
||||
if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"):
|
||||
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
|
||||
if is_instance_valid(_faction_standing_client):
|
||||
if _faction_standing_client.has_signal("faction_standing_received"):
|
||||
_faction_standing_client.connect(
|
||||
"faction_standing_received", Callable(self, "_on_faction_standing_received")
|
||||
)
|
||||
if _faction_standing_client.has_signal("faction_standing_sync_failed"):
|
||||
_faction_standing_client.connect(
|
||||
"faction_standing_sync_failed", Callable(self, "_on_faction_standing_sync_failed")
|
||||
)
|
||||
_render_progress_label()
|
||||
_render_accept_feedback_label()
|
||||
_render_reward_label()
|
||||
_render_faction_standing_label()
|
||||
if _defs_client.has_method("request_sync_from_server"):
|
||||
_defs_client.call("request_sync_from_server")
|
||||
_refresh_faction_standing()
|
||||
request_progress_refresh()
|
||||
|
||||
|
||||
|
|
@ -133,7 +157,9 @@ func _on_accept_result_received(quest_id: String, result: Dictionary) -> void:
|
|||
_render_progress_label()
|
||||
else:
|
||||
var rc := str(result.get("reasonCode", "")).strip_edges()
|
||||
if rc.is_empty():
|
||||
if rc == FACTION_GATE_BLOCKED_REASON:
|
||||
_render_accept_feedback_label(_format_faction_gate_blocked_deny(quest_id))
|
||||
elif rc.is_empty():
|
||||
_render_accept_feedback_label("Quest accept: denied (no reasonCode)")
|
||||
else:
|
||||
_render_accept_feedback_label("Quest accept: denied — %s" % rc)
|
||||
|
|
@ -217,6 +243,74 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void:
|
|||
if not _last_reward_quest_id.is_empty() and not _last_reward_summary.is_empty():
|
||||
quest_completion_reward_transition.emit(_last_reward_quest_id, _last_reward_summary)
|
||||
_refresh_economy_hud()
|
||||
_refresh_faction_standing()
|
||||
|
||||
|
||||
func _refresh_faction_standing() -> void:
|
||||
if (
|
||||
is_instance_valid(_faction_standing_client)
|
||||
and _faction_standing_client.has_method("request_sync_from_server")
|
||||
):
|
||||
_faction_standing_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _on_faction_standing_received(snapshot: Dictionary) -> void:
|
||||
_faction_standing_error = ""
|
||||
_last_faction_snapshot = snapshot.duplicate(true)
|
||||
_render_faction_standing_label()
|
||||
|
||||
|
||||
func _on_faction_standing_sync_failed(reason: String) -> void:
|
||||
_faction_standing_error = reason
|
||||
_render_faction_standing_label()
|
||||
|
||||
|
||||
func _render_faction_standing_label() -> void:
|
||||
if not is_instance_valid(_faction_standing_label):
|
||||
return
|
||||
var lines: PackedStringArray = [FACTION_STANDING_HEADER]
|
||||
if not _faction_standing_error.is_empty():
|
||||
lines.append("error — %s" % _faction_standing_error)
|
||||
for faction_id in FactionStandingClient.prototype_faction_ids_in_order():
|
||||
var fid := str(faction_id)
|
||||
var standing: int = FactionStandingClient.standing_for(fid, _last_faction_snapshot)
|
||||
var label := FactionStandingClient.display_name_for(fid)
|
||||
lines.append(" %s: %d" % [label, standing])
|
||||
_faction_standing_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _format_faction_gate_blocked_deny(quest_id: String) -> String:
|
||||
var rules: Array = _faction_gate_rules_for(quest_id)
|
||||
if rules.is_empty():
|
||||
return "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON
|
||||
var first_rule: Variant = rules[0]
|
||||
if not first_rule is Dictionary:
|
||||
return "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON
|
||||
var rule: Dictionary = first_rule
|
||||
var faction_id := str(rule.get("factionId", "")).strip_edges()
|
||||
var min_standing: int = int(rule.get("minStanding", 0))
|
||||
if faction_id.is_empty():
|
||||
return "Quest accept: denied — faction standing too low (%s)" % FACTION_GATE_BLOCKED_REASON
|
||||
var faction_label := _faction_display_name(faction_id)
|
||||
var current_standing: int = FactionStandingClient.standing_for(
|
||||
faction_id, _last_faction_snapshot
|
||||
)
|
||||
return (
|
||||
"Quest accept: denied — %s standing %d required (have %d)"
|
||||
% [faction_label, min_standing, current_standing]
|
||||
)
|
||||
|
||||
|
||||
func _faction_gate_rules_for(quest_id: String) -> Array:
|
||||
if not is_instance_valid(_defs_client):
|
||||
return []
|
||||
if not _defs_client.has_method("faction_gate_rules_for"):
|
||||
return []
|
||||
return _defs_client.call("faction_gate_rules_for", quest_id) as Array
|
||||
|
||||
|
||||
func _faction_display_name(faction_id: String) -> String:
|
||||
return FactionStandingClient.display_name_for(faction_id)
|
||||
|
||||
|
||||
func _refresh_economy_hud() -> void:
|
||||
|
|
@ -258,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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-142: `FactionStandingClient` GET parse + failure signals.
|
||||
|
||||
const FactionStandingClient := preload("res://scripts/faction_standing_client.gd")
|
||||
|
||||
var _standing_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_standing(snapshot: Dictionary) -> void:
|
||||
_standing_capture = snapshot
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class PendingSyncHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var request_count: int = 0
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_count += 1
|
||||
return OK
|
||||
|
||||
func complete_pending() -> void:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
|
||||
|
||||
static func _default_standing_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","factions":['
|
||||
+ '{"id":"prototype_faction_grid_operators","standing":0},'
|
||||
+ '{"id":"prototype_faction_rust_collective","standing":0}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
static func _post_operator_chain_standing_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","factions":['
|
||||
+ '{"id":"prototype_faction_rust_collective","standing":0},'
|
||||
+ '{"id":"prototype_faction_grid_operators","standing":15}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = FactionStandingClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_faction_standing_json_reads_grid_operators_row() -> void:
|
||||
# Arrange
|
||||
var json := _post_operator_chain_standing_json()
|
||||
# Act
|
||||
var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
(
|
||||
assert_that(
|
||||
FactionStandingClient.standing_for(
|
||||
FactionStandingClient.GRID_OPERATORS_ID, snapshot as Dictionary
|
||||
)
|
||||
)
|
||||
. is_equal(15)
|
||||
)
|
||||
|
||||
|
||||
func test_parse_faction_standing_json_rejects_bad_schema() -> void:
|
||||
# Arrange
|
||||
var json := '{"schemaVersion":2,"playerId":"dev-local-1","factions":[]}'
|
||||
# Act
|
||||
var snapshot: Variant = FactionStandingClient.parse_faction_standing_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
func test_standing_for_defaults_to_zero_when_row_missing() -> void:
|
||||
# Arrange
|
||||
var snapshot := {"schemaVersion": 1, "playerId": "dev-local-1", "factions": []}
|
||||
# Act
|
||||
var standing: int = FactionStandingClient.standing_for(
|
||||
FactionStandingClient.GRID_OPERATORS_ID, snapshot
|
||||
)
|
||||
# Assert
|
||||
assert_that(standing).is_equal(0)
|
||||
|
||||
|
||||
func test_display_name_for_prototype_factions() -> void:
|
||||
# Arrange
|
||||
# Act
|
||||
var grid_label := FactionStandingClient.display_name_for(
|
||||
FactionStandingClient.GRID_OPERATORS_ID
|
||||
)
|
||||
var rust_label := FactionStandingClient.display_name_for(
|
||||
FactionStandingClient.RUST_COLLECTIVE_ID
|
||||
)
|
||||
# Assert
|
||||
assert_str(grid_label).is_equal("Grid Operators")
|
||||
assert_str(rust_label).is_equal("Rust Collective")
|
||||
|
||||
|
||||
func test_request_sync_emits_faction_standing_received() -> void:
|
||||
# Arrange
|
||||
_standing_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _default_standing_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("faction_standing_received", Callable(self, "_capture_standing"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("faction_standing_received", any())
|
||||
assert_that(_standing_capture.has("factions")).is_true()
|
||||
assert_that(transport.last_url).contains("/faction-standing")
|
||||
|
||||
|
||||
func test_http_404_emits_faction_standing_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("faction_standing_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void:
|
||||
# Arrange
|
||||
var transport := PendingSyncHttpTransport.new()
|
||||
transport.body_json = _default_standing_json()
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(transport.request_count).is_equal(1)
|
||||
transport.complete_pending()
|
||||
assert_that(transport.request_count).is_equal(2)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dnyf6txxr40d8
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-142: faction standing label + faction_gate_blocked accept deny HUD tests.
|
||||
|
||||
const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
|
||||
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
|
||||
const FactionStandingClient := preload("res://scripts/faction_standing_client.gd")
|
||||
|
||||
const GRID_CONTRACT_QUEST_ID := "prototype_quest_grid_contract"
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class MockFactionSyncClient:
|
||||
extends Node
|
||||
|
||||
var sync_count: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_count += 1
|
||||
|
||||
func display_name_for(faction_id: String) -> String:
|
||||
return FactionStandingClient.display_name_for(faction_id)
|
||||
|
||||
|
||||
static func _post_operator_chain_standing_snapshot() -> Dictionary:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"playerId": "dev-local-1",
|
||||
"factions":
|
||||
[
|
||||
{"id": FactionStandingClient.GRID_OPERATORS_ID, "standing": 15},
|
||||
{"id": FactionStandingClient.RUST_COLLECTIVE_ID, "standing": 0},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
static func _grid_contract_defs_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"quests":['
|
||||
+ '{"id":"prototype_quest_grid_contract","displayName":"Grid Contract",'
|
||||
+ '"prerequisiteQuestIds":["prototype_quest_operator_chain"],'
|
||||
+ '"factionGateRules":[{"factionId":"prototype_faction_grid_operators","minStanding":15}],'
|
||||
+ '"steps":[]}]}'
|
||||
)
|
||||
|
||||
|
||||
func test_faction_standing_label_paints_grid_operators_standing() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var standing_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(standing_label)
|
||||
add_child(controller)
|
||||
controller.set("_faction_standing_label", standing_label)
|
||||
# Act
|
||||
controller.call("_on_faction_standing_received", _post_operator_chain_standing_snapshot())
|
||||
# Assert
|
||||
assert_str(standing_label.text).contains("Faction standing:")
|
||||
assert_str(standing_label.text).contains("Grid Operators: 15")
|
||||
assert_str(standing_label.text).contains("Rust Collective: 0")
|
||||
|
||||
|
||||
func test_faction_standing_sync_error_shows_error_line() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var standing_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(standing_label)
|
||||
add_child(controller)
|
||||
controller.set("_faction_standing_label", standing_label)
|
||||
# Act
|
||||
controller.call("_on_faction_standing_sync_failed", "HTTP 503")
|
||||
# Assert
|
||||
assert_str(standing_label.text).contains("error — HTTP 503")
|
||||
assert_str(standing_label.text).contains("Grid Operators: 0")
|
||||
|
||||
|
||||
func test_faction_gate_blocked_deny_uses_readable_gate_copy() -> void:
|
||||
# Arrange
|
||||
var defs_transport := MockHttpTransport.new()
|
||||
defs_transport.body_json = _grid_contract_defs_json()
|
||||
var defs_client: Node = QuestDefinitionsClient.new()
|
||||
defs_client.set("injected_http", defs_transport)
|
||||
auto_free(defs_transport)
|
||||
auto_free(defs_client)
|
||||
add_child(defs_client)
|
||||
defs_client.call("request_sync_from_server")
|
||||
var controller: Node = QuestHudController.new()
|
||||
var accept_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(accept_label)
|
||||
add_child(controller)
|
||||
controller.set("_defs_client", defs_client)
|
||||
controller.set("_accept_label", accept_label)
|
||||
controller.call(
|
||||
"_on_faction_standing_received",
|
||||
{"schemaVersion": 1, "playerId": "dev-local-1", "factions": []}
|
||||
)
|
||||
# Act
|
||||
var deny_text: String = str(
|
||||
controller.call("_format_faction_gate_blocked_deny", GRID_CONTRACT_QUEST_ID)
|
||||
)
|
||||
# Assert
|
||||
assert_str(deny_text).contains("Grid Operators standing 15 required (have 0)")
|
||||
|
||||
|
||||
func test_faction_gate_blocked_deny_falls_back_without_gate_rules() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
auto_free(controller)
|
||||
add_child(controller)
|
||||
# Act
|
||||
var deny_text: String = str(
|
||||
controller.call("_format_faction_gate_blocked_deny", GRID_CONTRACT_QUEST_ID)
|
||||
)
|
||||
# Assert
|
||||
assert_str(deny_text).contains("faction standing too low")
|
||||
assert_str(deny_text).contains("faction_gate_blocked")
|
||||
|
||||
|
||||
func test_accept_result_received_paints_readable_faction_gate_blocked() -> void:
|
||||
# Arrange
|
||||
var defs_transport := MockHttpTransport.new()
|
||||
defs_transport.body_json = _grid_contract_defs_json()
|
||||
var defs_client: Node = QuestDefinitionsClient.new()
|
||||
defs_client.set("injected_http", defs_transport)
|
||||
auto_free(defs_transport)
|
||||
auto_free(defs_client)
|
||||
add_child(defs_client)
|
||||
defs_client.call("request_sync_from_server")
|
||||
var controller: Node = QuestHudController.new()
|
||||
var accept_label := Label.new()
|
||||
var progress_client := Node.new()
|
||||
auto_free(controller)
|
||||
auto_free(accept_label)
|
||||
auto_free(progress_client)
|
||||
add_child(controller)
|
||||
controller.set("_defs_client", defs_client)
|
||||
controller.set("_accept_label", accept_label)
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.call(
|
||||
"_on_faction_standing_received",
|
||||
{"schemaVersion": 1, "playerId": "dev-local-1", "factions": []}
|
||||
)
|
||||
# Act
|
||||
controller.call(
|
||||
"_on_accept_result_received",
|
||||
GRID_CONTRACT_QUEST_ID,
|
||||
{"schemaVersion": 1, "accepted": false, "reasonCode": "faction_gate_blocked"}
|
||||
)
|
||||
# Assert
|
||||
assert_str(accept_label.text).contains("Grid Operators standing 15 required (have 0)")
|
||||
|
||||
|
||||
func test_completion_transition_requests_faction_standing_refresh() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var faction_client := MockFactionSyncClient.new()
|
||||
auto_free(controller)
|
||||
auto_free(faction_client)
|
||||
add_child(controller)
|
||||
add_child(faction_client)
|
||||
controller.set("_faction_standing_client", faction_client)
|
||||
var active_snapshot := {
|
||||
"schemaVersion": 1,
|
||||
"playerId": "dev-local-1",
|
||||
"quests":
|
||||
[
|
||||
{
|
||||
"questId": "prototype_quest_operator_chain",
|
||||
"status": "active",
|
||||
"currentStepIndex": 3,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
var completed_snapshot := {
|
||||
"schemaVersion": 1,
|
||||
"playerId": "dev-local-1",
|
||||
"quests":
|
||||
[
|
||||
{
|
||||
"questId": "prototype_quest_operator_chain",
|
||||
"status": "completed",
|
||||
"currentStepIndex": 3,
|
||||
"objectiveCounters": {},
|
||||
"completedAt": "2026-06-07T14:00:00Z",
|
||||
"completionRewardSummary":
|
||||
{
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [],
|
||||
"reputationGrants":
|
||||
[{"factionId": FactionStandingClient.GRID_OPERATORS_ID, "amount": 15}],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
# Act
|
||||
controller.call("_on_progress_received", active_snapshot)
|
||||
controller.call("_on_progress_received", completed_snapshot)
|
||||
# Assert
|
||||
assert_int(faction_client.sync_count).is_equal(1)
|
||||
|
||||
|
||||
func test_setup_wires_faction_standing_hud_dictionary() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress_label := Label.new()
|
||||
var accept_label := Label.new()
|
||||
var standing_label := Label.new()
|
||||
var progress := Node.new()
|
||||
var defs := Node.new()
|
||||
var faction_client := MockFactionSyncClient.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
auto_free(accept_label)
|
||||
auto_free(standing_label)
|
||||
auto_free(progress)
|
||||
auto_free(defs)
|
||||
auto_free(faction_client)
|
||||
add_child(controller)
|
||||
# Act
|
||||
controller.call(
|
||||
"setup",
|
||||
progress,
|
||||
defs,
|
||||
progress_label,
|
||||
accept_label,
|
||||
Callable(self, "_noop_http_config"),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{"client": faction_client, "label": standing_label}
|
||||
)
|
||||
# Assert
|
||||
assert_object(controller.get("_faction_standing_client")).is_same(faction_client)
|
||||
assert_object(controller.get("_faction_standing_label")).is_same(standing_label)
|
||||
|
||||
|
||||
func _noop_http_config(_client: Node) -> void:
|
||||
pass
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://71r1qdmlti84
|
||||
|
|
@ -52,6 +52,16 @@ static func _four_quests_json() -> String:
|
|||
)
|
||||
|
||||
|
||||
static func _grid_contract_quests_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"quests":['
|
||||
+ '{"id":"prototype_quest_grid_contract","displayName":"Grid Contract",'
|
||||
+ '"prerequisiteQuestIds":["prototype_quest_operator_chain"],'
|
||||
+ '"factionGateRules":[{"factionId":"prototype_faction_grid_operators","minStanding":15}],'
|
||||
+ '"steps":[]}]}'
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = QuestDefinitionsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
|
|
@ -82,7 +92,7 @@ func test_request_sync_emits_definitions_ready() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("definitions_ready", any())
|
||||
assert_signal(c).is_emitted("definitions_ready", any())
|
||||
assert_that(_quests_capture.size()).is_equal(4)
|
||||
assert_that(transport.last_url).contains("/quest-definitions")
|
||||
|
||||
|
|
@ -95,9 +105,9 @@ func test_display_name_for_returns_catalog_display_name() -> void:
|
|||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
|
||||
var display_name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
|
||||
# Assert
|
||||
assert_that(name).is_equal("Intro: Salvage Run")
|
||||
assert_that(display_name).is_equal("Intro: Salvage Run")
|
||||
|
||||
|
||||
func test_display_name_for_falls_back_to_raw_id() -> void:
|
||||
|
|
@ -108,6 +118,35 @@ func test_display_name_for_falls_back_to_raw_id() -> void:
|
|||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var name: String = str(c.call("display_name_for", "unknown_quest_id"))
|
||||
var display_name: String = str(c.call("display_name_for", "unknown_quest_id"))
|
||||
# Assert
|
||||
assert_that(name).is_equal("unknown_quest_id")
|
||||
assert_that(display_name).is_equal("unknown_quest_id")
|
||||
|
||||
|
||||
func test_faction_gate_rules_for_returns_cached_gate_rows() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _grid_contract_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var rules: Array = c.call("faction_gate_rules_for", "prototype_quest_grid_contract") as Array
|
||||
# Assert
|
||||
assert_that(rules.size()).is_equal(1)
|
||||
var rule: Dictionary = rules[0]
|
||||
assert_that(str(rule.get("factionId", ""))).is_equal("prototype_faction_grid_operators")
|
||||
assert_that(int(rule.get("minStanding", -1))).is_equal(15)
|
||||
|
||||
|
||||
func test_faction_gate_rules_for_returns_empty_when_quest_missing() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _grid_contract_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var rules: Array = c.call("faction_gate_rules_for", "prototype_quest_gather_intro") as Array
|
||||
# Assert
|
||||
assert_that(rules.is_empty()).is_true()
|
||||
|
|
|
|||
|
|
@ -159,6 +159,13 @@ func _completed_operator_chain_with_summary_snapshot() -> Dictionary:
|
|||
{
|
||||
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 50}],
|
||||
"reputationGrants":
|
||||
[
|
||||
{
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"amount": 15,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
|
|
@ -235,6 +242,7 @@ func test_completion_transition_paints_item_grant_with_display_name() -> void:
|
|||
# Assert
|
||||
assert_str(reward_label.text).contains("Survey Drone Kit ×1")
|
||||
assert_str(reward_label.text).contains("Salvage +50 XP")
|
||||
assert_str(reward_label.text).contains("Grid Operators +15 rep")
|
||||
|
||||
|
||||
func test_definitions_ready_repaints_reward_label_with_display_name() -> void:
|
||||
|
|
@ -319,7 +327,7 @@ func test_completion_transition_emits_economy_refresh_signal() -> void:
|
|||
controller.call("_on_progress_received", _active_gather_snapshot())
|
||||
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
|
||||
# Assert
|
||||
await assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any())
|
||||
assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any())
|
||||
|
||||
|
||||
func test_completion_transition_invokes_economy_refresh_callback() -> void:
|
||||
|
|
@ -352,9 +360,7 @@ func test_boot_completed_does_not_emit_economy_refresh_signal() -> void:
|
|||
# Act
|
||||
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
|
||||
# Assert
|
||||
await (assert_signal(controller).wait_until(200).is_not_emitted(
|
||||
"quest_completion_reward_transition"
|
||||
))
|
||||
assert_signal(controller).wait_until(200).is_not_emitted("quest_completion_reward_transition")
|
||||
|
||||
|
||||
func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M3 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Pre-production |
|
||||
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)). 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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-133–NEO-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 **1–19** 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 **3–19** — 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 1–17 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).
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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-133–NEO-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-133–NEO-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 **1–19** (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-136–NEO-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 |
|
||||
|
|
@ -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`
|
||||
|
|
@ -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`
|
||||
|
|
@ -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.
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue