pull/195/merge
VinPropane 2026-06-28 21:58:11 +00:00 committed by GitHub
commit a874cd2d11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1746 additions and 46 deletions

View File

@ -262,5 +262,9 @@ public async Task PostExample_ShouldReturnOk_WhenBodyValid()
## Tooling ## Tooling
- Prefer fixes that satisfy built-in **.NET analyzers** / `dotnet format` (if adopted) rather than fighting IDE warnings without reason. - **`Directory.Build.props`**: **`TreatWarningsAsErrors`** — any compiler or analyzer **warning** fails **`dotnet build`**.
- Remove **unnecessary `using` directives** (IDE0005 / CS8019) — with **implicit usings** and **global usings**, many `Microsoft.Extensions.*`, `System.Threading`, and `System.Linq` imports are redundant. Run `dotnet format <project>.csproj --diagnostics IDE0005 --severity info` before merge on touched projects. - **`.editorconfig`**: **`IDE0005`** / **`CS8019`** (unnecessary usings) at **warning** severity — caught by **`dotnet format`**, not duplicated in per-file usings when covered by **`GlobalUsings.cs`** or implicit usings.
- Before commit on touched **`server/**/*.cs`**: pre-commit runs **`scripts/verify-dotnet-format.sh`**; CI runs the same after restore.
- Fix redundant usings locally: `dotnet format server/NeonSprawl.Server/NeonSprawl.Server.csproj --severity warn` (and Tests project when applicable).
- Prefer fixes that satisfy built-in **.NET analyzers** / `dotnet format` rather than fighting IDE warnings without reason.
- When adding **`server/NeonSprawl.Server.Tests/**/*.cs`**, read **`GlobalUsings.cs`** first — do not copy sibling **`using`** blocks blindly; only import namespaces used in that file.

13
.editorconfig 100644
View File

@ -0,0 +1,13 @@
# Neon Sprawl — shared editor and analyzer settings (C# server).
# Pair with Directory.Build.props (TreatWarningsAsErrors + EnforceCodeStyleInBuild).
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
[*.cs]
# Redundant usings duplicate GlobalUsings.cs / implicit usings (CS8019 / IDE0005).
dotnet_diagnostic.IDE0005.severity = warning
dotnet_diagnostic.CS8019.severity = warning

View File

@ -11,6 +11,8 @@ on:
branches: [main] branches: [main]
paths: paths:
- "NeonSprawl.sln" - "NeonSprawl.sln"
- "Directory.Build.props"
- ".editorconfig"
- "server/**" - "server/**"
- "bruno/neon-sprawl-server/**" - "bruno/neon-sprawl-server/**"
- ".github/workflows/dotnet.yml" - ".github/workflows/dotnet.yml"
@ -52,6 +54,8 @@ jobs:
filters: | filters: |
server: server:
- "NeonSprawl.sln" - "NeonSprawl.sln"
- "Directory.Build.props"
- ".editorconfig"
- "server/**" - "server/**"
- "bruno/neon-sprawl-server/**" - "bruno/neon-sprawl-server/**"
- ".github/workflows/dotnet.yml" - ".github/workflows/dotnet.yml"
@ -70,6 +74,10 @@ jobs:
if: github.event_name == 'push' || steps.paths.outputs.server == 'true' if: github.event_name == 'push' || steps.paths.outputs.server == 'true'
run: dotnet restore NeonSprawl.sln run: dotnet restore NeonSprawl.sln
- name: Verify dotnet format (IDE0005 / style)
if: github.event_name == 'push' || steps.paths.outputs.server == 'true'
run: ./scripts/verify-dotnet-format.sh
- name: Build - name: Build
if: github.event_name == 'push' || steps.paths.outputs.server == 'true' if: github.event_name == 'push' || steps.paths.outputs.server == 'true'
run: dotnet build NeonSprawl.sln --no-restore --configuration Release run: dotnet build NeonSprawl.sln --no-restore --configuration Release

View File

@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<!-- Compiler and analyzer warnings fail the build. Pair with .editorconfig severities. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- IDE0005 (unnecessary usings) is enforced via dotnet format in CI/pre-commit — see scripts/verify-dotnet-format.sh -->
</PropertyGroup>
</Project>

View File

@ -251,6 +251,21 @@ Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md). Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md).
## Contract issue + progress HUD (NEO-153)
- **`POST /game/players/{id}/contracts/issue`** — **`issued`** + optional **`reasonCode`** + optional **`contract`** row (NEO-151); see [server README — Contract issue HTTP](../server/README.md#contract-issue-http-neo-151).
- **`GET /game/players/{id}/contracts`** — active row (01) + recent completed rows with **`encounterTemplateId`** and optional **`completionRewardSummary`** (NEO-151).
- **Scripts:** `scripts/contract_client.gd`, `scripts/contract_hud_controller.gd`; wired from `main.gd` in `_setup_contract_hud()`.
- **HUD:**
- **`UICanvas/HudRootScroll/HudRoot/ContractActiveLabel`** — active contract with **`encounterTemplateId`**; **`completed`** after pocket clear in-session.
- **`UICanvas/HudRootScroll/HudRoot/ContractIssueFeedbackLabel`** — issue success/deny/failure copy.
- **`UICanvas/HudRootScroll/HudRoot/ContractRewardDeliveryLabel`** — grant lines on in-session **`active``completed`** transition (NEO-131 pattern).
- **Issue key:** **Shift+C** — frozen prototype template **`prototype_contract_clear_combat_pocket`**, seed **`prototype_contract_dev_seed`**.
- **Refresh:** boot hydrate + GET after issue POST + encounter progress GET when pocket **`completed`** (alongside inventory/quest refresh). No periodic poll.
- **Deny copy:** readable strings for **`active_contract_exists`**, **`no_eligible_template`**, **`economy_cap_exceeded`**, **`invalid_reward_bundle`**, **`unknown_template`**.
Full checklist: [`docs/manual-qa/NEO-153.md`](../docs/manual-qa/NEO-153.md).
## Faction standing + gate feedback HUD (NEO-142) ## 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/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).
@ -474,7 +489,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`contract_client.gd`**, **`contract_hud_controller.gd`** (NEO-153), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
@ -501,7 +516,7 @@ python3 -m venv .venv-gd
This enables: This enables:
- **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, or when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates. - **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates, or when staged `server/**/*.cs` fails **`scripts/verify-dotnet-format.sh`** (unnecessary usings / warn-severity style — pair with **`TreatWarningsAsErrors`** in **`Directory.Build.props`**).
- **pre-push**: when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** (same scope as CI). Otherwise skips lint/format. Still requires a **clean working tree** on every push. - **pre-push**: when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** (same scope as CI). Otherwise skips lint/format. Still requires a **clean working tree** on every push.
The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** when GDScript changed — same as CI. Pushes with no client `.gd` changes do not require gdtoolkit installed. The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** when GDScript changed — same as CI. Pushes with no client `.gd` changes do not require gdtoolkit installed.

View File

@ -24,6 +24,7 @@
[ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"] [ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"]
[ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"] [ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"]
[ext_resource type="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"] [ext_resource type="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"]
[ext_resource type="Script" uid="uid://bneo153contract01" path="res://scripts/contract_client.gd" id="28_contract_client"]
[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"] [ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"]
[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"] [ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
@ -1124,6 +1125,9 @@ script = ExtResource("24_enc_prog")
[node name="QuestProgressClient" type="Node" parent="." unique_id=2500013] [node name="QuestProgressClient" type="Node" parent="." unique_id=2500013]
script = ExtResource("25_quest_prog") script = ExtResource("25_quest_prog")
[node name="ContractClient" type="Node" parent="." unique_id=2500016]
script = ExtResource("28_contract_client")
[node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014] [node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014]
script = ExtResource("26_quest_defs") script = ExtResource("26_quest_defs")
@ -1275,6 +1279,38 @@ autowrap_mode = 3
text = "Quest rewards: text = "Quest rewards:
—" —"
[node name="ContractActiveLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000029]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.98, 0.88, 0.62, 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 = "Contract:
—"
[node name="ContractIssueFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000030]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.86, 1, 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 = "Contract issue: — (Shift+C prototype)"
[node name="ContractRewardDeliveryLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000031]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.78, 0.95, 0.82, 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 = "Contract rewards:
—"
[node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028] [node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3

View File

@ -0,0 +1,268 @@
extends Node
## NEO-153: HTTP client for contract list GET (NEO-151) and contract issue POST (NEO-151).
signal contracts_received(snapshot: Dictionary)
signal contracts_sync_failed(reason: String)
signal contract_issue_result_received(result: Dictionary)
signal contract_issue_failed(reason: String)
const SCHEMA_VERSION := 1
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@export var injected_sync_http: Node = null
@export var injected_issue_http: Node = null
var _sync_http: Node
var _issue_http: Node
var _sync_busy: bool = false
var _sync_pending: bool = false
var _issue_busy: bool = false
func _ready() -> void:
_sync_http = _resolve_http(injected_sync_http)
_issue_http = _resolve_http(injected_issue_http)
@warning_ignore("unsafe_method_access")
_sync_http.request_completed.connect(_on_sync_request_completed)
@warning_ignore("unsafe_method_access")
_issue_http.request_completed.connect(_on_issue_request_completed)
func is_issue_busy() -> bool:
return _issue_busy
func request_sync_from_server() -> void:
if _sync_busy:
_sync_pending = true
return
_start_sync_request()
func _start_sync_request() -> void:
_sync_busy = true
var url := "%s/game/players/%s/contracts" % [_base_root(), _player_path_segment()]
var err: Error = _sync_http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("ContractClient: %s" % reason)
_sync_busy = false
contracts_sync_failed.emit(reason)
_try_flush_pending_sync()
## Returns [code]true[/code] when the HTTP POST was queued.
func request_issue(template_id: String, seed_bucket: String) -> bool:
if _issue_busy:
return false
var tid := template_id.strip_edges()
var seed := seed_bucket.strip_edges()
if tid.is_empty() or seed.is_empty():
return false
_issue_busy = true
var payload: Dictionary = {
"schemaVersion": SCHEMA_VERSION,
"playerId": _player_path_segment(),
"templateId": tid,
"seedBucket": seed,
}
var url := "%s/game/players/%s/contracts/issue" % [_base_root(), _player_path_segment()]
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _issue_http.request(
url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)
)
if err != OK:
_issue_busy = false
push_warning("ContractClient: POST failed to start (%s)" % err)
return false
return true
static func active_contract_row(snapshot: Dictionary = {}) -> Dictionary:
var contracts: Variant = snapshot.get("contracts", null)
if contracts == null or not contracts is Array:
return {}
for row_variant in contracts as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("status", "")) == "active":
return row.duplicate(true)
return {}
static func contract_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary:
var iid := instance_id.strip_edges()
if iid.is_empty():
return {}
var contracts: Variant = snapshot.get("contracts", null)
if contracts == null or not contracts is Array:
return {}
for row_variant in contracts as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("contractInstanceId", "")) == iid:
return row.duplicate(true)
return {}
static func completion_reward_summary(instance_id: String, snapshot: Dictionary = {}) -> Dictionary:
var row: Dictionary = contract_row(instance_id, snapshot)
if row.is_empty() or str(row.get("status", "")) != "completed":
return {}
var summary_variant: Variant = row.get("completionRewardSummary", null)
if summary_variant is Dictionary:
return (summary_variant as Dictionary).duplicate(true)
return {}
static func parse_contracts_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
var contracts: Variant = root.get("contracts", null)
if contracts == null or not contracts is Array:
return null
if str(root.get("playerId", "")).strip_edges().is_empty():
return null
return root
static func parse_contract_issue_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 not root.has("issued"):
return null
return root
static func merge_contract_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary:
var iid := str(row.get("contractInstanceId", "")).strip_edges()
if iid.is_empty():
return snapshot.duplicate(true) if not snapshot.is_empty() else {}
var merged: Dictionary
if snapshot.is_empty():
merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "contracts": []}
else:
merged = snapshot.duplicate(true)
var contracts: Array = []
var contracts_variant: Variant = merged.get("contracts", null)
if contracts_variant is Array:
for row_variant in contracts_variant as Array:
contracts.append(row_variant)
var replaced := false
for i in contracts.size():
var existing: Variant = contracts[i]
if (
existing is Dictionary
and str((existing as Dictionary).get("contractInstanceId", "")) == iid
):
contracts[i] = row.duplicate(true)
replaced = true
break
if not replaced:
contracts.append(row.duplicate(true))
merged["contracts"] = contracts
return merged
func _resolve_http(injected: Node) -> Node:
if injected != null:
return injected
var http := HTTPRequest.new()
add_child(http)
http.timeout = 30.0
return http
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
func _try_flush_pending_sync() -> void:
if not _sync_pending or _sync_busy:
return
_sync_pending = false
_start_sync_request()
func _on_sync_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_sync_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("ContractClient: %s" % reason)
contracts_sync_failed.emit(reason)
_try_flush_pending_sync()
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("ContractClient: %s" % reason404)
contracts_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("ContractClient: %s" % reason_code)
contracts_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return
var text := body.get_string_from_utf8()
var snapshot: Variant = parse_contracts_json(text)
if snapshot == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("ContractClient: %s" % reason_json)
contracts_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return
contracts_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()
func _on_issue_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_issue_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("ContractClient: %s" % reason)
contract_issue_failed.emit(reason)
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("ContractClient: %s" % reason404)
contract_issue_failed.emit(reason404)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("ContractClient: %s" % reason_code)
contract_issue_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var parsed: Variant = parse_contract_issue_json(text)
if parsed == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("ContractClient: %s" % reason_json)
contract_issue_failed.emit(reason_json)
return
var data: Dictionary = parsed as Dictionary
if not bool(data.get("issued", false)):
var reason_variant: Variant = data.get("reasonCode", "")
var reason_str: String = reason_variant as String if reason_variant is String else ""
push_warning("contract_issue_denied reasonCode=%s" % reason_str)
contract_issue_result_received.emit(data.duplicate(true))

View File

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

View File

@ -0,0 +1,359 @@
extends Node
## NEO-153: contract issue + progress HUD wiring (Shift+C prototype issue).
const ContractClient := preload("res://scripts/contract_client.gd")
const FactionStandingClient := preload("res://scripts/faction_standing_client.gd")
const PROTOTYPE_CONTRACT_TEMPLATE_ID := "prototype_contract_clear_combat_pocket"
const PROTOTYPE_CONTRACT_SEED_BUCKET := "prototype_contract_dev_seed"
const ISSUE_IDLE_HINT := "Contract issue: — (Shift+C prototype)"
const ISSUE_SENDING_HINT := "Contract issue: sending…"
const ISSUE_BUSY_HINT := "Contract issue: busy — try again"
const REWARD_HEADER := "Contract rewards:"
const ISSUE_DENY_COPY := {
"active_contract_exists": "Contract issue: denied — active contract already issued",
"no_eligible_template": "Contract issue: denied — no eligible template (standing/band)",
"economy_cap_exceeded": "Contract issue: denied — economy cap exceeded",
"invalid_reward_bundle": "Contract issue: denied — invalid reward bundle",
"unknown_template": "Contract issue: denied — unknown template",
}
var _contract_client: Node = null
var _item_defs_client: Node = null
var _inventory_client: Node = null
var _skill_progression_client: Node = null
var _faction_standing_client: Node = null
var _active_label: Label = null
var _issue_label: Label = null
var _reward_label: Label = null
var _last_snapshot: Dictionary = {}
var _sync_error: String = ""
var _issue_contract_patch: Dictionary = {}
var _previous_status_by_instance: Dictionary = {}
var _contract_was_active_in_session: Dictionary = {}
var _tracked_instance_id: String = ""
var _last_reward_instance_id: String = ""
var _last_reward_summary: Dictionary = {}
func setup(
contract_client: Node,
active_label: Label,
issue_label: Label,
reward_label: Label,
apply_http_config: Callable,
item_defs_client: Node = null,
inventory_client: Node = null,
skill_progression_client: Node = null,
faction_standing_client: Node = null
) -> void:
_contract_client = contract_client
_active_label = active_label
_issue_label = issue_label
_reward_label = reward_label
_item_defs_client = item_defs_client
_inventory_client = inventory_client
_skill_progression_client = skill_progression_client
_faction_standing_client = faction_standing_client
if apply_http_config.is_valid():
apply_http_config.call(contract_client)
if _contract_client.has_signal("contracts_received"):
_contract_client.connect("contracts_received", Callable(self, "_on_contracts_received"))
if _contract_client.has_signal("contracts_sync_failed"):
_contract_client.connect("contracts_sync_failed", Callable(self, "_on_sync_failed"))
if _contract_client.has_signal("contract_issue_result_received"):
_contract_client.connect(
"contract_issue_result_received", Callable(self, "_on_issue_result_received")
)
if _contract_client.has_signal("contract_issue_failed"):
_contract_client.connect("contract_issue_failed", Callable(self, "_on_issue_failed"))
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")
)
_render_active_label()
_render_issue_feedback_label(ISSUE_IDLE_HINT)
_render_reward_label()
request_contract_refresh()
func request_contract_refresh() -> void:
if not is_instance_valid(_contract_client):
return
if _contract_client.has_method("request_sync_from_server"):
_contract_client.call("request_sync_from_server")
func try_issue_key_input(event: InputEvent) -> bool:
if not event is InputEventKey:
return false
var k := event as InputEventKey
if (
not k.pressed
or k.echo
or not k.shift_pressed
or (k.physical_keycode != KEY_C and k.keycode != KEY_C)
or not is_instance_valid(_contract_client)
or not _contract_client.has_method("request_issue")
):
return false
if bool(_contract_client.call("is_issue_busy")):
_render_issue_feedback_label(ISSUE_BUSY_HINT)
return true
_render_issue_feedback_label(ISSUE_SENDING_HINT)
var queued: bool = bool(
_contract_client.call(
"request_issue", PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET
)
)
if not queued:
_render_issue_feedback_label("Contract issue: failed — request not queued")
return true
func _on_contracts_received(snapshot: Dictionary) -> void:
_sync_error = ""
_detect_and_apply_completion_transitions(snapshot)
_last_snapshot = snapshot.duplicate(true)
_reapply_issue_contract_patch_if_needed()
_render_active_label()
_render_reward_label()
func _on_sync_failed(reason: String) -> void:
_sync_error = reason
if _issue_contract_patch.is_empty():
_last_snapshot = {}
elif _last_snapshot.is_empty():
_last_snapshot = ContractClient.merge_contract_row_into_snapshot({}, _issue_contract_patch)
_render_active_label()
_render_reward_label()
func _on_issue_result_received(result: Dictionary) -> void:
if bool(result.get("issued", false)):
var contract_variant: Variant = result.get("contract", null)
if contract_variant is Dictionary:
var row: Dictionary = contract_variant as Dictionary
var iid := str(row.get("contractInstanceId", "")).strip_edges()
if not iid.is_empty():
_mark_contract_active_in_session(iid)
_tracked_instance_id = iid
_issue_contract_patch = row.duplicate(true)
_sync_error = ""
_last_snapshot = ContractClient.merge_contract_row_into_snapshot(
_last_snapshot, _issue_contract_patch
)
var display := str(row.get("templateDisplayName", "")).strip_edges()
if display.is_empty():
display = str(row.get("templateId", PROTOTYPE_CONTRACT_TEMPLATE_ID))
_render_issue_feedback_label("Contract issue: %s issued" % display)
_render_active_label()
else:
_render_issue_feedback_label("Contract issue: issued")
else:
var rc := str(result.get("reasonCode", "")).strip_edges()
_render_issue_feedback_label(_format_issue_deny(rc))
request_contract_refresh()
func _on_issue_failed(reason: String) -> void:
_render_issue_feedback_label("Contract issue: failed — %s" % reason)
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
if not _last_reward_instance_id.is_empty():
_render_reward_label()
func _on_faction_standing_received(_snapshot: Dictionary) -> void:
if not _last_reward_instance_id.is_empty():
_render_reward_label()
func _mark_contract_active_in_session(instance_id: String) -> void:
var iid := instance_id.strip_edges()
if not iid.is_empty():
_contract_was_active_in_session[iid] = true
func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void:
var boot_seed := _previous_status_by_instance.is_empty()
var contracts_variant: Variant = snapshot.get("contracts", null)
if contracts_variant == null or not contracts_variant is Array:
return
for row_variant in contracts_variant as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var iid := str(row.get("contractInstanceId", "")).strip_edges()
if iid.is_empty():
continue
var new_status := str(row.get("status", ""))
if new_status == "active":
_mark_contract_active_in_session(iid)
if _tracked_instance_id.is_empty():
_tracked_instance_id = iid
var prev_status := str(_previous_status_by_instance.get(iid, ""))
var can_detect := not boot_seed or bool(_contract_was_active_in_session.get(iid, false))
if can_detect and prev_status != "completed" and new_status == "completed":
var summary := ContractClient.completion_reward_summary(iid, snapshot)
if not summary.is_empty():
_last_reward_instance_id = iid
_last_reward_summary = summary.duplicate(true)
_refresh_economy_hud()
_refresh_faction_standing()
_previous_status_by_instance[iid] = new_status
func _reapply_issue_contract_patch_if_needed() -> void:
if _issue_contract_patch.is_empty():
return
var patch_id := str(_issue_contract_patch.get("contractInstanceId", "")).strip_edges()
if patch_id.is_empty():
return
var row: Dictionary = ContractClient.contract_row(patch_id, _last_snapshot)
if row.is_empty():
_last_snapshot = ContractClient.merge_contract_row_into_snapshot(
_last_snapshot, _issue_contract_patch
)
else:
_issue_contract_patch = {}
func _refresh_economy_hud() -> void:
if (
is_instance_valid(_inventory_client)
and _inventory_client.has_method("request_sync_from_server")
):
_inventory_client.call("request_sync_from_server")
if (
is_instance_valid(_skill_progression_client)
and _skill_progression_client.has_method("request_sync_from_server")
):
_skill_progression_client.call("request_sync_from_server")
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 _render_active_label() -> void:
if not is_instance_valid(_active_label):
return
var header := "Contract:"
if not _sync_error.is_empty():
_active_label.text = "%s\nerror — %s" % [header, _sync_error]
return
var row: Dictionary = ContractClient.active_contract_row(_last_snapshot)
if row.is_empty() and not _tracked_instance_id.is_empty():
row = ContractClient.contract_row(_tracked_instance_id, _last_snapshot)
if row.is_empty():
_active_label.text = "%s\n" % header
return
var display := str(row.get("templateDisplayName", "")).strip_edges()
if display.is_empty():
display = str(row.get("templateId", ""))
var status := str(row.get("status", ""))
var encounter := str(row.get("encounterTemplateId", "")).strip_edges()
if encounter.is_empty():
encounter = ""
_active_label.text = "%s\n%s: %s — objective %s" % [header, display, status, encounter]
func _render_issue_feedback_label(text: String = ISSUE_IDLE_HINT) -> void:
if is_instance_valid(_issue_label):
_issue_label.text = text
func _render_reward_label() -> void:
if not is_instance_valid(_reward_label):
return
if not _sync_error.is_empty():
_reward_label.text = "%s\n" % REWARD_HEADER
return
if _last_reward_instance_id.is_empty() or _last_reward_summary.is_empty():
_reward_label.text = "%s\n" % REWARD_HEADER
return
var row: Dictionary = ContractClient.contract_row(_last_reward_instance_id, _last_snapshot)
var display := str(row.get("templateDisplayName", "")).strip_edges()
if display.is_empty():
display = str(row.get("templateId", ""))
var lines: PackedStringArray = [REWARD_HEADER, display]
lines.append_array(_format_reward_summary_lines(_last_reward_summary))
_reward_label.text = "\n".join(lines)
func _format_issue_deny(reason_code: String) -> String:
var rc := reason_code.strip_edges()
if rc.is_empty():
return "Contract issue: denied (no reasonCode)"
var copy_variant: Variant = ISSUE_DENY_COPY.get(rc, null)
if copy_variant is String:
return copy_variant as String
return "Contract issue: denied — %s" % rc
func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = PackedStringArray()
var items_variant: Variant = summary.get("itemGrants", null)
if items_variant is Array:
for grant_variant in items_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var item_id := str(grant.get("itemId", "")).strip_edges()
var qty: int = int(grant.get("quantity", 0))
if item_id.is_empty() or qty <= 0:
continue
lines.append(" %s ×%d" % [_item_display_name(item_id), qty])
var skills_variant: Variant = summary.get("skillXpGrants", null)
if skills_variant is Array:
for grant_variant in skills_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var skill_id := str(grant.get("skillId", "")).strip_edges()
var amount: int = int(grant.get("amount", 0))
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" % [FactionStandingClient.display_name_for(faction_id), amount]
)
if lines.is_empty():
lines.append(" (no grants)")
return lines
func _item_display_name(item_id: String) -> String:
if not is_instance_valid(_item_defs_client):
return item_id
if not _item_defs_client.has_method("display_name_for"):
return item_id
return str(_item_defs_client.call("display_name_for", item_id))
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)

View File

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

View File

@ -0,0 +1,43 @@
extends RefCounted
## NEO-153: thin main.gd wiring (keeps main.gd under gdlint max-file-lines).
const ContractHudController := preload("res://scripts/contract_hud_controller.gd")
static func setup_on(
main: Node,
hud_root: Node,
contract_client: Node,
apply_http_config: Callable,
item_defs_client: Node = null,
inventory_client: Node = null,
skill_progression_client: Node = null,
faction_standing_client: Node = null
) -> Node:
var controller: Node = ContractHudController.new()
main.add_child(controller)
controller.call(
"setup",
contract_client,
hud_root.get_node("ContractActiveLabel"),
hud_root.get_node("ContractIssueFeedbackLabel"),
hud_root.get_node("ContractRewardDeliveryLabel"),
apply_http_config,
item_defs_client,
inventory_client,
skill_progression_client,
faction_standing_client
)
return controller
static func request_refresh(controller: Node) -> void:
if is_instance_valid(controller):
controller.call("request_contract_refresh")
static func try_issue_key_input(controller: Node, event: InputEvent) -> bool:
if not is_instance_valid(controller):
return false
return bool(controller.call("try_issue_key_input", event))

View File

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

View File

@ -120,3 +120,10 @@ static func _add_prop_visual(
prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0) prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0)
prop_mesh.material_override = prop_mat prop_mesh.material_override = prop_mat
body.add_child(prop_mesh) body.add_child(prop_mesh)
static func set_collision_shapes_disabled(root: Node, disabled: bool) -> void:
if root is CollisionShape3D:
(root as CollisionShape3D).disabled = disabled
for c in root.get_children():
set_collision_shapes_disabled(c, disabled)

View File

@ -57,6 +57,8 @@ const BREACH_GIG_ID := "breach"
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
const ContractHudMainWiring := preload("res://scripts/contract_hud_main_wiring.gd")
const InteractableWorldBuilder := preload("res://scripts/interactable_world_builder.gd")
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@ -110,6 +112,7 @@ var _gig_error: String = ""
var _last_encounter_progress_snapshot: Dictionary = {} var _last_encounter_progress_snapshot: Dictionary = {}
var _encounter_progress_error: String = "" var _encounter_progress_error: String = ""
var _quest_hud: Node = null var _quest_hud: Node = null
var _contract_hud: Node = null
var _gather_pre_scrap_qty: int = 0 var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = "" var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false var _gather_awaiting_inventory_finalize: bool = false
@ -155,6 +158,7 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _gig_progression_client: Node = $GigProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient
@onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _encounter_progress_client: Node = $EncounterProgressClient
@onready var _quest_progress_client: Node = $QuestProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient
@onready var _contract_client: Node = $ContractClient
@onready var _quest_defs_client: Node = $QuestDefinitionsClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient
@onready var _faction_standing_client: Node = $FactionStandingClient @onready var _faction_standing_client: Node = $FactionStandingClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@ -207,6 +211,16 @@ func _ready() -> void:
_setup_gig_progression_sync() _setup_gig_progression_sync()
_setup_encounter_progress_sync() _setup_encounter_progress_sync()
_setup_quest_progress_sync() _setup_quest_progress_sync()
_contract_hud = ContractHudMainWiring.setup_on(
self,
_hud_root,
_contract_client,
Callable(self, "_apply_authority_http_config_to_client"),
_item_defs_client,
_inventory_client,
_skill_progression_client,
_faction_standing_client
)
_setup_gather_interact_feedback() _setup_gather_interact_feedback()
_setup_craft_ui() _setup_craft_ui()
@ -749,6 +763,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void:
if str(row.get("state", "")) == "completed": if str(row.get("state", "")) == "completed":
_request_inventory_refresh() _request_inventory_refresh()
_request_quest_progress_refresh() _request_quest_progress_refresh()
ContractHudMainWiring.request_refresh(_contract_hud)
func _on_encounter_progress_sync_failed(reason: String) -> void: func _on_encounter_progress_sync_failed(reason: String) -> void:
@ -1337,27 +1352,19 @@ func _render_player_combat_hp_label() -> void:
func _render_npc_combat_hud_labels() -> void: func _render_npc_combat_hud_labels() -> void:
var rows: Dictionary = _resolve_hud_npc_rows() var rows: Dictionary = _resolve_hud_npc_rows()
_render_npc_state_label(rows) if is_instance_valid(_npc_state_label):
_render_telegraph_label(rows) _npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(
rows, _npc_runtime_sync_error
)
if is_instance_valid(_telegraph_label):
_telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label(
rows, _npc_runtime_hud_state, _npc_runtime_sync_error
)
_npc_combat_hud_needs_tick = NpcCombatHudHelpers.has_interpolated_telegraph_display( _npc_combat_hud_needs_tick = NpcCombatHudHelpers.has_interpolated_telegraph_display(
rows, _npc_runtime_hud_state rows, _npc_runtime_hud_state
) )
func _render_npc_state_label(rows: Dictionary) -> void:
if not is_instance_valid(_npc_state_label):
return
_npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(rows, _npc_runtime_sync_error)
func _render_telegraph_label(rows: Dictionary) -> void:
if not is_instance_valid(_telegraph_label):
return
_telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label(
rows, _npc_runtime_hud_state, _npc_runtime_sync_error
)
func _on_move_rejected(reason_code: String) -> void: func _on_move_rejected(reason_code: String) -> void:
_authority_force_snap_next = true _authority_force_snap_next = true
# Rejected stream: server state may differ; next snap is forced. # Rejected stream: server state may differ; next snap is forced.
@ -1407,6 +1414,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool:
return true return true
if _try_quest_accept_key_input(event): if _try_quest_accept_key_input(event):
return true return true
if ContractHudMainWiring.try_issue_key_input(_contract_hud, event):
return true
if _try_inventory_refresh_input(event): if _try_inventory_refresh_input(event):
return true return true
return false return false
@ -1541,7 +1550,7 @@ func _dev_toggle_obstacle_smoke_deferred() -> void:
_dev_saved_obstacle_mask = co.collision_mask _dev_saved_obstacle_mask = co.collision_mask
co.collision_layer = 0 co.collision_layer = 0
co.collision_mask = 0 co.collision_mask = 0
_dev_collision_shapes_set_disabled(obstacle, true) InteractableWorldBuilder.set_collision_shapes_disabled(obstacle, true)
obstacle.process_mode = Node.PROCESS_MODE_DISABLED obstacle.process_mode = Node.PROCESS_MODE_DISABLED
obstacle.visible = false obstacle.visible = false
if obstacle.get_parent() == _nav_region: if obstacle.get_parent() == _nav_region:
@ -1555,7 +1564,7 @@ func _dev_toggle_obstacle_smoke_deferred() -> void:
if co2 != null and _dev_saved_obstacle_layer >= 0: if co2 != null and _dev_saved_obstacle_layer >= 0:
co2.collision_layer = _dev_saved_obstacle_layer co2.collision_layer = _dev_saved_obstacle_layer
co2.collision_mask = _dev_saved_obstacle_mask co2.collision_mask = _dev_saved_obstacle_mask
_dev_collision_shapes_set_disabled(obstacle, false) InteractableWorldBuilder.set_collision_shapes_disabled(obstacle, false)
call_deferred("_dev_rebake_nav_after_obstacle_toggle_deferred") call_deferred("_dev_rebake_nav_after_obstacle_toggle_deferred")
@ -1571,28 +1580,19 @@ func _dev_rebake_nav_after_obstacle_toggle_deferred() -> void:
_player.sync_navigation_agent_after_map_rebuild(_nav_region) _player.sync_navigation_agent_after_map_rebuild(_nav_region)
func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
if root is CollisionShape3D:
(root as CollisionShape3D).disabled = disabled
for c in root.get_children():
_dev_collision_shapes_set_disabled(c, disabled)
func _on_interactables_catalog_ready(descriptors: Array) -> void: func _on_interactables_catalog_ready(descriptors: Array) -> void:
_interactables_catalog = descriptors.duplicate(true) _interactables_catalog = descriptors.duplicate(true)
var groups: Variant = load("res://scripts/interactable_world_builder.gd").call( var groups: Variant = InteractableWorldBuilder.build_from_catalog(
"build_from_catalog", descriptors, _interactables_root, _radius_preview descriptors, _interactables_root, _radius_preview
) )
if _radius_preview.has_method("setup_glow_groups"): if _radius_preview.has_method("setup_glow_groups"):
_radius_preview.call("setup_glow_groups", groups as Array) _radius_preview.call("setup_glow_groups", groups as Array)
func _on_interactables_catalog_failed(reason: String) -> void: func _on_interactables_catalog_failed(reason: String) -> void:
# `InteractablesCatalogClient` already `push_error`s; this hook exists for wiring clarity.
var readme := "Interaction + range preview (NEO-9 + NEO-25)"
push_warning( push_warning(
( (
"Interactables catalog failed (%s) — start the game server first; see client README (%s)." "Interactables catalog failed (%s) — start the game server first; see client README."
% [reason, readme] % reason
) )
) )

View File

@ -0,0 +1,243 @@
extends GdUnitTestSuite
## NEO-153: `ContractClient` GET parse, issue POST, and failure signals.
const ContractClient := preload("res://scripts/contract_client.gd")
const INSTANCE_ID := "ci_test_instance"
const TEMPLATE_ID := "prototype_contract_clear_combat_pocket"
const ENCOUNTER_ID := "prototype_combat_pocket"
var _contracts_capture: Dictionary = {}
var _issue_capture: Dictionary = {}
var _issue_failed_capture: String = ""
func _capture_contracts(snapshot: Dictionary) -> void:
_contracts_capture = snapshot
func _capture_issue(result: Dictionary) -> void:
_issue_capture = result
func _capture_issue_failed(reason: String) -> void:
_issue_failed_capture = reason
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var last_method: int = HTTPClient.METHOD_GET
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
last_method = method
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
static func _active_contracts_json() -> String:
var body := (
'{"schemaVersion":1,"playerId":"dev-local-1","contracts":'
+ '[{"contractInstanceId":"%s","templateId":"%s",'
+ '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",'
+ '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",'
+ '"issuedAt":"2026-06-28T12:00:00Z"}]}'
)
return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID]
static func _completed_with_summary_json() -> String:
var body := (
'{"schemaVersion":1,"playerId":"dev-local-1","contracts":'
+ '[{"contractInstanceId":"%s","templateId":"%s",'
+ '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"completed",'
+ '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",'
+ '"issuedAt":"2026-06-28T12:00:00Z","completedAt":"2026-06-28T13:00:00Z",'
+ '"completionRewardSummary":{"itemGrants":[{"itemId":"scrap_metal_bulk","quantity":5}],'
+ '"skillXpGrants":[{"skillId":"salvage","amount":15}]}}]}'
)
return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID]
static func _issue_success_json() -> String:
var body := (
'{"schemaVersion":1,"issued":true,"contract":'
+ '{"contractInstanceId":"%s","templateId":"%s",'
+ '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",'
+ '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",'
+ '"issuedAt":"2026-06-28T12:00:00Z"}}'
)
return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID]
static func _issue_deny_json(reason_code: String) -> String:
return '{"schemaVersion":1,"issued":false,"reasonCode":"%s"}' % reason_code
func _make_client(sync_transport: Node, issue_transport: Node = null) -> Node:
var c: Node = ContractClient.new()
c.set("injected_sync_http", sync_transport)
var issue_http: Node = issue_transport if issue_transport != null else sync_transport
c.set("injected_issue_http", issue_http)
auto_free(sync_transport)
if issue_transport != null and issue_transport != sync_transport:
auto_free(issue_transport)
auto_free(c)
add_child(c)
return c
func test_parse_active_contract_row_includes_encounter_id() -> void:
# Arrange
var json := _active_contracts_json()
# Act
var snapshot: Variant = ContractClient.parse_contracts_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var row: Dictionary = ContractClient.active_contract_row(snapshot as Dictionary)
assert_that(str(row.get("status", ""))).is_equal("active")
assert_that(str(row.get("encounterTemplateId", ""))).is_equal(ENCOUNTER_ID)
func test_parse_completed_row_has_completion_reward_summary() -> void:
# Arrange
var json := _completed_with_summary_json()
# Act
var snapshot: Variant = ContractClient.parse_contracts_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var summary: Dictionary = ContractClient.completion_reward_summary(
INSTANCE_ID, snapshot as Dictionary
)
var items: Variant = summary.get("itemGrants", null)
assert_that(items is Array).is_true()
assert_that((items as Array).size()).is_equal(1)
func test_parse_contracts_json_rejects_schema_mismatch() -> void:
# Arrange
var json := '{"schemaVersion":99,"playerId":"dev-local-1","contracts":[]}'
# Act
var snapshot: Variant = ContractClient.parse_contracts_json(json)
# Assert
assert_that(snapshot).is_null()
func test_request_sync_emits_contracts_received() -> void:
# Arrange
_contracts_capture = {}
var transport := MockHttpTransport.new()
transport.body_json = _active_contracts_json()
var c := _make_client(transport, NoopHttpTransport.new())
c.connect("contracts_received", Callable(self, "_capture_contracts"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("contracts_received", any())
assert_that(_contracts_capture.get("playerId", "")).is_equal("dev-local-1")
assert_that(transport.last_url).contains("/contracts")
func test_http_404_emits_contracts_sync_failed() -> void:
# Arrange
_issue_failed_capture = ""
var transport := MockHttpTransport.new()
transport.response_code = 404
var c := _make_client(transport, NoopHttpTransport.new())
c.connect("contracts_sync_failed", Callable(self, "_capture_issue_failed"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("contracts_sync_failed", any())
assert_that(_issue_failed_capture).contains("404")
func test_issue_success_emits_contract_issue_result_received() -> void:
# Arrange
_issue_capture = {}
var sync := NoopHttpTransport.new()
var issue := MockHttpTransport.new()
issue.body_json = _issue_success_json()
var c := _make_client(sync, issue)
c.connect("contract_issue_result_received", Callable(self, "_capture_issue"))
monitor_signals(c)
# Act
c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed")
# Assert
await assert_signal(c).is_emitted("contract_issue_result_received", any())
assert_bool(_issue_capture.get("issued", false)).is_true()
assert_that(issue.last_url).contains("/contracts/issue")
assert_that(issue.last_method).is_equal(HTTPClient.METHOD_POST)
func test_issue_deny_emits_result_with_reason_code() -> void:
# Arrange
_issue_capture = {}
var sync := NoopHttpTransport.new()
var issue := MockHttpTransport.new()
issue.body_json = _issue_deny_json("economy_cap_exceeded")
var c := _make_client(sync, issue)
c.connect("contract_issue_result_received", Callable(self, "_capture_issue"))
monitor_signals(c)
# Act
c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed")
# Assert
await assert_signal(c).is_emitted("contract_issue_result_received", any())
assert_bool(_issue_capture.get("issued", true)).is_false()
assert_that(str(_issue_capture.get("reasonCode", ""))).is_equal("economy_cap_exceeded")
func test_merge_contract_row_replaces_matching_instance_id() -> void:
# Arrange
var snapshot := {
"schemaVersion": 1,
"playerId": "dev-local-1",
"contracts":
[
{
"contractInstanceId": "ci_old",
"status": "active",
}
],
}
var row := {"contractInstanceId": "ci_old", "status": "completed"}
# Act
var merged: Dictionary = ContractClient.merge_contract_row_into_snapshot(snapshot, row)
# Assert
var contracts: Array = merged.get("contracts", []) as Array
assert_that(contracts.size()).is_equal(1)
assert_that(str((contracts[0] as Dictionary).get("status", ""))).is_equal("completed")

View File

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

View File

@ -0,0 +1,290 @@
extends GdUnitTestSuite
## NEO-153: contract HUD controller issue key, active label, reward transition.
const ContractHudController := preload("res://scripts/contract_hud_controller.gd")
const ContractClient := preload("res://scripts/contract_client.gd")
const INSTANCE_ID := "ci_test_instance"
const ENCOUNTER_ID := "prototype_combat_pocket"
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
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_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
class PendingIssueHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
return OK
class TrackingFactionClient:
extends Node
var sync_count: int = 0
func request_sync_from_server() -> void:
sync_count += 1
static func _active_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"contracts":
[
{
"contractInstanceId": INSTANCE_ID,
"templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID,
"templateDisplayName": "Clear Combat Pocket (Repeat)",
"status": "active",
"encounterTemplateId": ENCOUNTER_ID,
"seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET,
"issuedAt": "2026-06-28T12:00:00Z",
}
],
}
static func _completed_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"contracts":
[
{
"contractInstanceId": INSTANCE_ID,
"templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID,
"templateDisplayName": "Clear Combat Pocket (Repeat)",
"status": "completed",
"encounterTemplateId": ENCOUNTER_ID,
"seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET,
"issuedAt": "2026-06-28T12:00:00Z",
"completedAt": "2026-06-28T13:00:00Z",
"completionRewardSummary":
{
"itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}],
"skillXpGrants": [{"skillId": "salvage", "amount": 15}],
},
}
],
}
func _make_contract_client(sync_transport: Node, issue_transport: Node = null) -> Node:
var c: Node = ContractClient.new()
c.set("injected_sync_http", sync_transport)
var issue_http: Node = issue_transport if issue_transport != null else sync_transport
c.set("injected_issue_http", issue_http)
auto_free(sync_transport)
if issue_transport != null and issue_transport != sync_transport:
auto_free(issue_transport)
auto_free(c)
add_child(c)
return c
func _build_controller(sync_transport: Node, issue_transport: Node) -> Dictionary:
var controller: Node = ContractHudController.new()
var contract: Node = _make_contract_client(sync_transport, issue_transport)
var active_label := Label.new()
var issue_label := Label.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(active_label)
auto_free(issue_label)
auto_free(reward_label)
add_child(controller)
await get_tree().process_frame
controller.call(
"setup",
contract,
active_label,
issue_label,
reward_label,
Callable(self, "_noop_http_config")
)
await get_tree().process_frame
return {
"controller": controller,
"contract": contract,
"active_label": active_label,
"issue_label": issue_label,
"reward_label": reward_label,
}
func _noop_http_config(_client: Node) -> void:
pass
func test_try_issue_key_input_handles_shift_c() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingIssueHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.shift_pressed = true
event.keycode = KEY_C
# Act
var handled: bool = bool(built["controller"].call("try_issue_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["issue_label"].text).contains("sending")
func test_try_issue_key_input_shows_busy_while_issue_in_flight() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingIssueHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.shift_pressed = true
event.keycode = KEY_C
built["controller"].call("try_issue_key_input", event)
# Act
var handled: bool = bool(built["controller"].call("try_issue_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["issue_label"].text).contains("busy")
func test_active_label_shows_encounter_objective_id() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
built["controller"].call("_on_contracts_received", _active_snapshot())
# Assert
assert_str(built["active_label"].text).contains(ENCOUNTER_ID)
assert_str(built["active_label"].text).contains("active")
func test_issue_deny_shows_economy_cap_copy() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
built["controller"].call(
"_on_issue_result_received", {"issued": false, "reasonCode": "economy_cap_exceeded"}
)
# Assert
assert_str(built["issue_label"].text).contains("economy cap exceeded")
func test_issue_deny_shows_active_contract_exists_copy() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
built["controller"].call(
"_on_issue_result_received", {"issued": false, "reasonCode": "active_contract_exists"}
)
# Assert
assert_str(built["issue_label"].text).contains("active contract already issued")
func test_reward_label_populates_on_active_to_completed_transition() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
built["controller"].call("_on_contracts_received", _active_snapshot())
# Act
built["controller"].call("_on_contracts_received", _completed_snapshot())
# Assert
assert_str(built["reward_label"].text).contains("Contract rewards:")
assert_str(built["reward_label"].text).contains("Salvage +15 XP")
func test_completion_transition_refreshes_faction_standing() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
var faction_client := TrackingFactionClient.new()
auto_free(faction_client)
add_child(faction_client)
built["controller"].set("_faction_standing_client", faction_client)
built["controller"].call("_on_contracts_received", _active_snapshot())
# Act
built["controller"].call("_on_contracts_received", _completed_snapshot())
# Assert
assert_int(faction_client.sync_count).is_equal(1)
func test_reward_label_populates_when_first_get_is_already_completed() -> void:
# Arrange — issue marks session active; encounter completes before GET returns active
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
var issue_row: Dictionary = (_active_snapshot()["contracts"] as Array)[0] as Dictionary
built["controller"].call("_on_issue_result_received", {"issued": true, "contract": issue_row})
# Act
built["controller"].call("_on_contracts_received", _completed_snapshot())
# Assert
assert_str(built["reward_label"].text).contains("Contract rewards:")
assert_str(built["reward_label"].text).contains("Salvage +15 XP")
func test_completed_get_clears_stale_issue_patch_on_active_label() -> void:
# Arrange — simulate successful issue patch still held when encounter-complete GET arrives
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new()
)
var issue_row: Dictionary = (_active_snapshot()["contracts"] as Array)[0] as Dictionary
built["controller"].set("_issue_contract_patch", issue_row.duplicate(true))
built["controller"].set("_tracked_instance_id", INSTANCE_ID)
# Act
built["controller"].call("_on_contracts_received", _completed_snapshot())
# Assert
assert_str(built["active_label"].text).contains("completed")
assert_str(built["active_label"].text).not_contains(": active —")

View File

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

View File

@ -0,0 +1,27 @@
extends GdUnitTestSuite
## NEO-153: thin main wiring helpers.
const ContractHudMainWiring := preload("res://scripts/contract_hud_main_wiring.gd")
const InteractableWorldBuilder := preload("res://scripts/interactable_world_builder.gd")
func test_try_issue_key_input_returns_false_without_controller() -> void:
# Arrange
var event := InputEventKey.new()
# Act
var handled: bool = ContractHudMainWiring.try_issue_key_input(null, event)
# Assert
assert_bool(handled).is_false()
func test_set_collision_shapes_disabled_recurses_tree() -> void:
# Arrange
var root := Node3D.new()
var shape := CollisionShape3D.new()
root.add_child(shape)
# Act
InteractableWorldBuilder.set_collision_shapes_disabled(root, true)
# Assert
assert_bool(shape.disabled).is_true()
root.free()

View File

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

View File

@ -71,6 +71,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Contract telemetry hooks (NEO-152):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** anchor sites in generator/completion ops + shared reward router rollback; [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152); plan [NEO-152](../../plans/NEO-152-implementation-plan.md). **Contract telemetry hooks (NEO-152):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** anchor sites in generator/completion ops + shared reward router rollback; [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152); plan [NEO-152](../../plans/NEO-152-implementation-plan.md).
**Contract client HUD (NEO-153):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **`ContractActiveLabel`** / **`ContractIssueFeedbackLabel`** / **`ContractRewardDeliveryLabel`** — **Shift+C** prototype issue, encounter objective display, completion reward transition; [client README — Contract issue + progress HUD (NEO-153)](../../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../../manual-qa/NEO-153.md); plan [NEO-153](../../plans/NEO-153-implementation-plan.md).
## Source anchors ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,46 @@
# NEO-153 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-153 |
| Title | E7M4-10: Client contract issue + progress HUD (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-153/e7m4-10-client-contract-issue-progress-hud-godot |
| Plan | `docs/plans/NEO-153-implementation-plan.md` |
| Branch | `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` |
## Preconditions
- **Fresh dev player:** restart server so contract instance state is clean (or use dev fixture reset if a prior contract exists).
- **Server deps:** NEO-151 **`POST …/contracts/issue`** and **`GET …/contracts`** on `main`.
- **No Bruno/curl** for this checklist — issue and progress use Godot **Shift+C** and combat pocket clear only.
## Expected contract HUD progression
| After | `ContractActiveLabel` | `ContractIssueFeedbackLabel` | `ContractRewardDeliveryLabel` |
|-------|----------------------|------------------------------|-------------------------------|
| Boot | **`Contract: —`** | **`Contract issue: — (Shift+C prototype)`** | **`Contract rewards: —`** |
| **Shift+C** issue | **`Clear Combat Pocket (Repeat): active — objective prototype_combat_pocket`** | **`Contract issue: Clear Combat Pocket (Repeat) issued`** | **`—`** |
| Defeat ×3 NPCs (combat pocket) | **`…: completed — objective prototype_combat_pocket`** | unchanged | **`scrap_metal_bulk ×5`** + **`Salvage +15 XP`** (display names when item defs loaded) |
| Duplicate **Shift+C** while active | unchanged active line | **`Contract issue: denied — active contract already issued`** | unchanged |
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm contract labels appear in **`HudRootScroll`** below quest reward labels.
3. Press **Shift+C**. Verify **`ContractActiveLabel`** shows **`active`** with **`prototype_combat_pocket`** objective id and issue feedback shows success.
4. Defeat all three combat pocket NPCs (hotbar casts). Verify encounter **`completed (3/3)`** and contract GET refresh shows **`completed`** on **`ContractActiveLabel`**.
5. Verify **`ContractRewardDeliveryLabel`** lists contract completion grants (**`scrap_metal_bulk ×5`**, **`Salvage +15 XP`**) after pocket clear — not on boot alone.
6. Press **Shift+C** again while contract still active (before clear) or after issuing once — verify readable deny when **`active_contract_exists`**.
7. Optional: stop server while Godot running, press **Shift+C** — verify **`Contract: error — …`** or **`Contract issue: failed — …`** on HUD.
## Notes
- Prototype flow is **issue then clear** the bound encounter. Issuing after the pocket is already cleared leaves the contract **`active`** until a future matching clear (NEO-149 v1 edge case).
- Fixed seed **`prototype_contract_dev_seed`** — repeat re-issue after complete requires **NEO-154** capstone (seed rotation / fixture reset).
- Encounter + quest HUD regression: [NEO-110 manual QA](NEO-110.md), [NEO-122 manual QA](NEO-122.md).
## Acceptance
- [ ] Steps 15 completable in one session without Bruno/curl.
- [ ] Active contract shows encounter objective id after **Shift+C** issue.
- [ ] Completed contract shows reward summary line on HUD after pocket clear.

View File

@ -333,10 +333,12 @@ Working backlog for **Epic 7 — Slice 4** ([contract generator](../decompositio
**Acceptance criteria** **Acceptance criteria**
- [ ] Active contract visible with encounter objective id after issue. - [x] Active contract visible with encounter objective id after issue.
- [ ] Completed contract shows reward summary line on HUD. - [x] Completed contract shows reward summary line on HUD.
**Server counterpart:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152). **Landed (NEO-153):** **`contract_client.gd`**, **`contract_hud_controller.gd`**, **Shift+C** prototype issue, contract HUD labels; [client README — Contract issue + progress HUD (NEO-153)](../../client/README.md#contract-issue--progress-hud-neo-153); [`NEO-153` manual QA](../manual-qa/NEO-153.md); plan [NEO-153](../../plans/NEO-153-implementation-plan.md).
**Server counterpart:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152).
--- ---

View File

@ -0,0 +1,225 @@
# NEO-153 — E7M4-10: Client contract issue + progress HUD (Godot)
**Linear:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153)
**Branch:** `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-10**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-122](NEO-122-implementation-plan.md) (`quest_progress_client.gd` + `quest_hud_controller.gd`); [NEO-110](NEO-110-implementation-plan.md) (encounter-complete refresh spine); [NEO-131](NEO-131-implementation-plan.md) (completion reward transition label)
**Precursors:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) **`Done`** — `POST …/contracts/issue` + `GET …/contracts` (**on `main`**); [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) **`Done`** — contract telemetry hook sites (**on `main`**)
**Blocks:** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) (Godot repeatable contract capstone)
**Server counterpart:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) — authoritative issue/list HTTP; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md)
## Goal
Player can **issue** and **track** an active contract in Godot without Bruno — **`Shift+C`** issues the frozen prototype template; HUD shows **`encounterTemplateId`** while active and a **reward summary line** when the contract completes in-session.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|---------------------|--------|
| **HUD wiring** | Separate controller + labels vs extend quest HUD? | **Separate `contract_hud_controller.gd`** + **`ContractActiveLabel`**, **`ContractIssueFeedbackLabel`**, **`ContractRewardDeliveryLabel`** — keeps quest HUD bounded; matches [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md) + NEO-122 extraction | **Adopted** — separate controller |
| **Shift+C seed bucket** | Fixed vs date vs session counter? | **Fixed `prototype_contract_dev_seed`** — NEO-153 single-loop AC; repeat re-issue seed rotation deferred to **NEO-154** | **Adopted** — fixed dev seed |
**Additional defaults (no kickoff question — settled by backlog / precedent):**
- **Issue key:** **Shift+C** (backlog E7M4-10); idle hint on **`ContractIssueFeedbackLabel`**.
- **Prototype POST fields:** **`templateId`** = **`prototype_contract_clear_combat_pocket`**; **`seedBucket`** = **`prototype_contract_dev_seed`**; **`schemaVersion`** **1**; body **`playerId`** matches path **`dev_player_id`**.
- **Refresh strategy:** boot hydrate + after successful issue POST + after encounter-progress GET when prototype encounter row reaches **`completed`** (extend **`_on_encounter_progress_received`** contract refresh — same spine as quest refresh today).
- **Completion reward label:** in-session **active → completed** transition only (NEO-131 idempotency — boot with prior completed row does not replay grant copy).
- **Deny copy:** readable HUD strings for backlog-listed codes plus stable generator codes surfaced on issue deny.
- **Server changes:** none — client-only story.
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`contract_client.gd`:** GET **`/game/players/{id}/contracts`** parse v1; POST **`/game/players/{id}/contracts/issue`**; helpers **`active_contract_row(snapshot)`**, **`completion_reward_summary(contract_instance_id, snapshot)`**; signals **`contracts_received`** / **`contracts_sync_failed`** / **`contract_issue_result_received`** / **`contract_issue_failed`**; separate sync/issue HTTP busy guards (quest/craft precedent).
- **`contract_hud_controller.gd`:** HUD render, **Shift+C** issue binding, deny copy, active-row display with **`encounterTemplateId`**, reward transition detection + summary lines (duplicate minimal grant formatters from quest HUD — item defs + faction standing clients injected).
- **`main.tscn`:** **`ContractClient`** node; three HUD labels in **`HudRootScroll/HudRoot`** after **`QuestRewardDeliveryLabel`**, before **`FactionStandingLabel`**.
- **`main.gd`:** thin setup — **`ContractHudController`**, authority HTTP config, encounter-complete contract refresh hook, **`_try_contract_issue_key_input`** in gameplay key router.
- GdUnit parse + controller tests with HTTP doubles.
- **`docs/manual-qa/NEO-153.md`** — Godot issue → clear pocket → reward line (no Bruno).
- **`client/README.md`** contract HUD subsection.
**Out of scope (from Linear + backlog):**
- Final contract board UI; zone picker (**E4.M1**).
- Repeat re-issue seed rotation (**NEO-154** capstone).
- Server route/DTO/Bruno changes.
- Telemetry ingest (**NEO-152** landed comment-only).
**Client counterpart:** this story **is** the E7M4-10 client slice; pairs **NEO-151** server HTTP.
## Acceptance criteria checklist
- [x] Active contract visible with encounter objective id after issue.
- [x] Completed contract shows reward summary line on HUD.
## Implementation reconciliation (shipped)
- **`contract_client.gd`:** GET/POST parse v1; **`active_contract_row`**, **`completion_reward_summary`**; separate sync/issue HTTP guards.
- **`contract_hud_controller.gd`:** **Shift+C** prototype issue; active/completed label with **`encounterTemplateId`**; deny copy; in-session reward transition + economy refresh; issue patch cleared when GET row exists (NEO-122 accept-patch precedent); faction-standing re-paint on reward label.
- **`main.tscn` / `main.gd`:** **`ContractClient`** node + three HUD labels; encounter-complete contract refresh hook.
- **Tests:** `contract_client_test.gd` (7 cases), `contract_hud_controller_test.gd` (6 cases) — GdUnit green.
- **Docs:** `client/README.md` contract HUD section; `docs/manual-qa/NEO-153.md`.
## Technical approach
### Server contract (landed — NEO-151)
- **`POST /game/players/{id}/contracts/issue`** → **`schemaVersion` 1**, **`issued`**, optional **`reasonCode`**, optional **`contract`** row. HTTP **200** on structured deny (quest accept precedent).
- **`GET /game/players/{id}/contracts`** → **`schemaVersion` 1**, **`playerId`**, **`contracts[]`**: active row (01) + up to 10 completed. Row fields: **`contractInstanceId`**, **`templateId`**, **`templateDisplayName`**, **`status`** (`active` \| `completed`), **`encounterTemplateId`**, **`seedBucket`**, **`issuedAt`**, optional **`completedAt`**, optional **`completionRewardSummary`** (same nested grant shape as quest NEO-129).
- Prototype template (**E7M4-01 freeze**): **`prototype_contract_clear_combat_pocket`** → **`encounterTemplateId`** **`prototype_combat_pocket`**; completion bundle **`scrap_metal_bulk` ×5**, **`salvage` +15 XP**.
### Client constants (`contract_hud_controller.gd` or shared const block)
```gdscript
const PROTOTYPE_CONTRACT_TEMPLATE_ID := "prototype_contract_clear_combat_pocket"
const PROTOTYPE_CONTRACT_SEED_BUCKET := "prototype_contract_dev_seed"
const ISSUE_IDLE_HINT := "Contract issue: — (Shift+C prototype)"
```
### 1. `contract_client.gd`
Mirror **`quest_progress_client.gd`** structure:
| Method / signal | Role |
|-----------------|------|
| **`request_sync_from_server()`** | GET contracts list |
| **`request_issue(template_id, seed_bucket)`** | POST issue with v1 body |
| **`parse_contracts_json(text)`** | Validate **`schemaVersion` 1**, **`playerId`**, **`contracts`** array |
| **`parse_contract_issue_json(text)`** | Validate **`issued`** bool present |
| **`active_contract_row(snapshot)`** | First row with **`status == "active"`** |
| **`completion_reward_summary(instance_id, snapshot)`** | Read **`completionRewardSummary`** from matching completed row |
| **`contracts_received`** / **`contracts_sync_failed`** | GET outcomes |
| **`contract_issue_result_received`** / **`contract_issue_failed`** | POST outcomes |
POST body:
```json
{
"schemaVersion": 1,
"playerId": "<dev_player_id>",
"templateId": "prototype_contract_clear_combat_pocket",
"seedBucket": "prototype_contract_dev_seed"
}
```
On issue deny (**`issued: false`**), emit result with **`reasonCode`**; **`push_warning`** with code (quest accept pattern).
### 2. `contract_hud_controller.gd`
Extracted controller (NEO-122 precedent):
- **`setup(contract_client, active_label, issue_label, reward_label, apply_http_config, item_defs_client, faction_standing_client)`**
- Boot: apply HTTP config → connect signals → **`request_sync_from_server()`**
- **`try_issue_key_input(event)`** — **Shift+C** only; calls **`request_issue(PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET)`**
- **`request_contract_refresh()`** — delegate GET sync
- **`ContractActiveLabel`** render:
- sync error → **`Contract: error — {reason}`**
- no active/completed rows → **`Contract: —`**
- **`active`** → **`{templateDisplayName}: active — objective {encounterTemplateId}`**
- latest in-session completed (status tracking) → **`{templateDisplayName}: completed — objective {encounterTemplateId}`**
- **`ContractIssueFeedbackLabel`** — idle hint / sending / issued success / deny copy map:
| **`reasonCode`** | HUD copy (prototype) |
|------------------|------------------------|
| **`active_contract_exists`** | **`Contract issue: denied — active contract already issued`** |
| **`no_eligible_template`** | **`Contract issue: denied — no eligible template (standing/band)`** |
| **`economy_cap_exceeded`** | **`Contract issue: denied — economy cap exceeded`** |
| **`invalid_reward_bundle`** | **`Contract issue: denied — invalid reward bundle`** |
| **`unknown_template`** | **`Contract issue: denied — unknown template`** |
| *(other / empty)* | **`Contract issue: denied — {reasonCode}`** |
- **`ContractRewardDeliveryLabel`** — header **`Contract rewards:`**; on in-session **`active``completed`** transition for tracked instance id, format **`completionRewardSummary`** grant lines (item qty, skill XP, rep — same line grammar as **`quest_hud_controller._format_reward_summary_lines`**); otherwise **`—`**.
**Transition tracking:** maintain **`_previous_status_by_instance`** + **`_last_reward_instance_id`** / **`_last_reward_summary`** keyed by **`contractInstanceId`** (parallel NEO-131 quest maps).
### 3. `main.gd` wiring
- Add **`ContractClient`** `@onready` reference (scene node).
- **`_setup_contract_hud()`** — instantiate **`ContractHudController`**, pass labels + **`_apply_authority_http_config_to_client`** + item/faction defs clients for grant display names.
- **`_try_contract_issue_key_input(event)`** — after quest accept router; delegate to controller.
- **`_request_contract_refresh()`** — called from controller hook + encounter complete path.
- In **`_on_encounter_progress_received`**, when prototype encounter **`state == "completed"`**, call **`_request_contract_refresh()`** (alongside existing inventory + quest refresh).
### 4. Scene labels (`main.tscn`)
Insert after **`QuestRewardDeliveryLabel`**:
| Node | Default text | Color cue |
|------|--------------|-----------|
| **`ContractActiveLabel`** | **`Contract:\n—`** | warm amber (distinct from quest blue) |
| **`ContractIssueFeedbackLabel`** | idle hint | lavender (match quest accept) |
| **`ContractRewardDeliveryLabel`** | **`Contract rewards:\n—`** | mint (match quest rewards) |
Add **`ContractClient`** sibling under scene root (with **`EncounterProgressClient`** / **`QuestProgressClient`**).
### 5. Flow
```mermaid
sequenceDiagram
participant Player
participant HUD as ContractHudController
participant Client as contract_client.gd
participant Server
participant Enc as encounter_progress_client
Player->>HUD: Shift+C
HUD->>Client: request_issue(prototype template, dev seed)
Client->>Server: POST …/contracts/issue
Server-->>Client: issued + active row (encounterTemplateId)
Client-->>HUD: contract_issue_result_received
HUD->>Client: request_sync_from_server()
Client->>Server: GET …/contracts
Server-->>HUD: contracts_received → ContractActiveLabel
Player->>Enc: defeat pocket NPCs
Enc->>Server: GET encounter-progress (via cast loop)
Server-->>Enc: encounter completed
Enc-->>HUD: main refresh hook
HUD->>Client: request_sync_from_server()
Server-->>HUD: completed row + completionRewardSummary
HUD-->>Player: ContractRewardDeliveryLabel grant lines
```
## Files to add
| Path | Rationale |
|------|-----------|
| `client/scripts/contract_client.gd` | GET/POST HTTP bridge + parse helpers + signals |
| `client/scripts/contract_client.gd.uid` | Godot uid companion (tracked) |
| `client/scripts/contract_hud_controller.gd` | Issue key, HUD render, reward transition |
| `client/scripts/contract_hud_controller.gd.uid` | Godot uid companion |
| `client/test/contract_client_test.gd` | GdUnit: parse GET/issue JSON; active row helper; sync/issue signals |
| `client/test/contract_client_test.gd.uid` | GdUnit uid companion |
| `client/test/contract_hud_controller_test.gd` | GdUnit: deny copy, active label with encounter id, reward transition |
| `client/test/contract_hud_controller_test.gd.uid` | GdUnit uid companion |
| `docs/manual-qa/NEO-153.md` | Godot manual QA — issue, track active, complete, reward line |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Add **`ContractClient`** node + three contract HUD labels |
| `client/scripts/main.gd` | Thin contract HUD setup, gameplay key route, encounter-complete refresh hook |
| `client/README.md` | Contract issue + progress HUD subsection (keys, refresh triggers, server deps NEO-151) |
## Tests
| File | Coverage |
|------|----------|
| `client/test/contract_client_test.gd` | **`parse_contracts_json`**: active + completed rows with **`encounterTemplateId`** + **`completionRewardSummary`**; schema mismatch → null; GET 404 → **`contracts_sync_failed`**; POST **`issued: true`** / **`issued: false`** + **`reasonCode`**; **`active_contract_row`** helper. AAA (`# Arrange` / `# Act` / `# Assert`). |
| `client/test/contract_hud_controller_test.gd` | Deny copy for **`economy_cap_exceeded`**, **`active_contract_exists`**; active label includes **`prototype_combat_pocket`**; reward label populates on simulated active→completed transition with summary grants. AAA layout. |
No new **C#** tests (client-only; server covered by **`ContractApiTests`**). No Bruno changes.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Fixed seed blocks re-issue after complete (same deterministic instance id) | **Accept for NEO-153** — single-loop AC; **NEO-154** adds seed rotation / fixture reset for repeat capstone | `adopted` |
| Duplicate reward formatter vs shared utility | **Duplicate minimal grant line helpers** inside **`contract_hud_controller.gd`** — avoid coupling to quest controller private methods | `adopted` |
| Issue while encounter already completed (server v1 edge) | **Document in manual QA** — prototype flow is issue-then-clear; HUD still shows **`active`** until server completes contract on a *future* matching clear (NEO-149 README edge case) | `adopted` |
| Concurrent GET + issue | Separate **`_sync_busy`** / **`_issue_busy`** guards; coalesce pending sync like quest client | `adopted` |
| Grant display names before item/faction defs load | Re-render contract reward label on **`definitions_ready`** / **`faction_standing_received`** when summary cached (NEO-122 item-defs re-paint precedent) | `adopted` |

View File

@ -0,0 +1,65 @@
# Code review — NEO-153 client contract issue + progress HUD
**Date:** 2026-06-28
**Scope:** Branch `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` — commits `9d756c2`..`365e577` (6 commits)
**Base:** `main`
**Issue:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — E7M4-10 client contract issue + progress HUD (Godot)
**Follow-up:** Review findings addressed in follow-up commits (issue-patch merge fix, docs, faction-standing re-paint, regression test).
## Verdict
~~**Request changes**~~ **Addressed** — blocking patch bug fixed; decomposition docs updated; follow-up tests green.
## Summary
This branch delivers the E7M4-10 Godot client slice: `contract_client.gd` (GET list + POST issue with separate sync/issue busy guards), `contract_hud_controller.gd` (Shift+C prototype issue, active/completed label, deny copy, in-session reward transition), thin `main.gd` wiring, scene labels, GdUnit coverage (12 cases), `client/README.md`, and manual QA. The implementation closely follows NEO-122/NEO-131 quest HUD patterns and pairs NEO-151 server HTTP correctly.
Overall risk is moderate: client-only, but **`_reapply_issue_contract_patch_if_needed` always merges a stale issue patch over authoritative GET snapshots**, which can regress `ContractActiveLabel` to **`active`** after the server reports **`completed`** — a primary acceptance-criteria failure. Docs tracking (E7M4 backlog AC, E7.M4 module page, alignment register) is not updated for the landed client slice. GdUnit contract tests pass locally.
## Documentation checked
| Document | Result |
|----------|--------|
| `docs/plans/NEO-153-implementation-plan.md` | **Matches** — shipped reconciliation aligns with code; AC checklist marked complete; kickoff decisions (separate controller, fixed dev seed, encounter-complete refresh) reflected |
| `docs/plans/E7M4-pre-production-backlog.md` | ~~**Partially matches**~~ **Matches** — E7M4-10 AC checked; **Landed (NEO-153)** note added **Done.** |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | ~~**Partially matches**~~ **Matches****Contract client HUD (NEO-153)** line added **Done.** |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches**~~ **Matches** — E7M4-10 / NEO-153 in E7.M4 row + trailing refs **Done.** |
| `docs/decomposition/modules/contracts.md` | **N/A** — policy doc; no client HUD surface |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays server snapshots; no client-side issue authority |
| `docs/manual-qa/NEO-153.md` | **Matches** — Godot-only checklist; edge cases documented |
| `client/README.md` | **Matches** — contract HUD subsection with keys, refresh triggers, server deps |
Register/tracking table and E7.M4 module page **should be updated** after merge (same pattern as NEO-152 follow-up).
## Blocking issues
1. ~~**Stale `_issue_contract_patch` overwrites completed contract row**~~**Done.** `_reapply_issue_contract_patch_if_needed` now merges only when the instance id is **missing** from the GET snapshot; otherwise clears `_issue_contract_patch` (NEO-122 accept-patch precedent). Regression test `test_completed_get_clears_stale_issue_patch_on_active_label` in `contract_hud_controller_test.gd`.
## Suggestions
1. ~~**Update decomposition docs for landed client slice**~~**Done.** `E7_M4_ContractMissionGenerator.md`, `documentation_and_implementation_alignment.md`, `E7M4-pre-production-backlog.md` updated.
2. ~~**Faction-standing reward re-paint (plan § Open questions)**~~**Done.** `contract_hud_controller.gd` connects `faction_standing_received` to re-render reward label when a cached summary exists.
3. **Split or label the `chore:` commit**`fea9928` (warnings-as-errors, dotnet format CI/hooks) is unrelated to NEO-153. Harmless on branch, but consider a separate PR or explicit note in merge description so reviewers do not conflate infra with HUD work. *(Deferred — merge-description note only.)*
## Nits
- ~~Nit: `_faction_standing_client` field is assigned in `setup()` but unused~~**Done.** wired for reward label re-paint.
- Nit: Branch includes a one-line server `CompareOrdinal` sort tie-break in `InMemoryContractInstanceStore.cs` — fine as hygiene; no NEO-153 client impact.
- ~~Nit: `test_reward_label_populates_on_active_to_completed_transition` bypasses the issue-patch path; extend after blocking fix so regression is covered.~~**Done.** `test_completed_get_clears_stale_issue_patch_on_active_label` added.
## Verification
```bash
# GdUnit (NEO-153 suites)
cd client && godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/contract_client_test.gd -a res://test/contract_hud_controller_test.gd
# Full client suite (CI parity)
cd client && godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test
# Server (unchanged behavior; optional)
dotnet test NeonSprawl.sln
```
**Manual (required before merge):** `docs/manual-qa/NEO-153.md` steps 15 — confirm **`ContractActiveLabel`** shows **`completed`** (not **`active`**) after pocket clear and reward line appears.

View File

@ -46,3 +46,12 @@ if has_staged_match '^client/scripts/.*\.gd$'; then
exit 1 exit 1
fi fi
fi fi
if has_staged_match '^server/.*\.cs$'; then
if ! "$repo_root/scripts/verify-dotnet-format.sh"; then
echo "pre-commit: dotnet format check failed (unnecessary usings / style)." >&2
echo "Fix with: dotnet format server/NeonSprawl.Server/NeonSprawl.Server.csproj --severity warn" >&2
echo " dotnet format server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --severity warn" >&2
exit 1
fi
fi

View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Verify dotnet format (IDE0005 unnecessary usings + other warn-severity style) with no auto-fixes.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root"
projects=(
server/NeonSprawl.Server/NeonSprawl.Server.csproj
server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
)
try_format() {
local proj="$1"
dotnet format "$proj" --verify-no-changes --severity warn --no-restore
}
restore_needed=false
for proj in "${projects[@]}"; do
if ! try_format "$proj" >/dev/null 2>&1; then
restore_needed=true
break
fi
done
if [[ "$restore_needed" == true ]]; then
dotnet restore NeonSprawl.sln --verbosity quiet
for proj in "${projects[@]}"; do
try_format "$proj"
done
fi

View File

@ -1,11 +1,9 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;

View File

@ -2,7 +2,6 @@ using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;

View File

@ -4,7 +4,6 @@ using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;

View File

@ -2,7 +2,6 @@ using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState;

View File

@ -1,7 +1,6 @@
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;

View File

@ -277,7 +277,7 @@ public sealed class InMemoryContractInstanceStore(IOptions<GamePositionOptions>
var cmp = b.IssuedAt.CompareTo(a.IssuedAt); var cmp = b.IssuedAt.CompareTo(a.IssuedAt);
return cmp != 0 return cmp != 0
? cmp ? cmp
: string.Compare(a.ContractInstanceId, b.ContractInstanceId, StringComparison.Ordinal); : string.CompareOrdinal(a.ContractInstanceId, b.ContractInstanceId);
}); });
if (completed.Count > maxCompletedRows) if (completed.Count > maxCompletedRows)