Compare commits
34 Commits
5fd0e19af4
...
41a284a24e
| Author | SHA1 | Date |
|---|---|---|
|
|
41a284a24e | |
|
|
cb95876f73 | |
|
|
1f43d7e26d | |
|
|
1900a6fb69 | |
|
|
ed032e8bb5 | |
|
|
2d131671c2 | |
|
|
01b627cc8a | |
|
|
23da39f7d3 | |
|
|
9ac4f27ea2 | |
|
|
71af7a5942 | |
|
|
3dccab8edb | |
|
|
e6f3d63318 | |
|
|
3a2fce2ce0 | |
|
|
4dc9f76f79 | |
|
|
d25d7d0211 | |
|
|
c4b71aac0d | |
|
|
b92b86a407 | |
|
|
acde9e8145 | |
|
|
aeb7277120 | |
|
|
09e160e955 | |
|
|
9aaa9cca56 | |
|
|
9c054fbcf2 | |
|
|
9894ce0d59 | |
|
|
a87556f88b | |
|
|
dd948120ff | |
|
|
f9d730749b | |
|
|
84f053d671 | |
|
|
96fe4d11f0 | |
|
|
42d0025457 | |
|
|
1882476fd3 | |
|
|
5127fe59a7 | |
|
|
acb8ccf3c1 | |
|
|
ada7630605 | |
|
|
7c2f913757 |
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
description: Commits at agent discretion; push only when user asks; gh pr create with NEO-* title when opening PRs; PR text without tool boilerplate
|
||||
description: Commits at agent discretion; push only when user asks; GitHub MCP create_pull_request with NEO-* title when opening PRs; PR text without tool boilerplate
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
|
|
@ -20,17 +20,20 @@ alwaysApply: true
|
|||
|
||||
- Do **not** add **“Made-with: Cursor”**, **“Generated with Cursor”**, tool co-author lines, or similar AI/IDE boilerplate to **PR descriptions**, **GitHub merge/squash commit bodies** you draft, or other **remote-facing** narrative unless the user explicitly requests it.
|
||||
- Keep PR text to scope, verification, and project-required contract snippets (e.g. from `docs/plans/`).
|
||||
- For **all GitHub operations** (PR create/update/comment/checks), use the **`user-github` MCP** tools. Do **not** use `gh` CLI for repository operations in this project.
|
||||
|
||||
### Opening a PR when the user asks
|
||||
|
||||
When the user asks to **open**, **create**, or **file** a pull request (or clearly wants one opened for the current branch):
|
||||
|
||||
1. **Use the GitHub CLI** — Run **`gh pr create`** (not only a browser **`/pull/new/...`** link). That way you control the **title** and avoid GitHub’s default, which title-cases the branch name and turns **`NEO-25`** into **`Neo 25`**.
|
||||
2. **Title** — **`--title`** must **begin** with the **Linear issue key** in canonical form: **`NEO-123:`** (see [linear-git-naming](linear-git-naming.md): uppercase team prefix, hyphen, issue number), then a short human summary. Example: **`NEO-25: InteractableDescriptor list / fetch-driven client`**.
|
||||
3. **Infer the key** — In order: the **current branch**’s leading segment (e.g. `NEO-25-my-slug` → `NEO-25`); the matching **`docs/plans/{KEY}-implementation-plan.md`** on the branch; Linear MCP / issue in chat; or **ask** the user if ambiguous.
|
||||
4. **Body** — Pass **`--body`** or **`--body-file`** with scope, how to verify (`dotnet test`, manual QA path, etc.), and pointers to `docs/plans/` when useful.
|
||||
5. **Upstream** — If **`gh pr create`** fails because the branch is not on `origin`, push first (**only** as allowed under **Never push** above—i.e. the same user message asked to open the PR or explicitly to push).
|
||||
6. **Fallback** — If **`gh`** is missing or not authenticated (`gh auth status`), tell the user and optionally still give the **`pull/new/...`** URL, with an explicit reminder to **edit the title** so it starts with **`NEO-123:`** (not `Neo 123`).
|
||||
1. **Use the GitHub MCP** — Call **`create_pull_request`** on the **`user-github`** MCP server (read the tool descriptor under the workspace MCP folder first, same as other MCP tools). Pass **`owner`**, **`repo`**, **`head`**, **`base`**, **`title`**, and **`body`**. You set the **title** explicitly—do not rely only on GitHub’s **Compare & pull request** default, which title-cases the branch and can turn **`NEO-25`** into **`Neo 25`**.
|
||||
2. **Repository** — Set **`owner`** and **`repo`** from **`git remote get-url origin`** (e.g. `git@github.com:ViPro-Technologies/neon-sprawl.git` → `ViPro-Technologies`, `neon-sprawl`). For this repo they are almost always **`ViPro-Technologies`** / **`neon-sprawl`**.
|
||||
3. **Branches** — **`head`** is the branch that contains the work (e.g. `NEO-26-selectionevent-surface-debug-consumer`); **`base`** is usually **`main`** unless the user names another target.
|
||||
4. **Title** — Must **begin** with the **Linear issue key** in canonical form: **`NEO-123:`** (see [linear-git-naming](linear-git-naming.md): uppercase team prefix, hyphen, issue number), then a short human summary. Example: **`NEO-25: InteractableDescriptor list / fetch-driven client`**.
|
||||
5. **Infer the key** — In order: the **current branch**’s leading segment (e.g. `NEO-25-my-slug` → `NEO-25`); the matching **`docs/plans/{KEY}-implementation-plan.md`** on the branch; Linear MCP / issue in chat; or **ask** the user if ambiguous.
|
||||
6. **Body** — Pass **`body`** with scope, how to verify (`dotnet test`, manual QA path, etc.), and pointers to `docs/plans/` when useful. When building JSON for the MCP tool, use **real newlines** in markdown strings (not literal `\n` escape sequences), per MCP guidance for this server.
|
||||
7. **Upstream** — If **`create_pull_request`** fails because **`head`** is not on **`origin`**, push first (**only** as allowed under **Never push** above—i.e. the same user message asked to open the PR or explicitly to push).
|
||||
8. **Fallback** — If the GitHub MCP is unavailable or the call fails after push, give **`https://github.com/{owner}/{repo}/pull/new/{head}`** and tell the user to set the PR title to **`NEO-123:`** … . Do not switch to `gh` as an agent fallback.
|
||||
|
||||
## Commit message format when a Linear issue applies
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ public async Task PostMove_ShouldReturnBadRequest_WhenSchemaVersionIsWrong() {
|
|||
- **Act:** invoke the single behavior under test. For integration tests, the Act block may include a short sequence of calls if that sequence *is* the behavior (e.g. POST then GET to verify persistence)—keep it one clear scenario per test.
|
||||
- **Assert:** all expectations (`Assert.*`, etc.); read response bodies here when the read is for verification, not for driving the next call (otherwise fold those reads into Act).
|
||||
- Multi-scenario files stay readable with **one AAA triple per `[Fact]` / `[Theory]`** method.
|
||||
- **Write-time default:** use the workspace VS Code snippet prefix **`xut`** (method) or **`xutc`** (class) from `.vscode/csharp.code-snippets` when authoring tests so AAA sections are present from the first draft.
|
||||
- **Template reference:** `server/NeonSprawl.Server.Tests/TestTemplates/AAA_TEST_METHOD_TEMPLATE.cs.txt`.
|
||||
|
||||
## Tooling
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ alwaysApply: true
|
|||
**Agent:** You may **`git commit`** at your discretion while on story/ticket work; do **not** **`git push`** except when the user explicitly asks (see [commit-and-review](commit-and-review.md) **Never push** and **Opening a PR when the user asks**).
|
||||
|
||||
- **Beginning work on a new Linear issue** — Create a **new branch** as soon as story work starts (planning, implementation, or both). **Branch names must start with the Linear issue id** (e.g. `NEO-6-position-state-api`); see [linear-git-naming](linear-git-naming.md). Stay on that branch for everything scoped to that issue, including **`docs/plans/{KEY}-implementation-plan.md`**, until the story is merged. Do not put ticketed story plans only on `main` while implementation lives on a branch.
|
||||
- **Branching from `main` requires a fresh pull first** — before creating a new story branch from `main`, run `git fetch origin`, `git checkout main`, and `git pull --ff-only` so the branch starts from the latest remote `main`. Do not branch from a stale local `main`.
|
||||
|
||||
- **Documentation-only work** — Commit directly on **`main`** when the change is **not** tied to an active Linear issue branch: general Markdown under `docs/` (except per-story implementation plans for an issue you are working on that branch), root and nested `README.md` files, `neon_sprawl_vision.plan.md`, and other cross-cutting prose. No feature branch required.
|
||||
|
||||
|
|
@ -17,4 +18,4 @@ When a change mixes documentation and code, treat it as a **code change** (use a
|
|||
|
||||
After the PR for a story branch is **merged**, clean up locally per [story-end](story-end.md) (`checkout main`, `pull`, `branch -d`).
|
||||
|
||||
When the user asks the agent to **open a PR**, use **`gh pr create`** with a **`NEO-*:`** title prefix per [commit-and-review](commit-and-review.md) **Opening a PR when the user asks**—not only a GitHub **Compare & pull request** link (the web UI mangles issue-key casing in the default title).
|
||||
When the user asks the agent to **open a PR**, use the **GitHub MCP** **`create_pull_request`** tool with a **`NEO-*:`** title prefix per [commit-and-review](commit-and-review.md) **Opening a PR when the user asks**—not only a GitHub **Compare & pull request** link (the web UI mangles issue-key casing in the default title).
|
||||
|
|
|
|||
|
|
@ -32,4 +32,4 @@ When suggesting or creating a branch for **NEO-5**, use something like **`NEO-5-
|
|||
## Agent behavior
|
||||
|
||||
- When creating a **branch** or **commit** for tracked work, **infer the id** from the current story (e.g. user says NEO-5), the branch already in use, or the Linear issue fetched via MCP (`plugin-linear-linear`, e.g. `get_issue` / `list_issues`).
|
||||
- When the user asks the agent to **open a PR**, the PR **title** must start with that same **`NEO-*:`** form (use **`gh pr create --title`** per [commit-and-review](commit-and-review.md)); do not ship a default web-only title that lowercases the key to **`Neo`**.
|
||||
- When the user asks the agent to **open a PR**, the PR **title** must start with that same **`NEO-*:`** form (set **`title`** on the GitHub MCP **`create_pull_request`** call per [commit-and-review](commit-and-review.md)); do not ship a default web-only title that lowercases the key to **`Neo`**.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ When the user **ends story work** on a ticketed branch—phrases like “end sto
|
|||
|
||||
## 2a. When `main` is push-protected
|
||||
|
||||
If the user asks to **push** changes that sit on **`main`** and **`git push origin main` fails** (required PR / status checks), create a branch from current `main` (e.g. `chore/…`), **`git push -u origin`** that branch, then open the PR with **`gh pr create`** and a title keyed per [commit-and-review](commit-and-review.md) **Opening a PR when the user asks** (or give the **`pull/new/...`** URL **and** remind them to fix the title to **`NEO-123:`**). **`git reset --hard origin/main`** if you had temporarily advanced local `main` so local `main` matches remote until the PR merges.
|
||||
If the user asks to **push** changes that sit on **`main`** and **`git push origin main` fails** (required PR / status checks), create a branch from current `main` (e.g. `chore/…`), **`git push -u origin`** that branch, then open the PR with the **GitHub MCP** **`create_pull_request`** tool and a title keyed per [commit-and-review](commit-and-review.md) **Opening a PR when the user asks** (or give the **`pull/new/...`** URL **and** remind them to fix the title to **`NEO-123:`**). **`git reset --hard origin/main`** if you had temporarily advanced local `main` so local `main` matches remote until the PR merges.
|
||||
|
||||
## 3. What not to do
|
||||
|
||||
|
|
@ -58,4 +58,4 @@ Then give any **git** details (branch deleted, `main` ahead, etc.) **after** the
|
|||
- [story-kickoff](story-kickoff.md) — start of story workflow
|
||||
- [git-workflow](git-workflow.md) — branch vs `main`
|
||||
- [linear-git-naming](linear-git-naming.md) — branch names
|
||||
- [commit-and-review](commit-and-review.md) — push and **`gh pr create`** only when the user requests it; PR title **`NEO-*:`** prefix
|
||||
- [commit-and-review](commit-and-review.md) — push and **GitHub MCP `create_pull_request`** only when the user requests it; PR title **`NEO-*:`** prefix
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
|
|||
- Use **concrete questions**, often with short options when helpful; group related questions so the user can answer in one pass.
|
||||
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists.
|
||||
|
||||
### Clarifications gate (hard requirement)
|
||||
|
||||
- **Do not** create or update `docs/plans/{KEY}-implementation-plan.md` until clarification questions have been asked and the user has answered (or explicitly says no questions are needed).
|
||||
- If at least one **blocking decision** exists in planning, use **`AskQuestion`** (Agent mode) instead of prose-only questions.
|
||||
- If kickoff has zero questions, explicitly record why in the plan under a **Kickoff clarifications** section with evidence from Linear + repo context.
|
||||
- **Do not commit** a kickoff plan until the clarification step above is complete.
|
||||
|
||||
## 1. Load Linear context
|
||||
|
||||
- Fetch the issue when possible (Linear MCP `get_issue` / `list_issues`, server **`plugin-linear-linear`**), or use a URL / pasted description the user provides.
|
||||
|
|
@ -48,6 +55,7 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
|
|||
**Required sections** in that file (do **not** ship a plan that omits any of these):
|
||||
|
||||
- Story reference (key, title, link if available)
|
||||
- Kickoff clarifications (questions asked + answers, or explicit “No clarifications needed” with reason)
|
||||
- Goal, scope, and out-of-scope (from Linear)
|
||||
- Acceptance criteria checklist (from Linear)
|
||||
- Technical approach (concise)
|
||||
|
|
@ -67,4 +75,4 @@ These three lists (**files to add**, **files to modify**, **tests**) must **alwa
|
|||
|
||||
When the story is **done** and merged to `main`, follow [story-end](story-end.md) to return to `main`, pull, and remove the local story branch.
|
||||
|
||||
When the user asks to **open or create a PR** for this story, follow [commit-and-review](commit-and-review.md) **Opening a PR when the user asks**: use **`gh pr create`** with a title that **starts with the Linear key** (e.g. **`NEO-25:`**), not GitHub’s default title from the web UI alone.
|
||||
When the user asks to **open or create a PR** for this story, follow [commit-and-review](commit-and-review.md) **Opening a PR when the user asks**: use the **GitHub MCP** **`create_pull_request`** tool with a title that **starts with the Linear key** (e.g. **`NEO-25:`**), not GitHub’s default title from the web UI alone.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ alwaysApply: true
|
|||
- **Non-trivial logic** (validation, simulation rules, serialization boundaries, idempotency, security-sensitive paths) should have **automated tests** once a test project exists in the repo.
|
||||
- **Default framework:** **xUnit** for new test projects unless the repo already standardizes on something else—stay consistent with existing `*.Tests` projects.
|
||||
- **Layout:** use **Arrange / Act / Assert** in every test method; see [C# style — Unit and integration tests](csharp-style.md#unit-and-integration-tests-arrange-act-assert).
|
||||
- **At-write-time default:** start new tests with snippet **`xut`** / **`xutc`** from `.vscode/csharp.code-snippets` (AAA sections pre-inserted) or copy `server/NeonSprawl.Server.Tests/TestTemplates/AAA_TEST_METHOD_TEMPLATE.cs.txt`.
|
||||
- **Names:** **`MethodName_ShouldExpectedOutcome_WhenScenario`**; see [C# style — Test method naming convention](csharp-style.md#test-method-naming-convention).
|
||||
- **No test project yet:** It is acceptable to ship a small change without a suite only while the server is mostly scaffolding. When you add the **first** testable module, **add a `NeonSprawl.Server.Tests` (or equivalent) project** and start covering that area; do not leave new core logic permanently untested.
|
||||
- **Bug fixes:** Prefer a **regression test** when the fix is non-obvious or has broken before.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"xUnit Test Method AAA": {
|
||||
"scope": "csharp",
|
||||
"prefix": "xut",
|
||||
"description": "xUnit test method with explicit Arrange/Act/Assert",
|
||||
"body": [
|
||||
"[Fact]",
|
||||
"public async Task ${1:MethodName}_Should${2:ExpectedOutcome}_When${3:Scenario}()",
|
||||
"{",
|
||||
" // Arrange",
|
||||
" ${4:// setup}",
|
||||
"",
|
||||
" // Act",
|
||||
" ${5:// execute}",
|
||||
"",
|
||||
" // Assert",
|
||||
" ${6:// verify}",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"xUnit Test Class AAA": {
|
||||
"scope": "csharp",
|
||||
"prefix": "xutc",
|
||||
"description": "xUnit test class scaffold with AAA guidance",
|
||||
"body": [
|
||||
"using Xunit;",
|
||||
"",
|
||||
"namespace ${1:NeonSprawl.Server.Tests};",
|
||||
"",
|
||||
"public sealed class ${2:Feature}Tests",
|
||||
"{",
|
||||
" [Fact]",
|
||||
" public async Task ${3:MethodName}_Should${4:ExpectedOutcome}_When${5:Scenario}()",
|
||||
" {",
|
||||
" // Arrange",
|
||||
" ${6:// setup}",
|
||||
"",
|
||||
" // Act",
|
||||
" ${7:// execute}",
|
||||
"",
|
||||
" // Assert",
|
||||
" ${8:// verify}",
|
||||
" }",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
# Agents (Neon Sprawl)
|
||||
|
||||
## Critical GitHub tooling rule
|
||||
|
||||
- Agents must use **GitHub MCP** (`user-github`) for all GitHub operations.
|
||||
- Agents must **never** use `gh` CLI in this repository.
|
||||
- If MCP is unavailable/auth-failed, agents must stop and ask the user (no `gh` fallback).
|
||||
|
||||
Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or by asking explicitly (e.g. “review this as the code review agent”).
|
||||
|
||||
| Agent | Rule file | When to use |
|
||||
|
|
@ -7,6 +13,6 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
|
|||
| **Code review** | [`.cursor/rules/code-review-agent.md`](.cursor/rules/code-review-agent.md) | PR / diff / pre-merge review; **always writes** `docs/reviews/YYYY-MM-DD-{slug}.md` with **Documentation checked** vs `docs/plans/` and `docs/decomposition/modules/`; **when suggestions are fixed, strike through those bullets + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); short chat pointer |
|
||||
| **Docs review** | [`.cursor/rules/docs-review-agent.md`](.cursor/rules/docs-review-agent.md) | Coherence / links / dev-guide fitness for `docs/` (especially `docs/game-design/` + decomposition); **writes** `docs/reviews/YYYY-MM-DD-{slug}.md`; **when suggestions are fixed, strike through + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); use when working in **documents** or @ mention |
|
||||
|
||||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** agents may **commit** at discretion on story/ticket work; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use **`gh pr create`** with a title starting **`NEO-123:`** (same rule file). **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
|
||||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** agents may **commit** at discretion on story/ticket work; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use the **GitHub MCP** **`create_pull_request`** tool (`user-github`) with a title starting **`NEO-123:`** ([commit-and-review](.cursor/rules/commit-and-review.md)); **do not use `gh` CLI for GitHub operations in this repo**. **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
|
||||
|
||||
**Commits tied to a Linear issue** must start the subject with the **issue id** and a colon (e.g. `NEO-8: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/linear-git-naming.md`](.cursor/rules/linear-git-naming.md).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
meta {
|
||||
name: hotbar-loadout get missing player
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/missing-player/hotbar-loadout
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 404", function () {
|
||||
expect(res.getStatus()).to.equal(404);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
meta {
|
||||
name: hotbar-loadout get
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("schema and slots present", function () {
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.slotCount).to.equal(8);
|
||||
expect(Array.isArray(body.slots)).to.equal(true);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
meta {
|
||||
name: hotbar-loadout post bad schema
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 999,
|
||||
"slots": [
|
||||
{
|
||||
"slotIndex": 0,
|
||||
"abilityId": "prototype_pulse"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 400", function () {
|
||||
expect(res.getStatus()).to.equal(400);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
meta {
|
||||
name: hotbar-loadout post bind
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slots": [
|
||||
{
|
||||
"slotIndex": 0,
|
||||
"abilityId": "prototype_pulse"
|
||||
},
|
||||
{
|
||||
"slotIndex": 3,
|
||||
"abilityId": "prototype_burst"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("updated true and payload shape", function () {
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.updated).to.equal(true);
|
||||
expect(body.loadout.slotCount).to.equal(8);
|
||||
});
|
||||
}
|
||||
|
|
@ -107,6 +107,26 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
|
|||
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
|
||||
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
|
||||
|
||||
### SelectionEvent (NEO-26)
|
||||
|
||||
- **`TargetSelectionClient`** emits **`selection_event(event: Dictionary)`** only when the server-acknowledged **`lockedTargetId`** changes (not on **`validity`-only** updates; those stay on **`target_state_changed`**).
|
||||
- Payload keys: **`previous`** ([String] or `null`), **`next`** (same), **`cause`** (`tab` \| `clear` \| `server_correction`), **`sequence`** (int from the new state). **`POST …/target/select`** from **`request_select_target_id`** (including the path used by Tab) uses cause **`tab`**; **`request_clear_target`** uses **`clear`**; every **`GET …/target`** completion uses **`server_correction`** (including boot sync).
|
||||
- **Slice 3 telemetry hook (NEO-27):** this selection transition maps to product event name **`target_changed`**; with **`log_selection_events`** enabled, Godot Output prints the `target_changed` token for manual verification. **TODO(E9.M1):** replace dev log with cataloged telemetry emit.
|
||||
- **Ability telemetry reserves (NEO-27):** `scripts/main.gd` documents future hook sites for **`ability_cast_requested`** / **`ability_cast_denied`** (implemented in E1.M4 + E5.M1, not in this story).
|
||||
- **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scene’s path to that node) without reshaping the payload.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).
|
||||
|
||||
## Hotbar loadout hydration (NEO-29)
|
||||
|
||||
`scripts/hotbar_loadout_client.gd` fetches server-owned `HotbarLoadout` state and applies it to `scripts/hotbar_state.gd`:
|
||||
|
||||
- On `main.gd` startup, `_setup_hotbar_loadout_sync()` creates `HotbarState` + `HotbarLoadoutClient` nodes, mirrors `base_url`/`dev_player_id` from `PositionAuthorityClient`, and calls `request_sync_from_server()`.
|
||||
- `HotbarState` keeps a fixed **8-slot** local mirror (`Array` indexed `0..7`) with nullable ability ids.
|
||||
- `HotbarLoadoutClient.request_bind_slot(slot_index, ability_id)` POSTs one slot update to `/game/players/{id}/hotbar-loadout`; denials emit `loadout_update_denied(reason_code, loadout)` so future UI can surface machine-readable errors directly.
|
||||
|
||||
This story hydrates bindings only; cast execution and cooldown behavior stay in later E1.M4 / E5.M1 stories.
|
||||
|
||||
### Manual check (NEO-24)
|
||||
|
||||
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
|
||||
|
|
@ -172,7 +192,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
|
|||
|
||||
**Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor.
|
||||
|
||||
**Git hook (recommended):** install the repo’s local **pre-push** GDScript lint hook from the repo root (once per clone):
|
||||
**Git hooks (recommended):** install the repo’s local hooks from the repo root (once per clone):
|
||||
|
||||
```bash
|
||||
./scripts/install-git-hooks.sh
|
||||
|
|
@ -184,7 +204,12 @@ On **Windows** (PowerShell), same hook:
|
|||
pwsh -File scripts/install-git-hooks.ps1
|
||||
```
|
||||
|
||||
It runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** before each push. It prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** and blocks the push — same as CI.
|
||||
This installs:
|
||||
|
||||
- **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-push**: runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`**.
|
||||
|
||||
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** and blocks the push — same as CI.
|
||||
|
||||
**If CI fails gdlint but your push succeeded:** the hook was not installed, **`gdlint`/`gdformat` were missing** when the hook ran, or push bypassed hooks (`--no-verify`).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
extends Node
|
||||
|
||||
## NEO-29: client bridge for server-owned `HotbarLoadout` v1 fetch/update.
|
||||
signal loadout_changed(loadout: Dictionary)
|
||||
signal loadout_update_denied(reason_code: String, loadout: Dictionary)
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _last_loadout: Dictionary = {}
|
||||
var _hotbar_state: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func set_hotbar_state(state_node: Node) -> void:
|
||||
_hotbar_state = state_node
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/hotbar-loadout" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
push_warning("HotbarLoadoutClient: GET failed to start (%s)" % err)
|
||||
_busy = false
|
||||
|
||||
|
||||
func request_bind_slot(slot_index: int, ability_id: String) -> void:
|
||||
if _busy:
|
||||
return
|
||||
var payload: Dictionary = {
|
||||
"schemaVersion": 1,
|
||||
"slots": [{"slotIndex": slot_index, "abilityId": ability_id.strip_edges()}],
|
||||
}
|
||||
_post_update(payload)
|
||||
|
||||
|
||||
func cached_loadout() -> Dictionary:
|
||||
return _last_loadout.duplicate(true)
|
||||
|
||||
|
||||
func _post_update(payload: Dictionary) -> void:
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/hotbar-loadout" % [_base_root(), _player_path_segment()]
|
||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
push_warning("HotbarLoadoutClient: POST failed to start (%s)" % err)
|
||||
_busy = false
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_warning("HotbarLoadoutClient: HTTP failed (result=%s)" % result)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
if response_code != 200:
|
||||
push_warning("HotbarLoadoutClient: HTTP %s non-JSON body" % response_code)
|
||||
return
|
||||
var data: Dictionary = parsed
|
||||
var loadout: Dictionary = data
|
||||
if data.has("loadout") and data["loadout"] is Dictionary:
|
||||
loadout = data["loadout"]
|
||||
if loadout.is_empty() or not loadout.has("slots"):
|
||||
return
|
||||
_last_loadout = loadout.duplicate(true)
|
||||
_apply_state(loadout)
|
||||
loadout_changed.emit(_last_loadout.duplicate(true))
|
||||
if data.has("updated") and not bool(data.get("updated", false)):
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason: String = reason_variant as String if reason_variant is String else ""
|
||||
if not reason.is_empty():
|
||||
loadout_update_denied.emit(reason, _last_loadout.duplicate(true))
|
||||
|
||||
|
||||
func _apply_state(loadout: Dictionary) -> void:
|
||||
if _hotbar_state == null or not is_instance_valid(_hotbar_state):
|
||||
return
|
||||
if not _hotbar_state.has_method("apply_slots"):
|
||||
return
|
||||
var slots_variant: Variant = loadout.get("slots", [])
|
||||
if slots_variant is Array:
|
||||
_hotbar_state.call("apply_slots", slots_variant as Array)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
extends Node
|
||||
|
||||
## Local hotbar slot state mirror for NEO-29. Holds slot -> ability mapping from
|
||||
## server-owned `HotbarLoadout` payloads.
|
||||
signal hotbar_changed(slots: Array)
|
||||
|
||||
const SLOT_COUNT: int = 8
|
||||
|
||||
var _slots: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if _slots.is_empty():
|
||||
_slots.resize(SLOT_COUNT)
|
||||
for i in range(SLOT_COUNT):
|
||||
_slots[i] = null
|
||||
|
||||
|
||||
func apply_slots(slots: Array) -> void:
|
||||
var normalized: Array = []
|
||||
normalized.resize(SLOT_COUNT)
|
||||
for i in range(SLOT_COUNT):
|
||||
normalized[i] = null
|
||||
for slot_variant in slots:
|
||||
if not slot_variant is Dictionary:
|
||||
continue
|
||||
var slot: Dictionary = slot_variant
|
||||
var idx: int = int(slot.get("slotIndex", -1))
|
||||
if idx < 0 or idx >= SLOT_COUNT:
|
||||
continue
|
||||
var ability_variant: Variant = slot.get("abilityId", null)
|
||||
if ability_variant is String and not (ability_variant as String).is_empty():
|
||||
normalized[idx] = ability_variant as String
|
||||
else:
|
||||
normalized[idx] = null
|
||||
_slots = normalized
|
||||
hotbar_changed.emit(_slots.duplicate())
|
||||
|
||||
|
||||
func slots_snapshot() -> Array:
|
||||
return _slots.duplicate()
|
||||
|
|
@ -64,6 +64,8 @@ var _dev_saved_obstacle_mask: int = -1
|
|||
var _dev_saved_obstacle_process_mode: Node.ProcessMode = Node.PROCESS_MODE_INHERIT
|
||||
## Cached `Obstacle` (path breaks after reparent for nav bake; no fixed [NodePath]).
|
||||
var _dev_obstacle_smoke: Node3D
|
||||
var _hotbar_state: Node = null
|
||||
var _hotbar_client: Node = null
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -116,6 +118,7 @@ func _ready() -> void:
|
|||
_catalog.call("request_catalog")
|
||||
_authority.call("sync_from_server")
|
||||
_target_client.call("request_sync_from_server")
|
||||
_setup_hotbar_loadout_sync()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
|
|
@ -200,6 +203,11 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
|||
## surface client-vs-server divergence. This is the same signal `TargetSelectionClient`
|
||||
## hooks for movement-driven lock refresh — we do not snap the capsule to this value
|
||||
## (that is `authoritative_position_received`'s job on boot + move rejection).
|
||||
##
|
||||
## NEO-27 telemetry reserve (input/cast path not implemented in E1.M3):
|
||||
## - `ability_cast_requested` hook will attach in E1.M4 input wiring before cast submit.
|
||||
## - `ability_cast_denied` hook will attach on cast-response denial (`reasonCode`) in E1.M4+E5.M1.
|
||||
## TODO(E9.M1): map both to the telemetry schema/catalog once available.
|
||||
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
|
||||
_last_ack_world = world
|
||||
_have_last_ack = true
|
||||
|
|
@ -216,6 +224,27 @@ func _on_target_state_changed(state: Dictionary) -> void:
|
|||
_render_target_lock_label(_player.global_position)
|
||||
|
||||
|
||||
func _setup_hotbar_loadout_sync() -> void:
|
||||
# NEO-29: hydrate a local hotbar state mirror from server-owned HotbarLoadout on boot.
|
||||
_hotbar_state = load("res://scripts/hotbar_state.gd").new()
|
||||
_hotbar_state.name = "HotbarState"
|
||||
add_child(_hotbar_state)
|
||||
_hotbar_client = load("res://scripts/hotbar_loadout_client.gd").new()
|
||||
_hotbar_client.name = "HotbarLoadoutClient"
|
||||
add_child(_hotbar_client)
|
||||
if is_instance_valid(_authority):
|
||||
var authority_base_url: Variant = _authority.get("base_url")
|
||||
var authority_player_id: Variant = _authority.get("dev_player_id")
|
||||
if authority_base_url is String and not (authority_base_url as String).is_empty():
|
||||
_hotbar_client.set("base_url", authority_base_url)
|
||||
if authority_player_id is String and not (authority_player_id as String).is_empty():
|
||||
_hotbar_client.set("dev_player_id", authority_player_id)
|
||||
if _hotbar_client.has_method("set_hotbar_state"):
|
||||
_hotbar_client.call("set_hotbar_state", _hotbar_state)
|
||||
if _hotbar_client.has_method("request_sync_from_server"):
|
||||
_hotbar_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
## Combines cached server state (`_last_target_state`) with per-anchor horizontal
|
||||
## distance computed from the live capsule position. Distances are a **display-only**
|
||||
## hint — the server remains authoritative for `validity`. If the HUD shows
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
extends Node
|
||||
|
||||
## NEO-24 (E1M3-02): client tab-target + lock UI synced to server (NEO-23 `TargetState` v1).
|
||||
## NEO-26 (E1M3-04): emits [signal selection_event] when server-acknowledged
|
||||
## [code]lockedTargetId[/code] changes (see [member log_selection_events]).
|
||||
##
|
||||
## Flow:
|
||||
## - `request_sync_from_server()` → `GET …/target` returns `PlayerTargetStateResponse` v1.
|
||||
|
|
@ -19,6 +21,15 @@ extends Node
|
|||
|
||||
signal target_state_changed(state: Dictionary)
|
||||
|
||||
## NEO-26: fired only when normalized [code]lockedTargetId[/code] changes after a GET/POST.
|
||||
## Payload: [code]previous[/code] ([String] or [code]null[/code]), [code]next[/code] (same),
|
||||
## [code]cause[/code] ([code]"tab"[/code] | [code]"clear"[/code] |
|
||||
## [code]"server_correction"[/code]), [code]sequence[/code] ([int], from new state).
|
||||
## E1.M4+ may [code]connect[/code] here without reshaping.
|
||||
## NEO-27 telemetry hook: this transition maps to product event name [code]target_changed[/code]
|
||||
## (TODO(E9.M1): wire schema/catalog and ingest once telemetry contracts land).
|
||||
signal selection_event(event: Dictionary)
|
||||
|
||||
enum Phase { IDLE, GET, POST_SELECT }
|
||||
|
||||
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
|
||||
|
|
@ -30,6 +41,10 @@ const _REFRESH_COOLDOWN_MSEC: int = 250
|
|||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
|
||||
## When true, each [signal selection_event] is also printed to the Godot output
|
||||
## (dev aid only).
|
||||
@export var log_selection_events: bool = false
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _phase: Phase = Phase.IDLE
|
||||
|
|
@ -54,6 +69,11 @@ var _state: Dictionary = {}
|
|||
## `-_REFRESH_COOLDOWN_MSEC` so the first snap is never blocked by the window on boot.
|
||||
var _last_refresh_msec: int = -_REFRESH_COOLDOWN_MSEC
|
||||
|
||||
## Cause for the in-flight HTTP request (GET → [code]server_correction[/code];
|
||||
## POST → [code]tab[/code] / [code]clear[/code]).
|
||||
## Set immediately before a request starts; cleared when the response is applied or dropped.
|
||||
var _http_selection_cause: String = ""
|
||||
|
||||
|
||||
func _create_http_request() -> Node:
|
||||
return HTTPRequest.new()
|
||||
|
|
@ -81,6 +101,7 @@ func _input(event: InputEvent) -> void:
|
|||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_http_selection_cause = "server_correction"
|
||||
_busy = true
|
||||
_phase = Phase.GET
|
||||
_last_refresh_msec = Time.get_ticks_msec()
|
||||
|
|
@ -90,6 +111,7 @@ func request_sync_from_server() -> void:
|
|||
push_warning("TargetSelectionClient: GET failed to start (%s)" % err)
|
||||
_busy = false
|
||||
_phase = Phase.IDLE
|
||||
_http_selection_cause = ""
|
||||
|
||||
|
||||
func request_tab_next() -> void:
|
||||
|
|
@ -117,12 +139,12 @@ func request_select_target_id(target_id: String) -> void:
|
|||
return
|
||||
var payload: Dictionary = {"schemaVersion": 1, "targetId": trimmed}
|
||||
_maybe_attach_position_hint(payload)
|
||||
_post_select(payload)
|
||||
_post_select(payload, "tab")
|
||||
|
||||
|
||||
func request_clear_target() -> void:
|
||||
# NEO-23: omit `targetId` to clear. JSON `null` also works; omission keeps the body minimal.
|
||||
_post_select({"schemaVersion": 1})
|
||||
_post_select({"schemaVersion": 1}, "clear")
|
||||
|
||||
|
||||
## NEO-24 follow-up #5: attach the live capsule position as an advisory `positionHint` so the
|
||||
|
|
@ -172,9 +194,10 @@ func _has_lock() -> bool:
|
|||
return v is String and not (v as String).is_empty()
|
||||
|
||||
|
||||
func _post_select(payload: Dictionary) -> void:
|
||||
func _post_select(payload: Dictionary, selection_cause: String) -> void:
|
||||
if _busy:
|
||||
return
|
||||
_http_selection_cause = selection_cause
|
||||
_kick_freshness_stream()
|
||||
_busy = true
|
||||
_phase = Phase.POST_SELECT
|
||||
|
|
@ -186,6 +209,7 @@ func _post_select(payload: Dictionary) -> void:
|
|||
push_warning("TargetSelectionClient: POST failed to start (%s)" % err)
|
||||
_busy = false
|
||||
_phase = Phase.IDLE
|
||||
_http_selection_cause = ""
|
||||
|
||||
|
||||
## Nudges the authority with the current capsule position so the server's stored snapshot
|
||||
|
|
@ -220,6 +244,7 @@ func _on_request_completed(
|
|||
|
||||
if _result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_warning("TargetSelectionClient: HTTP failed (result=%s)" % _result)
|
||||
_http_selection_cause = ""
|
||||
return
|
||||
|
||||
var text := body.get_string_from_utf8()
|
||||
|
|
@ -227,6 +252,7 @@ func _on_request_completed(
|
|||
if not parsed is Dictionary:
|
||||
if response_code != 200:
|
||||
push_warning("TargetSelectionClient: HTTP %s non-JSON body" % response_code)
|
||||
_http_selection_cause = ""
|
||||
return
|
||||
var data: Dictionary = parsed
|
||||
|
||||
|
|
@ -234,37 +260,51 @@ func _on_request_completed(
|
|||
Phase.GET:
|
||||
if response_code == 200:
|
||||
_update_state_from_get(data)
|
||||
else:
|
||||
_http_selection_cause = ""
|
||||
Phase.POST_SELECT:
|
||||
# Server includes authoritative `targetState` on 200 (apply and denial). 400 / 404
|
||||
# bodies may lack it — swallow quietly; `_state` keeps last-good.
|
||||
if response_code == 200:
|
||||
_update_state_from_post(data)
|
||||
else:
|
||||
_http_selection_cause = ""
|
||||
_:
|
||||
pass
|
||||
_http_selection_cause = ""
|
||||
|
||||
|
||||
func _update_state_from_get(data: Dictionary) -> void:
|
||||
var state := _extract_target_state_fields(data)
|
||||
var cause := _http_selection_cause
|
||||
_http_selection_cause = ""
|
||||
if state.is_empty():
|
||||
return
|
||||
var prev: Dictionary = _state.duplicate()
|
||||
_state = state
|
||||
target_state_changed.emit(_state.duplicate())
|
||||
_maybe_emit_selection_event(prev, state, cause)
|
||||
|
||||
|
||||
func _update_state_from_post(data: Dictionary) -> void:
|
||||
var target_state_variant: Variant = data.get("targetState", null)
|
||||
if not target_state_variant is Dictionary:
|
||||
_http_selection_cause = ""
|
||||
push_warning("TargetSelectionClient: POST 200 missing targetState")
|
||||
return
|
||||
var state := _extract_target_state_fields(target_state_variant as Dictionary)
|
||||
if state.is_empty():
|
||||
_http_selection_cause = ""
|
||||
return
|
||||
var cause := _http_selection_cause
|
||||
_http_selection_cause = ""
|
||||
state["selectionApplied"] = bool(data.get("selectionApplied", false))
|
||||
var reason_variant: Variant = data.get("reasonCode", null)
|
||||
if reason_variant is String and not (reason_variant as String).is_empty():
|
||||
state["reasonCode"] = reason_variant as String
|
||||
var prev: Dictionary = _state.duplicate()
|
||||
_state = state
|
||||
target_state_changed.emit(_state.duplicate())
|
||||
_maybe_emit_selection_event(prev, state, cause)
|
||||
|
||||
|
||||
## Shape-only parse of the v1 target state payload (works for GET body and POST echo).
|
||||
|
|
@ -285,3 +325,34 @@ func _extract_target_state_fields(data: Dictionary) -> Dictionary:
|
|||
out["validity"] = (validity_variant as String) if validity_variant is String else "none"
|
||||
out["sequence"] = int(data.get("sequence", 0))
|
||||
return out
|
||||
|
||||
|
||||
func _normalized_lock_id(state: Dictionary) -> Variant:
|
||||
if state.is_empty():
|
||||
return null
|
||||
var v: Variant = state.get("lockedTargetId", null)
|
||||
if v is String and not (v as String).is_empty():
|
||||
return v as String
|
||||
return null
|
||||
|
||||
|
||||
func _maybe_emit_selection_event(
|
||||
prev_state: Dictionary, new_state: Dictionary, cause: String
|
||||
) -> void:
|
||||
var prev_id: Variant = _normalized_lock_id(prev_state)
|
||||
var next_id: Variant = _normalized_lock_id(new_state)
|
||||
if prev_id == next_id:
|
||||
return
|
||||
if cause.is_empty():
|
||||
return
|
||||
var ev: Dictionary = {
|
||||
"previous": prev_id,
|
||||
"next": next_id,
|
||||
"cause": cause,
|
||||
"sequence": int(new_state.get("sequence", 0)),
|
||||
}
|
||||
selection_event.emit(ev)
|
||||
if log_selection_events:
|
||||
# NEO-27 telemetry hook site: `target_changed` mirrors this payload shape.
|
||||
# TODO(E9.M1): replace dev print with cataloged telemetry emit.
|
||||
print("[TargetSelectionClient] target_changed ", ev)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
# Note: declaration-order-only edits in main scene scripts can still trigger hook-required
|
||||
# client test restaging; this suite is included in the same commit without behavior changes.
|
||||
const HotbarClient := preload("res://scripts/hotbar_loadout_client.gd")
|
||||
const HotbarState := preload("res://scripts/hotbar_state.gd")
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var last_body: String = ""
|
||||
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
|
||||
var _queue: Array[Dictionary] = []
|
||||
|
||||
func enqueue(result: int, code: int, body: String) -> void:
|
||||
_queue.append({"result": result, "code": code, "body": body})
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
last_method = method
|
||||
last_body = request_data
|
||||
if _queue.is_empty():
|
||||
return ERR_UNAVAILABLE
|
||||
var r: Dictionary = _queue.pop_front()
|
||||
request_completed.emit(
|
||||
r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class HoldFirstThenAutoTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var request_count: int = 0
|
||||
var last_body: String = ""
|
||||
var _first: bool = true
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
request_count += 1
|
||||
last_body = request_data
|
||||
var body_json := (
|
||||
'{"schemaVersion":1,"updated":true,"loadout":'
|
||||
+ '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
|
||||
)
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
if _first:
|
||||
_first = false
|
||||
call_deferred("_emit", body_bytes)
|
||||
else:
|
||||
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
|
||||
return OK
|
||||
|
||||
func _emit(body_bytes: PackedByteArray) -> void:
|
||||
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = HotbarClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func _sample_slots_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":['
|
||||
+ '{"slotIndex":0,"abilityId":"prototype_pulse"},'
|
||||
+ '{"slotIndex":1,"abilityId":null},'
|
||||
+ '{"slotIndex":2,"abilityId":"prototype_guard"},'
|
||||
+ '{"slotIndex":3,"abilityId":null},'
|
||||
+ '{"slotIndex":4,"abilityId":null},'
|
||||
+ '{"slotIndex":5,"abilityId":null},'
|
||||
+ '{"slotIndex":6,"abilityId":null},'
|
||||
+ '{"slotIndex":7,"abilityId":null}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func test_sync_get_applies_slots_to_hotbar_state() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _sample_slots_json())
|
||||
var c := _make_client(transport)
|
||||
var state := HotbarState.new()
|
||||
auto_free(state)
|
||||
add_child(state)
|
||||
c.call("set_hotbar_state", state)
|
||||
c.call("request_sync_from_server")
|
||||
var slots: Array = state.call("slots_snapshot")
|
||||
assert_that(slots.size()).is_equal(8)
|
||||
assert_that(slots[0]).is_equal("prototype_pulse")
|
||||
assert_that(slots[2]).is_equal("prototype_guard")
|
||||
assert_that(slots[1]).is_null()
|
||||
|
||||
|
||||
func test_bind_slot_posts_expected_payload() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
200,
|
||||
(
|
||||
'{"schemaVersion":1,"updated":true,"loadout":'
|
||||
+ '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
|
||||
)
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
c.call("request_bind_slot", 3, "prototype_burst")
|
||||
assert_that(transport.last_url).contains("/hotbar-loadout")
|
||||
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||
var parsed: Variant = JSON.parse_string(transport.last_body)
|
||||
assert_that(parsed is Dictionary).is_true()
|
||||
var body: Dictionary = parsed
|
||||
assert_that(int(body.get("schemaVersion", 0))).is_equal(1)
|
||||
var slots: Array = body.get("slots", [])
|
||||
assert_that(slots.size()).is_equal(1)
|
||||
assert_that(int((slots[0] as Dictionary).get("slotIndex", -1))).is_equal(3)
|
||||
assert_that((slots[0] as Dictionary).get("abilityId")).is_equal("prototype_burst")
|
||||
|
||||
|
||||
func test_denied_update_emits_reason_signal() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
200,
|
||||
(
|
||||
'{"schemaVersion":1,"updated":false,"reasonCode":"unknown_ability","loadout":'
|
||||
+ '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
|
||||
)
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
c.call("request_bind_slot", 1, "not_real")
|
||||
assert_signal(c).is_emitted("loadout_update_denied")
|
||||
|
||||
|
||||
func test_bind_slot_while_busy_is_ignored() -> void:
|
||||
var transport := HoldFirstThenAutoTransport.new()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_bind_slot", 1, "prototype_pulse")
|
||||
c.call("request_bind_slot", 2, "prototype_guard")
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
assert_that(transport.request_count).is_equal(1)
|
||||
var parsed: Variant = JSON.parse_string(transport.last_body)
|
||||
assert_that(parsed is Dictionary).is_true()
|
||||
var slots: Array = (parsed as Dictionary).get("slots", [])
|
||||
assert_that(int((slots[0] as Dictionary).get("slotIndex", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_http_failure_does_not_mutate_cached_loadout() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_CANT_CONNECT, 0, "")
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
var cached: Dictionary = c.call("cached_loadout")
|
||||
assert_that(cached.is_empty()).is_true()
|
||||
|
||||
|
||||
func test_non_json_error_response_keeps_cached_loadout_empty() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 500, "oops")
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
var cached: Dictionary = c.call("cached_loadout")
|
||||
assert_that(cached.is_empty()).is_true()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://1r1d8lt00lmr
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-26: `TargetSelectionClient.selection_event` — cause strings and lock-id-only rule.
|
||||
|
||||
const TargetSelectionTestDouble := preload("res://test/target_selection_test_double.gd")
|
||||
|
||||
const ALPHA_ID := "prototype_target_alpha"
|
||||
const BETA_ID := "prototype_target_beta"
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var _queue: Array[Dictionary] = []
|
||||
|
||||
func enqueue(result: int, code: int, body: String) -> void:
|
||||
_queue.append({"result": result, "code": code, "body": body})
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
if _queue.is_empty():
|
||||
return ERR_UNAVAILABLE
|
||||
var r: Dictionary = _queue.pop_front()
|
||||
var body_bytes: PackedByteArray = str(r["body"]).to_utf8_buffer()
|
||||
request_completed.emit(r["result"], r["code"], PackedStringArray(), body_bytes)
|
||||
return OK
|
||||
|
||||
|
||||
func _make_client(http_transport: Node) -> Node:
|
||||
var c: Node = TargetSelectionTestDouble.new()
|
||||
c.set("injected_http", http_transport)
|
||||
auto_free(http_transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func _target_state_json(locked_id_or_null: Variant, validity: String, sequence: int = 0) -> String:
|
||||
var locked := "null"
|
||||
if locked_id_or_null is String and not (locked_id_or_null as String).is_empty():
|
||||
locked = '"%s"' % (locked_id_or_null as String)
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","lockedTargetId":%s,' % locked
|
||||
+ '"validity":"%s","sequence":%d}' % [validity, sequence]
|
||||
)
|
||||
|
||||
|
||||
func _select_response_json(
|
||||
applied: bool,
|
||||
locked_id_or_null: Variant,
|
||||
validity: String,
|
||||
sequence: int = 0,
|
||||
reason: String = ""
|
||||
) -> String:
|
||||
var target_state := _target_state_json(locked_id_or_null, validity, sequence)
|
||||
if applied:
|
||||
return '{"schemaVersion":1,"selectionApplied":true,"targetState":%s}' % target_state
|
||||
var reason_segment := ""
|
||||
if not reason.is_empty():
|
||||
reason_segment = ',"reasonCode":"%s"' % reason
|
||||
return (
|
||||
'{"schemaVersion":1,"selectionApplied":false%s,"targetState":%s}'
|
||||
% [reason_segment, target_state]
|
||||
)
|
||||
|
||||
|
||||
func test_tab_lock_change_emits_tab() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
var events: Array = []
|
||||
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
|
||||
c.request_tab_next()
|
||||
assert_that(events.size()).is_equal(1)
|
||||
var last: Dictionary = events[0] as Dictionary
|
||||
assert_that(last.get("cause", "")).is_equal("tab")
|
||||
assert_that(last.get("previous")).is_null()
|
||||
assert_that(last.get("next")).is_equal(ALPHA_ID)
|
||||
assert_that(int(last.get("sequence", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_clear_lock_change_emits_clear() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||
)
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, null, "none", 2))
|
||||
var c := _make_client(transport)
|
||||
var events: Array = []
|
||||
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
|
||||
c.request_tab_next()
|
||||
c.request_clear_target()
|
||||
assert_that(events.size()).is_equal(2)
|
||||
assert_that(int((events[0] as Dictionary).get("sequence", -1))).is_equal(1)
|
||||
assert_that((events[1] as Dictionary).get("cause", "")).is_equal("clear")
|
||||
assert_that((events[1] as Dictionary).get("previous")).is_equal(ALPHA_ID)
|
||||
assert_that((events[1] as Dictionary).get("next")).is_null()
|
||||
assert_that(int((events[1] as Dictionary).get("sequence", -1))).is_equal(2)
|
||||
|
||||
|
||||
func test_get_lock_id_change_emits_server_correction() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||
)
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(BETA_ID, "ok", 2))
|
||||
var c := _make_client(transport)
|
||||
var events: Array = []
|
||||
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
|
||||
c.request_tab_next()
|
||||
c.request_sync_from_server()
|
||||
assert_that(events.size()).is_equal(2)
|
||||
var last: Dictionary = events[1] as Dictionary
|
||||
assert_that(last.get("cause", "")).is_equal("server_correction")
|
||||
assert_that(last.get("previous")).is_equal(ALPHA_ID)
|
||||
assert_that(last.get("next")).is_equal(BETA_ID)
|
||||
assert_that(int(last.get("sequence", -1))).is_equal(2)
|
||||
|
||||
|
||||
func test_get_validity_only_change_emits_nothing() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||
)
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1)
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
var events: Array = []
|
||||
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
|
||||
c.request_tab_next()
|
||||
c.request_sync_from_server()
|
||||
assert_that(events.size()).is_equal(1)
|
||||
assert_that((events[0] as Dictionary).get("cause", "")).is_equal("tab")
|
||||
assert_that(int((events[0] as Dictionary).get("sequence", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_denial_without_lock_id_change_emits_nothing() -> void:
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
200,
|
||||
_select_response_json(false, null, "none", 0, "out_of_range")
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
var events: Array = []
|
||||
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
|
||||
c.request_select_target_id(ALPHA_ID)
|
||||
assert_that(events.size()).is_equal(0)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://ksj15nsjl6ph
|
||||
|
|
@ -100,7 +100,7 @@ flowchart LR
|
|||
|
||||
## Implementation snapshot
|
||||
|
||||
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **Still open on later issues:** **`SelectionEvent`**. **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
|
||||
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **`SelectionEvent` (prototype):** Godot **`TargetSelectionClient.selection_event`** when **`lockedTargetId`** changes — [NEO-26](../../plans/NEO-26-implementation-plan.md). **NEO-27 hook mapping:** `selection_event` maps to Slice 3 telemetry name **`target_changed`** (dev log token + TODO(E9.M1) in `client/scripts/target_selection_client.gd`); server lock mutation hook comments live in `server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs`; ability hook reserves (`ability_cast_requested` / `ability_cast_denied`) are documented in `client/scripts/main.gd` pending E1.M4 + E5.M1 — [NEO-27](../../plans/NEO-27-implementation-plan.md). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
|
||||
- **Precursor under E1.M1 (NEO-9 + NEO-25):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`GET /game/world/interactables`** (versioned descriptor list); **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry under `server/NeonSprawl.Server/Game/Interaction/`; client **`interactables_catalog_client.gd`**, **`interactable_world_builder.gd`**, **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-25](../../plans/NEO-25-implementation-plan.md), and [server README — Interaction](../../../server/README.md#interaction-neo-9).
|
||||
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ flowchart LR
|
|||
- **Occluders vs truth:** camera occlusion and pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)) affect **presentation** and click-to-move; **server** eligibility must not depend on “what the camera showed.”
|
||||
- **Lag compensation** for tab-target: deferred per [client vs server authority](client_server_authority.md#what-this-doc-does-not-fix-yet); decide with combat design when E5.M1 networking MVP is scoped.
|
||||
|
||||
**Telemetry (Slice 3):** `target_changed`, `ability_cast_requested`, `ability_cast_denied` — **TODO(E9.M1)** until event schema exists.
|
||||
**Telemetry (Slice 3):** `target_changed`, `ability_cast_requested`, `ability_cast_denied` hook sites are documented in client/server code (NEO-27); **TODO(E9.M1)** until event schema/catalog and ingest wiring exist.
|
||||
|
||||
## Linear backlog
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
| **Module ID** | E1.M4 |
|
||||
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | In Progress (see [dependency register](module_dependency_register.md)) |
|
||||
| **Linear** | Story slugs **E1M4-01** through **E1M4-05** in [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); label **`E1.M4`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
@ -39,6 +40,29 @@ Hotbar bindings, cooldown slot UI hooks, and the client/input path that issues `
|
|||
|
||||
Epic 1 **Slice 3** — `ability_cast_requested`, `ability_cast_denied` with reason codes.
|
||||
|
||||
## Implementation snapshot
|
||||
|
||||
- **Current state:** NEO-29 landed prototype `HotbarLoadout` v1 contract and hydration path (`GET`/`POST /game/players/{id}/hotbar-loadout`, fixed 8 slots, deny reason codes). `AbilityCastRequest` and `CooldownSnapshot` wiring are still pending.
|
||||
- **Prototype persistence policy (NEO-29):** per-player loadout key; Postgres when `ConnectionStrings:NeonSprawl` exists, in-memory fallback otherwise.
|
||||
- **Prototype backlog:** [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md) defines five vertical slices from hotbar loadout contract to cast telemetry hooks.
|
||||
- **Dependency expectation:** first implementation stories assume E1.M3 target selection flow exists and E5.M1 provides minimum cast accept/deny contract.
|
||||
|
||||
## Linear backlog
|
||||
|
||||
Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016).
|
||||
|
||||
**Stories:** full acceptance checklists in **[docs/plans/E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md)**.
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| **E1M4-01** | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) — `HotbarLoadout` v1 contract + baseline persistence path |
|
||||
| **E1M4-02** | TBD — Input to `AbilityCastRequest` path |
|
||||
| **E1M4-03** | TBD — Combat accept/deny integration + reason-code UX |
|
||||
| **E1M4-04** | TBD — `CooldownSnapshot` sync + slot presentation |
|
||||
| **E1M4-05** | TBD — Slice 3 telemetry hooks for cast funnel |
|
||||
|
||||
Issues should carry label **`E1.M4`** per [Linear alignment](../README.md#linear-alignment).
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 1.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
|||
|--------|-----------------|----------|-------------------|
|
||||
| E1.M1 | Ready | Prototype milestone **Done** ([E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on host shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot sync + path-follow ([NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEO-9](../../plans/NEO-9-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-8](../../plans/NEO-8-implementation-plan.md), [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-10](../../plans/NEO-10-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md), [NEO-13](../../plans/NEO-13-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
|
||||
| E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
|
||||
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **Still open:** **`SelectionEvent`** and richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact` (see [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md)). **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)–[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
|
||||
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)–[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
|
||||
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **Still open:** cast request path, accept/deny integration UX, cooldown snapshot sync, and telemetry hooks from later E1.M4 stories. | [NEO-29](../../plans/NEO-29-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E1.M1 | InputAndMovementRuntime | None | MoveCommand, PositionState, InteractionRequest | Prototype | Ready |
|
||||
| E1.M2 | IsometricCameraController | E1.M1 | CameraState, ZoomBandConfig, OcclusionPolicy | Prototype | Ready |
|
||||
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | In Progress |
|
||||
| E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | Planned |
|
||||
| E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | In Progress |
|
||||
|
||||
**E1.M2 note:** Prototype slice **Ready**: `client/scripts/isometric_follow_camera.gd` + child `Camera3D` on **`World/IsometricFollowCamera`** in `client/scenes/main.tscn`; per-tick **`CameraState`** (`client/scripts/camera_state.gd`); **`ZoomBandConfig`** ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); **`OcclusionPolicy`** ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); pick-through **`"occluder"`** convention ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); consumer contract + occluder lifecycle hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). See [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md).
|
||||
|
||||
**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **Partial (NEO-25):** versioned **`GET /game/world/interactables`** + `InteractablesListDtos` / `InteractablesWorldApi` in `Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` ([NEO-25](../../plans/NEO-25-implementation-plan.md); [server README — Interactable descriptors](../../../server/README.md#interactable-descriptors-neo-25)). **`SelectionEvent`** and richer multi-consumer **`InteractableDescriptor`** work remain **in progress** on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`).
|
||||
**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **Partial (NEO-25):** versioned **`GET /game/world/interactables`** + `InteractablesListDtos` / `InteractablesWorldApi` in `Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` ([NEO-25](../../plans/NEO-25-implementation-plan.md); [server README — Interactable descriptors](../../../server/README.md#interactable-descriptors-neo-25)). **NEO-26 (prototype `SelectionEvent`):** Godot **`TargetSelectionClient.selection_event`** on **`lockedTargetId`** changes ([NEO-26](../../plans/NEO-26-implementation-plan.md)). **Follow-on / in progress:** richer multi-consumer **`InteractableDescriptor`** targeting on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`).
|
||||
|
||||
**E1.M4 note:** Vertical-slice decomposition is defined in [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) with story slugs **E1M4-01** through **E1M4-05** (loadout contract, cast path, accept/deny UX, cooldown sync, telemetry hooks). **NEO-29** started implementation with `HotbarLoadout` v1 server APIs + persistence wiring and client hydration, so module status is **In Progress**; see [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md) and [NEO-29 plan](../../plans/NEO-29-implementation-plan.md).
|
||||
|
||||
### Epic 2 — Skills and Progression Framework
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
# NEO-26 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-26 |
|
||||
| **Title** | E1.M3: SelectionEvent surface + debug or HUD consumer |
|
||||
| **Linear** | [NEO-26](https://linear.app/neon-sprawl/issue/NEO-26/e1m3-selectionevent-surface-debug-or-hud-consumer) |
|
||||
| **Plan** | [`docs/plans/NEO-26-implementation-plan.md`](../plans/NEO-26-implementation-plan.md) |
|
||||
|
||||
Pair client (**F5**) with the server. Open the Godot **Output** dock.
|
||||
|
||||
## 1. `log_selection_events`
|
||||
|
||||
- [ ] Select **`TargetSelectionClient`** in the scene tree; set **`log_selection_events`** to **On** in the Inspector.
|
||||
- [ ] Run with server. Press **Tab** to lock a target: Output shows a line **`[TargetSelectionClient] SelectionEvent`** with `cause=tab`, `previous` null or prior id, `next` = locked id.
|
||||
- [ ] Press **Esc** to clear: next line shows **`cause=clear`**, `next` null when lock cleared.
|
||||
- [ ] Turn **`log_selection_events`** **Off**; Tab / Esc no longer print selection lines (HUD from NEO-24 unchanged).
|
||||
|
||||
## 2. Movement GET (validity-only)
|
||||
|
||||
- [ ] With logging **off**, lock **alpha**, **WASD** until HUD shows **`Validity: out_of_range`** (NEO-24 soft lock). No requirement for extra **Output** lines from NEO-26 here — **id-only** events suppress **`validity`-only** GET updates.
|
||||
|
||||
## 3. Subscription sanity (optional)
|
||||
|
||||
- [ ] In a throwaway local script or remote inspector, **`connect`** to **`selection_event`** and confirm the same payloads as printed when logging is on.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# NEO-27 — Manual QA checklist
|
||||
|
||||
Story: [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27/e1m3-slice-3-telemetry-hook-sites-target-changed)
|
||||
Plan: [`docs/plans/NEO-27-implementation-plan.md`](../plans/NEO-27-implementation-plan.md)
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Game server running (`server/NeonSprawl.Server`).
|
||||
- Godot client opens `client/project.godot` and runs `scenes/main.tscn`.
|
||||
- In the scene, `TargetSelectionClient.log_selection_events` is enabled (`true`).
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] **`target_changed` dev token appears on lock transition**
|
||||
- Press `Tab` to acquire a target lock or switch lock ids.
|
||||
- Verify Godot Output prints a line prefixed with `[TargetSelectionClient] target_changed`.
|
||||
- Confirm payload includes `previous`, `next`, `cause`, and `sequence`.
|
||||
|
||||
- [ ] **No `target_changed` log on validity-only updates**
|
||||
- Hold a lock, move to force `validity` changes (`ok` ↔ `out_of_range`) without lock-id change.
|
||||
- Verify `target_state_changed` behavior remains, and no new `target_changed` log is printed for id-unchanged refreshes.
|
||||
|
||||
- [ ] **Ability telemetry reserves are documented (not implemented)**
|
||||
- Confirm comment block exists in `client/scripts/main.gd` naming `ability_cast_requested` and `ability_cast_denied`.
|
||||
- Confirm comments include `TODO(E9.M1)` and reference E1.M4/E5.M1 as future wiring points.
|
||||
|
||||
## Expected outcome
|
||||
|
||||
- Slice 3 telemetry hook names are explicitly present in code/docs:
|
||||
- Active hook mapping: `target_changed`.
|
||||
- Reserved hook names: `ability_cast_requested`, `ability_cast_denied`.
|
||||
- No production telemetry pipeline is introduced in this story.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# NEO-29 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-29 |
|
||||
| Title | E1M4-01: HotbarLoadout v1 contract + baseline persistence path |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path |
|
||||
| Plan | `docs/plans/NEO-29-implementation-plan.md` |
|
||||
| Branch | `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` |
|
||||
|
||||
## 1) Setup sanity
|
||||
|
||||
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
- [ ] Confirm health endpoint responds: `curl -s http://localhost:5253/health`.
|
||||
- [ ] Confirm baseline loadout for dev player is reachable: `curl -s http://localhost:5253/game/players/dev-local-1/hotbar-loadout`.
|
||||
|
||||
## 2) Happy path (bind and re-read)
|
||||
|
||||
- [ ] Bind slot 0 + slot 3 in one request:
|
||||
`curl -s -X POST http://localhost:5253/game/players/dev-local-1/hotbar-loadout -H "Content-Type: application/json" -d '{"schemaVersion":1,"slots":[{"slotIndex":0,"abilityId":"prototype_pulse"},{"slotIndex":3,"abilityId":"prototype_burst"}]}'`
|
||||
- [ ] Verify response contains `updated=true` and `loadout.slots[0].abilityId == "prototype_pulse"` and `loadout.slots[3].abilityId == "prototype_burst"`.
|
||||
- [ ] Run GET again and verify the same two bindings are still present.
|
||||
|
||||
## 3) Reconnect / persistence behavior
|
||||
|
||||
- [ ] If Postgres is configured (`ConnectionStrings__NeonSprawl` set), stop and restart the server, then GET loadout and verify slot 0/3 bindings survived restart.
|
||||
- [ ] If Postgres is not configured (in-memory fallback), verify loadout behavior in the same process is stable and document that restart reset is expected for this mode.
|
||||
|
||||
## 4) Deny paths and reason codes
|
||||
|
||||
- [ ] Send out-of-bounds slot (`slotIndex: 8`) and confirm response has `updated=false` with `reasonCode == "slot_out_of_bounds"`.
|
||||
- [ ] Send unknown ability id (`abilityId: "not_real"`) and confirm response has `updated=false` with `reasonCode == "unknown_ability"`.
|
||||
- [ ] Send duplicate slot index entries in one POST and confirm response has `updated=false` with `reasonCode == "duplicate_slot"`.
|
||||
|
||||
## 5) Client hydration sanity
|
||||
|
||||
- [ ] Run `client/scenes/main.tscn` in Godot after slot bindings exist on server.
|
||||
- [ ] Confirm no startup warnings from `HotbarLoadoutClient` and verify `HotbarState` node exists under the scene root with 8-slot mirror populated from server response.
|
||||
|
||||
## 6) Client-driven update sanity (no curl)
|
||||
|
||||
- [ ] With `main.tscn` running, open the remote SceneTree and select `HotbarLoadoutClient` under the scene root.
|
||||
- [ ] Execute `request_bind_slot(2, "prototype_guard")` on that node (Editor script console or remote call).
|
||||
- [ ] Confirm no client warning log from `HotbarLoadoutClient` and verify `HotbarState.slots_snapshot()` now contains `"prototype_guard"` at index `2`.
|
||||
- [ ] Restart the client scene and verify hydration still reflects slot `2` from server (proves client fetch + apply path, not just local mutation).
|
||||
|
||||
## 7) Client deny-path signal sanity
|
||||
|
||||
- [ ] With `main.tscn` running, execute `request_bind_slot(2, "not_real")` on `HotbarLoadoutClient`.
|
||||
- [ ] Confirm the client logs/handles a deny response (server `updated=false`, `reasonCode="unknown_ability"`), and `HotbarState` remains unchanged from the last valid loadout.
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# E1.M4 — Prototype story backlog (AbilityInputScaffold)
|
||||
|
||||
Working backlog for **Epic 1 — Slice 3** ([interaction, targeting, ability input](../decomposition/epics/epic_01_core_player_runtime.md#epic-1-slice-3)). Decomposition and contracts: [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E1.M4`**; add **`Epic 1`** / Slice 3 as your team prefers.
|
||||
|
||||
**Precursor (do not re-scope):** selection and targeting authority live in **E1.M3** ([E1.M3 module](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md)); combat accept/deny remains authoritative in **E5.M1**.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E1M4-01 | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) |
|
||||
| E1M4-02 | [NEO-31](https://linear.app/neon-sprawl/issue/NEO-31/e1m4-02-input-to-abilitycastrequest-path) |
|
||||
| E1M4-03 | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux) |
|
||||
| E1M4-04 | [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32/e1m4-04-cooldownsnapshot-sync-slot-presentation) |
|
||||
| E1M4-05 | [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30/e1m4-05-slice-3-telemetry-hooks-for-cast-funnel) |
|
||||
|
||||
**Dependency graph in Linear:** E1M4-02 blocked by E1M4-01. E1M4-03 blocked by E1M4-02 and minimum E5.M1 accept/deny response contract. E1M4-04 blocked by E1M4-03. E1M4-05 blocked by E1M4-02 and E1M4-03.
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Depends on |
|
||||
|-------|------|------------|
|
||||
| 1 | **E1M4-01** | E1.M3 target lock shape, existing prototype auth/session APIs |
|
||||
| 2 | **E1M4-02** | E1M4-01, E1.M3 target selection flow |
|
||||
| 3 | **E1M4-03** | E1M4-02, E5.M1 accept/deny contract |
|
||||
| 4 | **E1M4-04** | E1M4-03 |
|
||||
| 5 | **E1M4-05** | E1M4-02, E1M4-03 |
|
||||
|
||||
---
|
||||
|
||||
### E1M4-01 — `HotbarLoadout` v1 contract + baseline persistence path
|
||||
|
||||
**Goal:** Define a versioned `HotbarLoadout` shape and minimal server-owned read/write flow so clients can bind slots and recover the same loadout on reconnect.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Server DTO/API surface (prototype JSON v1) for loadout fetch/update with explicit slot index and ability id fields.
|
||||
- Client load/apply path for initial hotbar hydration.
|
||||
- Validation for slot bounds and unknown ability ids with non-empty reason codes on deny.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final account-vs-character policy hardening beyond a documented prototype choice.
|
||||
- Cooldown timing and cast execution.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] A player can bind at least two slots and retrieve the same bindings after reconnect/reload.
|
||||
- [ ] Invalid slot/ability input is rejected with a stable machine-readable reason code.
|
||||
- [ ] Module docs and per-story implementation plan describe the chosen prototype persistence policy.
|
||||
|
||||
**Implementation note (NEO-29):** Prototype policy is **per-player** loadout key, persisted via **Postgres when configured** with **in-memory fallback**.
|
||||
|
||||
---
|
||||
|
||||
### E1M4-02 — Input to `AbilityCastRequest` path (hotbar activation)
|
||||
|
||||
**Goal:** Wire hotbar input to a real `AbilityCastRequest` payload that includes selected target context from E1.M3.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Client input binding (slot key or click) to request emission.
|
||||
- Request payload includes at least slot, ability id, and selected target id (if any).
|
||||
- Integration tests or harness coverage for request formation and send triggers.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final combat resolution semantics and cooldown rendering.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Pressing a bound slot emits one cast request with expected payload fields.
|
||||
- [ ] Request target context uses last acknowledged target state (not camera-only local guess).
|
||||
- [ ] Unbound slots do not emit requests and expose clear local feedback/logging.
|
||||
|
||||
---
|
||||
|
||||
### E1M4-03 — Combat accept/deny integration + reason-code UX
|
||||
|
||||
**Goal:** Consume authoritative cast accept/deny responses from E5.M1 and reconcile client presentation when casts are rejected.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Handle accept and deny responses in the cast path.
|
||||
- Surface deny reasons in agreed prototype UX surface (debug panel, toast, or combat log).
|
||||
- Reconcile optimistic UI state on deny.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Rich final combat feedback package and VFX.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Denied casts render a player-visible reason path for at least `invalid_target` and `out_of_range`.
|
||||
- [ ] Successful casts transition to post-request state without conflicting local rollback behavior.
|
||||
- [ ] Denial contract and reason list are documented in plan/module docs to prevent drift.
|
||||
|
||||
---
|
||||
|
||||
### E1M4-04 — `CooldownSnapshot` sync + slot presentation
|
||||
|
||||
**Goal:** Add cooldown snapshot contract and hook slot presentation to server cooldown truth.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Define `CooldownSnapshot` prototype fields and source route/update mechanism.
|
||||
- Update hotbar slot state (ready, cooling down, remaining time display or indicator).
|
||||
- Gate repeated cast attempts during active cooldown with synchronized behavior.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final polished animation treatments for cooldown visuals.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] After a successful cast, affected slot shows cooling state until cooldown completes.
|
||||
- [ ] Client cannot repeatedly fire a cooling ability without receiving deny/guard behavior.
|
||||
- [ ] Cooldown UI recovers correctly after reconnect by rehydrating snapshot state.
|
||||
|
||||
---
|
||||
|
||||
### E1M4-05 — Slice 3 telemetry hooks for cast funnel
|
||||
|
||||
**Goal:** Complete Slice 3 telemetry hook sites for `ability_cast_requested` and `ability_cast_denied` with reason metadata and TODO alignment for E9.M1 schema.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Add/verify hook locations in client/server cast flow for requested/denied events.
|
||||
- Include payload notes for ability id, slot id, target context, and deny reason.
|
||||
- Document temporary `TODO(E9.M1)` status where catalog/schema integration is pending.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Production telemetry ingest pipelines, dashboards, and retention policy work.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook names match Epic 1 Slice 3 telemetry vocabulary.
|
||||
- [ ] Denied cast hook carries a non-empty reason code.
|
||||
- [ ] Module docs reference where hooks are emitted and what remains deferred to E9.M1.
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- Track delivery in the created Linear issues and keep dependency links synchronized if scope changes.
|
||||
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` and keep decisions synchronized during implementation.
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` for user-visible stories when implementation lands.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [E1_M4_AbilityInputScaffold.md](../decomposition/modules/E1_M4_AbilityInputScaffold.md)
|
||||
- [E1_M3_InteractionAndTargetingLayer.md](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md)
|
||||
- [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md)
|
||||
- [epic_01_core_player_runtime.md](../decomposition/epics/epic_01_core_player_runtime.md)
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# NEO-26 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-26 |
|
||||
| **Title** | E1.M3: SelectionEvent surface + debug or HUD consumer |
|
||||
| **Linear** | [NEO-26](https://linear.app/neon-sprawl/issue/NEO-26/e1m3-selectionevent-surface-debug-or-hud-consumer) |
|
||||
| **Slug** | E1M3-04 |
|
||||
| **Git branch** | `NEO-26-selectionevent-surface-debug-consumer` |
|
||||
| **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1M3 prototype backlog](E1M3-prototype-backlog.md) |
|
||||
| **Depends on** | [NEO-23](NEO-23-implementation-plan.md) (`TargetState` v1), [NEO-24](NEO-24-implementation-plan.md) (tab/clear + `TargetSelectionClient`) — code paths exist on `main`; Linear may still list relations until those issues are closed. |
|
||||
| **Decomposition** | [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) (`SelectionEvent` contract); [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md) (future subscriber). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Every **material change** to the client’s **server-acknowledged combat target lock id** (`lockedTargetId` in the cached `TargetState` mirror) is observable as a **`SelectionEvent`** carrying **previous** id, **next** id, and **cause** (`tab`, `clear`, `server_correction`, …). Ship **one** built-in consumer: **dev-only `print`** behind an `@export` flag (planning choice 2026-04-25) so the bus is exercised without new HUD nodes.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Minimal **GDScript** event shape (Dictionary or small typed helper) aligned with the illustrative table in E1.M3: `previous` (Variant `String` or `null`), `next` (same), `cause` (`String` enum-like literals).
|
||||
- **Cause attribution** at the authoritative update boundary inside the existing selection client (see Technical approach) so we do not guess causes from state diffs alone.
|
||||
- **Consumer:** optional **`print`** when `@export var log_selection_events` (name TBD) is true on **`TargetSelectionClient`** (or tiny child node); document the **subscription point** for E1.M4 (`signal` name + emitter node path pattern).
|
||||
- **Tests:** tab vs clear vs movement-driven GET produce **distinct** `cause` strings when they each produce a lock-id transition (or documented no-op when id unchanged).
|
||||
|
||||
**Out of scope (per Linear / backlog)**
|
||||
|
||||
- Full prompt / party UI; wire protobuf events.
|
||||
- **Interactable** focus / primary selection as `SelectionEvent` — **target lock** only for this ticket (see D6); catalog exists regardless of NEO-25 lifecycle.
|
||||
- Changing server **`TargetState`** contracts (NEO-23); new HTTP routes.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] **Tab** path: after a successful selection cycle that **changes** `lockedTargetId`, a `SelectionEvent` is emitted with `cause` **`tab`** (and correct previous/next ids).
|
||||
- [x] **Clear** path: clearing the lock emits **`clear`** when `lockedTargetId` transitions from non-null to `null`.
|
||||
- [x] **Server correction:** a **GET**-driven authoritative update that **changes** `lockedTargetId` emits **`server_correction`**. **Id-unchanged** GETs (e.g. **`validity`** only) do **not** emit `SelectionEvent` for v1 — `test_get_validity_only_change_emits_nothing`; id change via mock in `test_get_lock_id_change_emits_server_correction`.
|
||||
- [x] **E1.M4 hook:** this plan + **`client/README.md`** (“SelectionEvent (NEO-26)”) document **`TargetSelectionClient.selection_event`** and stable payload keys.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Single emitter:** Extend **`TargetSelectionClient`** (`client/scripts/target_selection_client.gd`) rather than diffing `target_state_changed` in a second node — only this class knows whether the in-flight HTTP phase was **GET** (refresh) vs **POST** from **tab**, **clear**, or direct **`request_select_target_id`** (if used).
|
||||
|
||||
2. **Intent / phase tags:** Before starting HTTP, set a private enum or String tag, e.g. `_pending_selection_cause`, cleared after handling the response:
|
||||
- `request_tab_next` → `tab` (even though it calls `request_select_target_id` internally, set cause at the tab entrypoint before POST).
|
||||
- `request_clear_target` → `clear`.
|
||||
- `request_select_target_id` when **not** entered from `request_tab_next` → use cause **`tab`** (same string as Tab path — planning choice 2026-04-25: no separate `direct_select`).
|
||||
- `request_sync_from_server` when invoked from **`on_authoritative_ack`** → mark refresh as **`server_correction`** for the next GET completion.
|
||||
- Boot / one-shot sync from `main.gd` → **Decision (2026-04-25):** first and later **GET** completions use cause **`server_correction`**; **only emit** if normalized `lockedTargetId` differs from prior snapshot (avoids spam on identical boot GET).
|
||||
|
||||
3. **Emit rule:** After `_state` is updated in `_update_state_from_get` / `_update_state_from_post`, compare **previous** normalized `lockedTargetId` to **new**; if equal, **no** `SelectionEvent` (validity-only changes stay on `target_state_changed` only unless product later expands the contract).
|
||||
|
||||
4. **Signal:** Add e.g. **`selection_event(event: Dictionary)`** with keys `previous`, `next`, `cause` (and optional `sequence` int passthrough for debugging). Keep **`target_state_changed`** unchanged for existing HUD.
|
||||
|
||||
5. **Consumer:** No new scene nodes — **`TargetSelectionClient.log_selection_events`** mirrors each **`selection_event`** to **Output** via **`print`**.
|
||||
|
||||
6. **Docs:** Update **`client/README.md`** “Target lock + tab cycle” area with a short **“SelectionEvent (NEO-26)”** subsection: signal name, payload keys, print flag, E1.M4 subscription note.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `client/test/selection_event_client_test.gd` | GdUnit4: assert causes for tab POST, clear POST, and GET-with-mock-body changing lock id (`TargetSelectionTestDouble` pattern). |
|
||||
| `docs/manual-qa/NEO-26.md` | Manual QA for `log_selection_events` + optional subscription check. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/target_selection_client.gd` | Track pending cause through GET/POST; compare old/new `lockedTargetId`; emit `selection_event`; optional `print` behind `@export`; document next to `target_state_changed`. |
|
||||
| `client/README.md` | Document subscription point + payload for E1.M4. |
|
||||
| `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | **Implementation snapshot** bullet: replace “Still open — `SelectionEvent`” with pointer to NEO-26 + signal name (keeps decomposition aligned with shipped code). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|------------------|
|
||||
| `client/test/selection_event_client_test.gd` | **Add:** Tab mock POST changes lock → event `cause == "tab"`. Clear mock → `"clear"`. Simulated GET completion after flagging movement refresh → `"server_correction"` when body changes `lockedTargetId`. Assert **no** event when id unchanged. |
|
||||
| `client/test/target_selection_client_test.gd` | **Change (optional):** If new behavior duplicates setup, keep NEO-24 tests focused on HTTP/state; only touch this file when sharing helpers avoids copy-paste. |
|
||||
|
||||
**Manual verification:** [`docs/manual-qa/NEO-26.md`](../../manual-qa/NEO-26.md).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Validity-only updates:** If v1 stays **lock-id-only** (recommended), extend later with `kind` or dual fields if telemetry needs **`validity`** transitions on the same bus.
|
||||
- **Linear blocked-by:** Relations may still show NEO-23/NEO-24 until closed; implementation assumes their client/server surfaces on `main` are present.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|--------|----------|
|
||||
| D1 — Emitter location | **`TargetSelectionClient`** owns `selection_event` (intent-aware). |
|
||||
| D2 — Boot GET | Use cause **`server_correction`** for all GET completions (including first sync); **suppress** event if normalized `lockedTargetId` unchanged vs prior snapshot. |
|
||||
| D3 — Non-tab `request_select_target_id` | Use cause **`tab`** (same literal as Tab path; no `direct_select`). |
|
||||
| D4 — Consumer | **Print only** behind `@export` flag on emitter; no HUD panel in NEO-26. |
|
||||
| D5 — Emit rule (v1) | **Lock id only:** emit `SelectionEvent` only when normalized **`lockedTargetId`** changes; **`validity`** / sequence updates without id change → no event (HUD keeps using `target_state_changed`). *Rationale (2026-04-25):* matches backlog “previous / next” id story, smallest payload, clear E1.M4 hook for cast target; avoids conflating soft-lock range telemetry with selection changes. Override if you want validity on this signal too. |
|
||||
| D6 — Interactables | **Out of scope:** target lock only for NEO-26. *Rationale:* Linear / E1M3-04 acceptance is about **combat target** lock changes (`SelectionEvent` for tab-target), not interactable focus or “use now” flow ([NEO-9](NEO-9-implementation-plan.md) / catalog). Descriptor work ([NEO-25](NEO-25-implementation-plan.md)) can be **done** and this story still stays narrow: a unified “all selection kinds” bus would be **extra contract design** (payload + causes + wiring) that this ticket never asked for—not a dependency on NEO-25 being open. |
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# NEO-27 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-27 |
|
||||
| **Title** | E1.M3: Slice 3 telemetry hook sites (`target_changed`, …) |
|
||||
| **Linear** | [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27/e1m3-slice-3-telemetry-hook-sites-target-changed) |
|
||||
| **Slug** | E1M3-05 |
|
||||
| **Git branch** | `NEO-27-e1m3-slice-3-telemetry-hook-sites` |
|
||||
| **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1M3 prototype backlog](E1M3-prototype-backlog.md) |
|
||||
| **Decomposition** | [Epic 1 Slice 3 — telemetry hooks](../decomposition/epics/epic_01_core_player_runtime.md#epic-1-slice-3); [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
- **Q1 (resolved):** Where should reserved `ability_cast_requested` / `ability_cast_denied` hook comments live for this story?
|
||||
- **Answer:** Keep them in **`client/scripts/main.gd`** (no extra placeholder script in NEO-27).
|
||||
- **Why no additional blocking questions:** Event names, scope, and out-of-scope were explicit in Linear + backlog docs; this was the only implementation-location decision not already settled.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Establish **documented hook sites** for Epic 1 Slice 3 product telemetry names: **`target_changed`** (wired to real selection transitions), plus **reserved** (no-op / comment-only) sites for **`ability_cast_requested`** and **`ability_cast_denied`** until [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) + [E5.M1](../decomposition/modules/E5_M1_CombatRulesEngine.md). All real ingest waits on **`TODO(E9.M1)`** per [E9.M1 — TelemetryEventSchema](../decomposition/modules/E9_M1_TelemetryEventSchema.md).
|
||||
|
||||
**In scope**
|
||||
|
||||
- **Event names** in code or doc comments matching [epic Slice 3](../decomposition/epics/epic_01_core_player_runtime.md#epic-1-slice-3) (`target_changed`, `ability_cast_requested`, `ability_cast_denied`).
|
||||
- **`TODO(E9.M1)`** at each hook site until a catalog exists.
|
||||
- **Optional dev logging** when a flag is enabled (reuse or extend the NEO-26 `log_selection_events` pattern on the client; optional `ILogger` / `Debug.WriteLine` guard on server only if we add a clearly dev-gated path—prefer comments + single optional trace line consistent with repo norms).
|
||||
- **`documentation_and_implementation_alignment.md`:** extend the **E1.M3** snapshot row to mention NEO-27 hook sites (per Linear acceptance; NEO-23 is already on `main`).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Production ingest, dashboards, OpenTelemetry.
|
||||
- Changing `TargetState` / HTTP contracts (NEO-23).
|
||||
- Implementing real ability cast requests (E1.M4).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] **Documented event names** in-repo matching Slice 3: `target_changed`, `ability_cast_requested`, `ability_cast_denied` (latter two may be comment-only reserves).
|
||||
- [ ] **`TODO(E9.M1)`** at each hook site referencing schema work.
|
||||
- [ ] **Optional dev logging** for `target_changed` when a documented flag is on (or explicitly documented that an existing flag covers it, with log line including the product event name).
|
||||
- [ ] **`documentation_and_implementation_alignment.md`** E1.M3 row updated for NEO-27.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`target_changed` (client)** — Canonical prototype mirror: when **`TargetSelectionClient`** emits **`selection_event`** (NEO-26), the product telemetry name is **`target_changed`** (same transition: normalized `lockedTargetId` change, with `cause` / `sequence` carrying intent). Add a short class-level or method-level note on **`_maybe_emit_selection_event`** linking **`selection_event`** → **`target_changed`**. Optionally prefix dev **`print`** (when `log_selection_events` is true) with a literal `target_changed` token so grep and QA are unambiguous.
|
||||
|
||||
2. **`target_changed` (server)** — Authoritative lock id transitions occur in **`InMemoryPlayerTargetLockStore.ApplySet`** / **`ApplyClear`** when the stored lock id actually changes (sequence bumps). Add **`//` / `///` comments** (and `TODO(E9.M1)`) at those mutation success paths as the **server-side hook site** for `target_changed`. Rationale: single choke point; HTTP handlers stay thin.
|
||||
|
||||
3. **`ability_cast_requested` / `ability_cast_denied` (reserve)** — No hotbar or cast route exists yet. Add a compact **comment block** in **`client/scripts/main.gd`** (or the first file E1.M4 will extend for input, if we prefer—**Decision:** `main.gd` next to other prototype wiring) listing the two event names and pointing to E1.M4 / E5.M1 + E9.M1. Optionally mirror a one-line reserve in **`TargetingApi.cs`** near denial responses noting that **`ability_cast_denied`** (with `reasonCode`) will attach to a **future cast POST**, not the current target-select deny path—avoids confusion with selection denials.
|
||||
|
||||
4. **Docs alignment** — Append to the E1.M3 **Snapshot** cell: NEO-27 documents Slice 3 telemetry hook sites; link this plan.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `docs/manual-qa/NEO-27.md` | Ticket-level manual verification checklist for Slice 3 telemetry hook-site behavior and expectations. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/target_selection_client.gd` | Document `target_changed` ↔ `selection_event`; optional dev log prefix; `TODO(E9.M1)`. |
|
||||
| `server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs` | Server authoritative `target_changed` hook site on lock id transitions. |
|
||||
| `client/scripts/main.gd` | Reserved comment block for `ability_cast_requested` / `ability_cast_denied` (E1.M4 + E5.M1). |
|
||||
| `server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs` | Optional one-line reserve clarifying cast denials vs target-select denials (avoids mis-mapping event names). |
|
||||
| `client/README.md` | Short “Slice 3 telemetry (NEO-27)” pointer next to SelectionEvent section. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E1.M3 snapshot: NEO-27 telemetry hook sites landed (per team rule). |
|
||||
| `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | Optional one-line under telemetry: code hook pointers (`target_selection_client.gd`, lock store)—only if the module table still says hooks are TODO-only; keep register alignment. |
|
||||
| `docs/plans/NEO-27-implementation-plan.md` | Keep kickoff clarifications and resolved decisions synchronized with actual implementation choices. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `client/test/selection_event_client_test.gd` | **Change only if** new observable behavior is added (e.g. new export or signal). If implementation is comments + optional `print` format only, **no test change** — manual QA: enable `log_selection_events`, tab/clear, confirm output contains `target_changed`. |
|
||||
| `docs/manual-qa/NEO-27.md` | Add explicit manual checks for `target_changed` log token, id-change-only emission behavior, and ability telemetry reserve documentation. |
|
||||
|
||||
**Server:** No new tests required for comment-only / dev-trace placeholders unless we introduce a testable abstraction (out of scope).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
**Resolved (2026-04-26):** Linear no longer lists **NEO-24** as blocking NEO-27 (dependency removed on board).
|
||||
**Resolved (2026-04-26):** Keep ability telemetry reserve comments in **`client/scripts/main.gd`** for NEO-27; no placeholder script yet.
|
||||
|
||||
**None** otherwise.
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# NEO-29 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-29 |
|
||||
| **Title** | E1M4-01: HotbarLoadout v1 contract + baseline persistence path |
|
||||
| **Linear** | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) |
|
||||
| **Slug** | E1M4-01 |
|
||||
| **Git branch** | `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` |
|
||||
| **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1M4 prototype backlog](E1M4-prototype-backlog.md) |
|
||||
| **Decomposition** | [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md), [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md), [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
- **Q1 (blocking):** What should prototype `HotbarLoadout` scope be keyed to (player, character, or session)?
|
||||
- **Answer:** **Per player id** for the prototype.
|
||||
- **Q2 (blocking):** What baseline persistence backend should NEO-29 use?
|
||||
- **Answer:** Mirror position persistence pattern: use **Postgres when `ConnectionStrings:NeonSprawl` exists**, otherwise **in-memory fallback**.
|
||||
- **Q3 (blocking):** What fixed slot count should v1 expose?
|
||||
- **Answer:** **8 slots**.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Define and ship a prototype `HotbarLoadout` v1 read/write contract with server-owned validation and persistence, then hydrate client hotbar state from that server contract on boot/reconnect.
|
||||
|
||||
**In scope**
|
||||
|
||||
- New server API surface for loadout fetch + update with explicit `slotIndex` and `abilityId`.
|
||||
- Validation denies for out-of-bounds slots and unknown ability ids with stable machine-readable `reasonCode`.
|
||||
- Prototype persistence policy documentation and implementation: per-player loadout, Postgres when configured, in-memory fallback otherwise.
|
||||
- Client bootstrap path to fetch and apply hotbar loadout state.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final account-vs-character policy hardening beyond this prototype decision.
|
||||
- Cast execution, cooldown timing, and full combat authorization flow (follow-up stories in E1.M4 / E5.M1).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] A player can bind at least two slots and retrieve the same bindings after reconnect/reload.
|
||||
- [x] Invalid slot/ability input is rejected with a stable machine-readable reason code.
|
||||
- [x] Module docs and this plan describe the chosen prototype persistence policy.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. Add a dedicated `HotbarLoadout` v1 API to the server with `GET` (current loadout) and `POST` (replace/update slots) routes under `/game/players/{id}/hotbar-loadout`.
|
||||
2. Keep the response shape versioned (`schemaVersion`) and explicit per-slot entries (`slotIndex`, `abilityId`) so E1.M4 follow-up stories can extend without breaking the v1 envelope.
|
||||
3. Validate slot index range against fixed v1 slot count (**8**) and validate `abilityId` against a prototype server-side registry/list; return deterministic `reasonCode` values on deny (for example `slot_out_of_bounds`, `unknown_ability`).
|
||||
4. Mirror `PositionState` persistence wiring: add an abstraction with in-memory and Postgres implementations selected from configuration, so reconnect/reload behavior is stable in both local-no-db and db-backed runs.
|
||||
5. Hydrate the client hotbar model on startup/reconnect by requesting the loadout from server, then applying slot->ability bindings to local state without introducing cast execution behavior in this story.
|
||||
6. Update decomposition/module docs and server/client README notes so the prototype persistence choice (per-player + Postgres-or-memory) is explicit and discoverable.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutDtos.cs` | New v1 DTO contracts for loadout read/write API payloads. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs` | New route mapping and validation/deny response logic for loadout endpoints. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/IPlayerHotbarLoadoutStore.cs` | Persistence abstraction to mirror existing position store pattern. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerHotbarLoadoutStore.cs` | In-memory fallback implementation for prototype/dev without DB. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/PostgresPlayerHotbarLoadoutStore.cs` | Postgres-backed persistence path when connection string is configured. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/PostgresHotbarLoadoutBootstrap.cs` | One-time migration bootstrap for hotbar table DDL. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutServiceCollectionExtensions.cs` | DI registration and backend selection wiring. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs` | Canonical prototype ability-id allowlist for validation. |
|
||||
| `server/db/migrations/V002__player_hotbar_loadout.sql` | Postgres schema for per-player slot bindings persistence. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs` | Server contract + validation tests for loadout endpoints. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs` | Postgres persistence survives process restart for bound slots. |
|
||||
| `client/scripts/hotbar_loadout_client.gd` | Client HTTP wrapper for fetch/update loadout API. |
|
||||
| `client/scripts/hotbar_state.gd` | Client-side hotbar state holder that applies hydrated slot bindings. |
|
||||
| `client/test/hotbar_loadout_client_test.gd` | Client tests for hydration/apply and update request behavior. |
|
||||
| `docs/manual-qa/NEO-29.md` | Story-specific manual checklist for bind/reconnect/deny validation. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register hotbar loadout store/services and map new API routes. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Keep in-memory test harness forcing both position and hotbar stores in CI/local. |
|
||||
| `server/README.md` | Document new loadout endpoints and prototype persistence behavior. |
|
||||
| `client/scripts/main.gd` | Wire initial hotbar hydration call on boot/reconnect path. |
|
||||
| `client/README.md` | Document hotbar loadout hydration/update flow and debug expectations. |
|
||||
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Record NEO-29 implementation snapshot + persistence policy decision. |
|
||||
| `docs/plans/E1M4-prototype-backlog.md` | Mark NEO-29 implementation notes/decision alignment if scope wording changes. |
|
||||
| `docs/plans/NEO-29-implementation-plan.md` | Keep decisions and file/test plan aligned as implementation choices evolve. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs` | `GET`/`POST` contract shape, slot bounds validation, unknown ability validation, stable deny `reasonCode`, and successful bind persistence round-trip. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs` | Postgres-enabled persistence survives host restart/reconnect; in-memory fallback behavior remains consistent when DB is absent. |
|
||||
| `client/test/hotbar_loadout_client_test.gd` | Client hydration applies server slot bindings, preserves empty slots, and surfaces server deny reason codes from failed updates. |
|
||||
| `docs/manual-qa/NEO-29.md` | Manual checklist for two-slot bind + reconnect/reload verification and deny-path UX/log visibility. |
|
||||
|
||||
**Executed during implementation**
|
||||
|
||||
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests"` (passed)
|
||||
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests"` (passed)
|
||||
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` (passed)
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Risk:** Ability-id allowlist source may drift before full E5.M1 cast contract lands; keep a single prototype registry and document ownership to avoid client/server mismatch.
|
||||
- **Risk:** First cut may choose full-replace update semantics for simplicity; if partial patch semantics are needed for E1M4-02+, call that out as a follow-up to avoid contract churn.
|
||||
|
||||
## Decisions (implementation updates)
|
||||
|
||||
- **Update semantics:** NEO-29 uses **slot upsert semantics** (specified slots are updated; unspecified slots remain unchanged) to keep v1 payload compact while preserving server ownership.
|
||||
- **Deny shape:** Validation denials return HTTP 200 with `updated=false`, stable `reasonCode`, and authoritative `loadout` snapshot so clients can reconcile immediately.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Persistence scope | Per-player loadout for prototype v1. |
|
||||
| Persistence backend | Postgres when `ConnectionStrings:NeonSprawl` is configured; otherwise in-memory fallback. |
|
||||
| Slot count (v1) | Fixed 8-slot hotbar contract. |
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Code Review — NEO-26 SelectionEvent
|
||||
|
||||
- **Date:** 2026-04-25
|
||||
- **Scope:** Branch `NEO-26-selectionevent-surface-debug-consumer`; diff `origin/main...HEAD` (NEO-26 `SelectionEvent` surface + debug consumer)
|
||||
- **Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
Approve with nits.
|
||||
|
||||
## Summary
|
||||
|
||||
This branch adds a focused `SelectionEvent` surface in `TargetSelectionClient` and keeps it intentionally narrow to `lockedTargetId` transitions, with explicit `cause` attribution (`tab`, `clear`, `server_correction`). The implementation is coherent with the NEO-26 plan: cause tagging is set at request start, stale/failed paths clear the pending cause, and emit is suppressed for id-unchanged updates. Coverage in `client/test/selection_event_client_test.gd` exercises both positive and no-op rules, and the targeted suite passes locally. Risk is low and mostly around documentation tracking consistency rather than runtime behavior.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
- `docs/plans/NEO-26-implementation-plan.md` — **matches** (signal shape, cause rules, lock-id-only emit rule, print consumer, QA artifact all implemented).
|
||||
- `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` — **matches** (implementation snapshot updated to include NEO-26 `selection_event` prototype).
|
||||
- ~~`docs/decomposition/modules/module_dependency_register.md` — **partially matches** (E1.M3 note still says "`SelectionEvent` ... remain in progress"; …).~~ **Addressed:** E1.M3 note now records **NEO-26** prototype `SelectionEvent` and separates follow-on `InteractableDescriptor` / multi-consumer work.
|
||||
- ~~`docs/decomposition/modules/documentation_and_implementation_alignment.md` — **partially matches** (E1.M3 row still says "`SelectionEvent` ... still open"; …).~~ **Addressed:** E1.M3 snapshot row records **NEO-26** landed vs follow-on scope.
|
||||
|
||||
~~Implementation tracking update needed after merge: update the E1.M3 row wording in `documentation_and_implementation_alignment.md` (and optionally the E1.M3 note in `module_dependency_register.md`) so the docs reflect that prototype `SelectionEvent` now exists while richer downstream consumers remain open.~~ **Done.**
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~Update the E1.M3 tracking row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` to explicitly record NEO-26 as landed, replacing the blanket "`SelectionEvent` still open" phrasing with "prototype landed; richer consumers follow-on."~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Consider adding one assertion for `sequence` in `client/test/selection_event_client_test.gd` to lock payload stability for downstream subscribers.~~ **Done.** (`sequence` asserted on tab, clear, GET id-change, and validity-only cases.)
|
||||
|
||||
## Verification
|
||||
|
||||
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/selection_event_client_test.gd` (from `client/`) — passed (`5 test cases`, `0 failures`).
|
||||
- Manual checks documented in `docs/manual-qa/NEO-26.md`.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Code review — NEO-29
|
||||
|
||||
- **Date:** 2026-04-25
|
||||
- **Scope:** Branch `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` (`origin/main...HEAD`)
|
||||
- **Base:** `origin/main`
|
||||
- **Follow-up:** Re-review after `NEO-29: apply code review follow-ups` and subsequent test refactor commits (`d25d7d0..HEAD`).
|
||||
|
||||
## Verdict
|
||||
|
||||
Approve with nits.
|
||||
|
||||
## Summary
|
||||
|
||||
The earlier documentation-alignment and lower-bound slot test suggestions were addressed correctly. E1.M4 status tracking now matches implementation state, the `slotIndex < 0` deny-path test was added, and the client GDScript parse regression in `hotbar_loadout_client_test.gd` is now fixed. Refactored server AAA tests run cleanly in the targeted suites, and the hotbar client test suite now executes successfully. Remaining risk is low and limited to test-run warning noise from the headless Godot runner rather than functional test failures.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
- `docs/plans/NEO-29-implementation-plan.md` — **matches** (scope, API shape, persistence policy, deny reasons, tests/docs artifacts all present).
|
||||
- `docs/plans/E1M4-prototype-backlog.md` — **matches** (E1M4-01 slice framing and NEO-29 implementation note are consistent).
|
||||
- `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` — **matches** (summary status and implementation snapshot now align with landed NEO-29 work).
|
||||
- `docs/decomposition/modules/module_dependency_register.md` — **matches** (E1.M4 row and note now show `In Progress`).
|
||||
- `docs/decomposition/modules/documentation_and_implementation_alignment.md` — **matches** (tracking table now includes E1.M4 implementation snapshot/pointers).
|
||||
- `docs/decomposition/modules/contracts.md` — **matches** for prototype scope (JSON/HTTP spike is acceptable for early slice; versioned envelope and stable reason codes are documented).
|
||||
- `docs/decomposition/modules/client_server_authority.md` — **matches** (server remains authority for persistent gameplay-relevant loadout state; client acts as view/sync).
|
||||
|
||||
Register/tracking follow-up from the first pass is now closed.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~Update decomposition status tracking for E1.M4 from `Planned` to `In Progress` in the register/module summary (and corresponding tracking inventory) so docs comply with `documentation_and_implementation_alignment.md` and do not under-report shipped progress.~~ Done. Updated `docs/decomposition/modules/module_dependency_register.md` (row + note), `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` (summary status), and `docs/decomposition/modules/documentation_and_implementation_alignment.md` (new E1.M4 tracking row).
|
||||
2. ~~Several C# test files now place AAA markers inconsistently (e.g., `// Act` above `// Arrange` and inline comment placement on brace lines). This does not break runtime behavior, but it undermines the readability goal of the AAA refactor and conflicts with the documented template/order in `server/NeonSprawl.Server.Tests/TestTemplates/AAA_TEST_METHOD_TEMPLATE.cs.txt`.~~ Done. Normalized affected suites so AAA markers are consistently ordered and placed on their own lines in `server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs`, `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs`, `server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs`, `server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs`, and `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs`.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Consider adding one API test for `slotIndex < 0` deny parity with the existing `slotIndex == 8` test, to lock the lower-bound contract explicitly.~~ Done. Added `PostHotbarLoadout_ShouldDenyNegativeSlot_WithReasonCode` in `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests"`
|
||||
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests"`
|
||||
- `godot --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd`
|
||||
- Manual checklist: `docs/manual-qa/NEO-29.md`
|
||||
- Follow-up rerun (re-review):
|
||||
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests|FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests|FullyQualifiedName~TargetingApiTests|FullyQualifiedName~InteractionApiTests|FullyQualifiedName~MoveCommandApiTests|FullyQualifiedName~MoveCommandValidationTests|FullyQualifiedName~MoveStreamApiTests|FullyQualifiedName~PlayerTargetStateReaderTests|FullyQualifiedName~HorizontalReachTests|FullyQualifiedName~InteractablesWorldApiTests"` ✅ (71 passed)
|
||||
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` ❌ (parse/type error in `client/test/hotbar_loadout_client_test.gd`)
|
||||
- Final rerun (latest working tree):
|
||||
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests|FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests|FullyQualifiedName~TargetingApiTests|FullyQualifiedName~InteractionApiTests|FullyQualifiedName~MoveCommandApiTests|FullyQualifiedName~MoveCommandValidationTests|FullyQualifiedName~MoveStreamApiTests|FullyQualifiedName~PlayerTargetStateReaderTests|FullyQualifiedName~HorizontalReachTests|FullyQualifiedName~InteractablesWorldApiTests"` ✅ (71 passed)
|
||||
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` ✅ (6 passed; runner still prints non-fatal warning/error noise at shutdown)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
cd "$repo_root"
|
||||
|
||||
mapfile -t staged_files < <(git diff --cached --name-only --diff-filter=ACMR)
|
||||
if [[ ${#staged_files[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
has_staged_match() {
|
||||
local pattern="$1"
|
||||
for f in "${staged_files[@]}"; do
|
||||
if [[ "$f" =~ $pattern ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
server_route_like_changed=false
|
||||
if has_staged_match '^server/NeonSprawl\.Server/.*(Api\.cs|Dtos\.cs|Program\.cs)$'; then
|
||||
server_route_like_changed=true
|
||||
fi
|
||||
|
||||
if [[ "$server_route_like_changed" == true ]]; then
|
||||
if ! has_staged_match '^server/NeonSprawl\.Server\.Tests/'; then
|
||||
echo "pre-commit: server route/contract files changed but no server tests staged." >&2
|
||||
echo "Add or update tests under server/NeonSprawl.Server.Tests/ before commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! has_staged_match '^bruno/neon-sprawl-server/.*\.bru$'; then
|
||||
echo "pre-commit: server route/contract files changed but no Bruno request staged." >&2
|
||||
echo "Add/update .bru requests under bruno/neon-sprawl-server/ before commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if has_staged_match '^client/scripts/.*\.gd$'; then
|
||||
if ! has_staged_match '^client/test/.*_test\.gd$'; then
|
||||
echo "pre-commit: client/scripts .gd changes require test updates in client/test/*_test.gd." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
|
@ -1,19 +1,30 @@
|
|||
# Installs repo pre-push hook (gdlint + gdformat). Same behavior as install-git-hooks.sh.
|
||||
# Installs repo pre-commit and pre-push hooks. Same behavior as install-git-hooks.sh.
|
||||
# Run from repo root: pwsh -File scripts/install-git-hooks.ps1
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
||||
$hookDir = Join-Path $repoRoot ".git/hooks"
|
||||
$preCommitHookPath = Join-Path $hookDir "pre-commit"
|
||||
$hookPath = Join-Path $hookDir "pre-push"
|
||||
New-Item -ItemType Directory -Force -Path $hookDir | Out-Null
|
||||
$unixRoot = $repoRoot -replace "\\", "/"
|
||||
$lines = @(
|
||||
$preCommitLines = @(
|
||||
"#!/bin/sh",
|
||||
"set -e",
|
||||
"repo_root=`"$unixRoot`"",
|
||||
'cd "$repo_root" || exit 1',
|
||||
'exec sh "$repo_root/scripts/git-hooks/pre-commit" "$@"'
|
||||
)
|
||||
$preCommitContent = ($preCommitLines -join "`n") + "`n"
|
||||
$pushLines = @(
|
||||
"#!/bin/sh",
|
||||
"set -e",
|
||||
"repo_root=`"$unixRoot`"",
|
||||
'cd "$repo_root" || exit 1',
|
||||
'exec sh "$repo_root/scripts/git-hooks/pre-push" "$@"'
|
||||
)
|
||||
$content = ($lines -join "`n") + "`n"
|
||||
$content = ($pushLines -join "`n") + "`n"
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText($preCommitHookPath, $preCommitContent, $utf8)
|
||||
[System.IO.File]::WriteAllText($hookPath, $content, $utf8)
|
||||
Write-Host "Installed pre-commit hook at $preCommitHookPath"
|
||||
Write-Host "Installed pre-push hook at $hookPath"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,19 @@ set -euo pipefail
|
|||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
hook_dir="$repo_root/.git/hooks"
|
||||
pre_commit_hook_path="$hook_dir/pre-commit"
|
||||
hook_path="$hook_dir/pre-push"
|
||||
|
||||
mkdir -p "$hook_dir"
|
||||
|
||||
cat >"$pre_commit_hook_path" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
exec "$repo_root/scripts/git-hooks/pre-commit" "$@"
|
||||
EOF
|
||||
|
||||
cat >"$hook_path" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
|
@ -15,5 +24,7 @@ repo_root="$(git rev-parse --show-toplevel)"
|
|||
exec "$repo_root/scripts/git-hooks/pre-push" "$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$pre_commit_hook_path"
|
||||
chmod +x "$hook_path"
|
||||
echo "Installed pre-commit hook at $pre_commit_hook_path"
|
||||
echo "Installed pre-push hook at $hook_path"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,258 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||
|
||||
public sealed class HotbarLoadoutApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetHotbarLoadout_ShouldReturnEightSlots_WhenPlayerKnown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/players/dev-local-1/hotbar-loadout");
|
||||
var body = await response.Content.ReadFromJsonAsync<HotbarLoadoutResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(HotbarLoadoutResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.Equal(HotbarLoadoutResponse.SlotCountV1, body.SlotCount);
|
||||
Assert.Equal(HotbarLoadoutResponse.SlotCountV1, body.Slots.Count);
|
||||
Assert.All(body.Slots, slot => Assert.Null(slot.AbilityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldPersistBindings_AndRoundTripInGet()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots =
|
||||
[
|
||||
new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse },
|
||||
new HotbarSlotBindingJson { SlotIndex = 3, AbilityId = PrototypeAbilityRegistry.PrototypeBurst },
|
||||
],
|
||||
});
|
||||
var postBody = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||
var get = await client.GetFromJsonAsync<HotbarLoadoutResponse>("/game/players/dev-local-1/hotbar-loadout");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(postBody);
|
||||
Assert.True(postBody!.Updated);
|
||||
Assert.Null(postBody.ReasonCode);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, postBody.Loadout.Slots[0].AbilityId);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, postBody.Loadout.Slots[3].AbilityId);
|
||||
Assert.NotNull(get);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, get!.Slots[0].AbilityId);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, get.Slots[3].AbilityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldDenyOutOfBounds_WithReasonCode()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = 8, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||
});
|
||||
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Updated);
|
||||
Assert.Equal(HotbarLoadoutApi.ReasonSlotOutOfBounds, body.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldDenyNegativeSlot_WithReasonCode()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = -1, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||
});
|
||||
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Updated);
|
||||
Assert.Equal(HotbarLoadoutApi.ReasonSlotOutOfBounds, body.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldDenyUnknownAbility_WithReasonCode()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = 2, AbilityId = "missing_ability" }],
|
||||
});
|
||||
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Updated);
|
||||
Assert.Equal(HotbarLoadoutApi.ReasonUnknownAbility, body.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldDenyDuplicateSlots_WithReasonCode()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots =
|
||||
[
|
||||
new HotbarSlotBindingJson { SlotIndex = 1, AbilityId = PrototypeAbilityRegistry.PrototypePulse },
|
||||
new HotbarSlotBindingJson { SlotIndex = 1, AbilityId = PrototypeAbilityRegistry.PrototypeGuard },
|
||||
],
|
||||
});
|
||||
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Updated);
|
||||
Assert.Equal(HotbarLoadoutApi.ReasonDuplicateSlot, body.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldNormalizeAbilityIdCaseAndWhitespace_WhenKnownAbility()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = 5, AbilityId = " PROTOTYPE_DASH " }],
|
||||
});
|
||||
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Updated);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeDash, body.Loadout.Slots[5].AbilityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldReturnBadRequest_WhenSchemaVersionWrong()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = 999,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, post.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldReturnBadRequest_WhenBodyMalformed()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new StringContent("{\"schemaVersion\":1,\"slots\":[", Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHotbarLoadout_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/players/missing-player/hotbar-loadout");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostHotbarLoadout_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/missing-player/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, post.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class HotbarLoadoutPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||
{
|
||||
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task PostThenGetAcrossNewFactory_ShouldPersistHotbarBindings()
|
||||
{
|
||||
// Arrange
|
||||
await ResetHotbarTableAsync();
|
||||
var update = new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots =
|
||||
[
|
||||
new HotbarSlotBindingJson
|
||||
{
|
||||
SlotIndex = 1,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
},
|
||||
new HotbarSlotBindingJson
|
||||
{
|
||||
SlotIndex = 4,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
|
||||
},
|
||||
],
|
||||
};
|
||||
HttpStatusCode postStatus;
|
||||
|
||||
// Act
|
||||
using (var firstClient = Factory.CreateClient())
|
||||
{
|
||||
var post = await firstClient.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
update);
|
||||
postStatus = post.StatusCode;
|
||||
}
|
||||
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondClient = secondFactory.CreateClient();
|
||||
var loadout = await secondClient.GetFromJsonAsync<HotbarLoadoutResponse>(
|
||||
"/game/players/dev-local-1/hotbar-loadout");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, postStatus);
|
||||
Assert.NotNull(loadout);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, loadout!.Slots[1].AbilityId);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeGuard, loadout.Slots[4].AbilityId);
|
||||
}
|
||||
|
||||
private static async Task ResetHotbarTableAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V002__player_hotbar_loadout.sql");
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = await File.ReadAllTextAsync(ddlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var apply = new NpgsqlCommand(ddl, conn))
|
||||
{
|
||||
await apply.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_hotbar_loadout;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,37 +8,66 @@ public class HorizontalReachTests
|
|||
[Fact]
|
||||
public void HorizontalDistance_IgnoresY()
|
||||
{
|
||||
// Y is not a parameter; identical X/Z → zero horizontal separation regardless of implied height difference.
|
||||
// Arrange
|
||||
// Y is not a parameter; identical X/Z -> zero horizontal separation regardless of implied height difference.
|
||||
|
||||
// Act
|
||||
var d = HorizontalReach.HorizontalDistance(1, 2, 1, 2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWithinHorizontalRadius_AtExactBoundary_ReturnsTrue()
|
||||
{
|
||||
Assert.True(HorizontalReach.IsWithinHorizontalRadius(0, 0, 3, 0, 3));
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
var isInRange = HorizontalReach.IsWithinHorizontalRadius(0, 0, 3, 0, 3);
|
||||
|
||||
// Assert
|
||||
Assert.True(isInRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWithinHorizontalRadius_JustInside_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
const double radius = 3.0;
|
||||
const double eps = 1e-10;
|
||||
Assert.True(HorizontalReach.IsWithinHorizontalRadius(0, 0, radius - eps, 0, radius));
|
||||
|
||||
// Act
|
||||
var isInRange = HorizontalReach.IsWithinHorizontalRadius(0, 0, radius - eps, 0, radius);
|
||||
|
||||
// Assert
|
||||
Assert.True(isInRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWithinHorizontalRadius_JustOutside_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
const double radius = 3.0;
|
||||
const double eps = 1e-7;
|
||||
Assert.False(HorizontalReach.IsWithinHorizontalRadius(0, 0, radius + eps, 0, radius));
|
||||
|
||||
// Act
|
||||
var isInRange = HorizontalReach.IsWithinHorizontalRadius(0, 0, radius + eps, 0, radius);
|
||||
|
||||
// Assert
|
||||
Assert.False(isInRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWithinHorizontalRadius_NegativeRadius_ThrowsArgumentOutOfRange()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0));
|
||||
// Arrange
|
||||
Action act = () => HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0);
|
||||
|
||||
// Act
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(act);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ public class InteractablesWorldApiTests
|
|||
{
|
||||
[Fact]
|
||||
public async Task GetInteractables_ShouldReturnOrderedDescriptors_WithSchemaV1()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.GetAsync("/game/world/interactables");
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractablesListResponse>();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ public class InteractionApiTests
|
|||
[Fact]
|
||||
public async Task PostInteract_ShouldAllow_WhenExactlyAtHorizontalRadius()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var move = new MoveCommandRequest
|
||||
|
|
@ -20,7 +21,9 @@ public class InteractionApiTests
|
|||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 3, Y = 0, Z = 0 },
|
||||
};
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
|
||||
|
||||
// Act
|
||||
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
|
|
@ -29,16 +32,19 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldAllow_WhenPlayerInHorizontalRange()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var move = new MoveCommandRequest
|
||||
|
|
@ -46,7 +52,9 @@ public class InteractionApiTests
|
|||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 0, Y = 0.9, Z = 0 },
|
||||
};
|
||||
// Act
|
||||
var moveResp = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, moveResp.StatusCode);
|
||||
|
||||
var req = new InteractionRequest
|
||||
|
|
@ -69,7 +77,8 @@ public class InteractionApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldDenyOutOfRange_WhenPlayerSeededFarFromTerminal()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var req = new InteractionRequest
|
||||
|
|
@ -77,8 +86,10 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
|
|
@ -89,7 +100,8 @@ public class InteractionApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldDenyUnknownInteractable_WhenIdNotInRegistry()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var req = new InteractionRequest
|
||||
|
|
@ -97,8 +109,10 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = "no_such_thing",
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
|
|
@ -110,6 +124,7 @@ public class InteractionApiTests
|
|||
[Fact]
|
||||
public async Task PostInteract_ShouldBeCaseInsensitive_ForInteractableId()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var move = new MoveCommandRequest
|
||||
|
|
@ -117,7 +132,9 @@ public class InteractionApiTests
|
|||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 0, Y = 0, Z = 0 },
|
||||
};
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
|
||||
|
||||
// Act
|
||||
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||
|
||||
var req = new InteractionRequest
|
||||
{
|
||||
|
|
@ -126,16 +143,19 @@ public class InteractionApiTests
|
|||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldReturnBadRequest_WhenSchemaVersionWrong()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var req = new InteractionRequest
|
||||
|
|
@ -143,30 +163,36 @@ public class InteractionApiTests
|
|||
SchemaVersion = 999,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldReturnBadRequest_WhenInteractableIdMissing()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var content = new StringContent(
|
||||
$"{{\"schemaVersion\":{InteractionRequest.CurrentSchemaVersion}}}",
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsync("/game/players/dev-local-1/interact", content);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldReturnBadRequest_WhenInteractableIdWhitespaceOnly()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var req = new InteractionRequest
|
||||
|
|
@ -174,15 +200,18 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = " \t ",
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var req = new InteractionRequest
|
||||
|
|
@ -190,17 +219,21 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/nobody-here/interact", req);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldReflectNewPosition_AfterMove()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var far = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
|
|
@ -210,6 +243,7 @@ public class InteractionApiTests
|
|||
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
|
||||
});
|
||||
var farBody = await far.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
// Assert
|
||||
Assert.False(farBody!.Allowed);
|
||||
Assert.Equal(InteractionApi.ReasonOutOfRange, farBody.ReasonCode);
|
||||
|
||||
|
|
@ -234,6 +268,7 @@ public class InteractionApiTests
|
|||
[Fact]
|
||||
public async Task PostInteract_ShouldAllowResourceNode_WhenPlayerInRangeOfResourceAnchor()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var move = new MoveCommandRequest
|
||||
|
|
@ -241,7 +276,9 @@ public class InteractionApiTests
|
|||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 12, Y = 0.9, Z = -6 },
|
||||
};
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
|
||||
|
||||
// Act
|
||||
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
|
|
@ -250,9 +287,11 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Allowed);
|
||||
Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, body.InteractableId);
|
||||
|
|
@ -260,9 +299,11 @@ public class InteractionApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostInteract_ShouldDenyOutOfRange_ForResourceNode_WhenPlayerFar()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
new InteractionRequest
|
||||
|
|
@ -270,6 +311,7 @@ public class InteractionApiTests
|
|||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
|
|
|
|||
|
|
@ -110,7 +110,8 @@ public class MoveCommandApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostMove_ShouldReturn400WithReason_WhenHorizontalStepTooLarge()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b =>
|
||||
{
|
||||
b.ConfigureTestServices(services =>
|
||||
|
|
@ -128,8 +129,10 @@ public class MoveCommandApiTests
|
|||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 0, Y = 0.9, Z = 0 },
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<MoveCommandRejectedResponse>();
|
||||
|
|
@ -140,7 +143,8 @@ public class MoveCommandApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostMove_ShouldReturn400WithReason_WhenVerticalStepTooLarge()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b =>
|
||||
{
|
||||
b.ConfigureTestServices(services =>
|
||||
|
|
@ -154,8 +158,10 @@ public class MoveCommandApiTests
|
|||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = -5, Y = 2.0, Z = -5 },
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<MoveCommandRejectedResponse>();
|
||||
|
|
@ -165,12 +171,15 @@ public class MoveCommandApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostMove_ShouldReturn400WithoutReasonBody_WhenSchemaInvalid()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/game/players/dev-local-1/move",
|
||||
new StringContent("{\"schemaVersion\":999,\"target\":{\"x\":0,\"y\":0,\"z\":0}}", Encoding.UTF8, "application/json"));
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
|
|
|
|||
|
|
@ -24,78 +24,123 @@ public class MoveCommandValidationTests
|
|||
[Fact]
|
||||
public void TryValidate_ShouldAllow_WhenHorizontalExactlyAtMax()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 0, 0, 0);
|
||||
var to = new PositionVector { X = 5, Y = 0, Z = 0 };
|
||||
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code);
|
||||
// Assert
|
||||
Assert.True(ok);
|
||||
Assert.Equal("", code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldRejectHorizontal_WhenJustOutsideMax()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 0, 0, 0);
|
||||
var to = new PositionVector { X = 5 + 1e-6, Y = 0, Z = 0 };
|
||||
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code);
|
||||
// Assert
|
||||
Assert.False(ok);
|
||||
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldAllow_WhenVerticalExactlyAtMax()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 1, 0, 0);
|
||||
var to = new PositionVector { X = 0, Y = 2.5, Z = 0 };
|
||||
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out _));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out _);
|
||||
// Assert
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldRejectVertical_WhenJustOutsideMax()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 1, 0, 0);
|
||||
var to = new PositionVector { X = 0, Y = 2.5 + 1e-9, Z = 0 };
|
||||
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out var code));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out var code);
|
||||
// Assert
|
||||
Assert.False(ok);
|
||||
Assert.Equal(MoveCommandReasonCodes.VerticalStepExceeded, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldPreferHorizontalReason_WhenBothWouldFail()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 0, 0, 0);
|
||||
var to = new PositionVector { X = 100, Y = 100, Z = 0 };
|
||||
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(1, 1), out var code));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(1, 1), out var code);
|
||||
// Assert
|
||||
Assert.False(ok);
|
||||
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldAllow_WhenHorizontalDisabled()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 0, 0, 0);
|
||||
var to = new PositionVector { X = 1000, Y = 0, Z = 1000 };
|
||||
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code);
|
||||
// Assert
|
||||
Assert.True(ok);
|
||||
Assert.Equal("", code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(0, 0, 0, 0);
|
||||
var to = new PositionVector { X = -50, Y = 0, Z = 0 };
|
||||
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(100, 100, bounds: false), out _));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(100, 100, bounds: false), out _);
|
||||
// Assert
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldRejectOutOfBounds_WhenDistrictEnabled()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(5, 5, 5, 0);
|
||||
var to = new PositionVector { X = 5, Y = 5, Z = 11 };
|
||||
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out var code));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out var code);
|
||||
// Assert
|
||||
Assert.False(ok);
|
||||
Assert.Equal(MoveCommandReasonCodes.OutOfBounds, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldAllowOnBoundsSurface_WhenDistrictEnabled()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var from = new PositionSnapshot(5, 5, 5, 0);
|
||||
var to = new PositionVector { X = 10, Y = 10, Z = 10 };
|
||||
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out _));
|
||||
// Act
|
||||
var ok = MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out _);
|
||||
// Assert
|
||||
Assert.True(ok);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ public sealed class MoveStreamApiTests
|
|||
{
|
||||
[Fact]
|
||||
public async Task PostMoveStream_ShouldApplyChainAndIncrementSequence_WhenTwoSmallSteps()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b =>
|
||||
{
|
||||
b.ConfigureTestServices(services =>
|
||||
|
|
@ -25,7 +26,9 @@ public sealed class MoveStreamApiTests
|
|||
});
|
||||
});
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var before = await client.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
|
||||
// Assert
|
||||
Assert.NotNull(before);
|
||||
var seq0 = before!.Sequence;
|
||||
|
||||
|
|
@ -52,7 +55,8 @@ public sealed class MoveStreamApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostMoveStream_ShouldReturnNotFound_WhenPlayerIsUnknown()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var body = new MoveStreamRequest
|
||||
|
|
@ -60,30 +64,36 @@ public sealed class MoveStreamApiTests
|
|||
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
|
||||
Targets = [new PositionVector { X = 0, Y = 0.9, Z = 0 }],
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/unknown-player/move-stream", body);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostMoveStream_ShouldReturn400_WhenTargetsEmpty()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var content = new StringContent(
|
||||
"{\"schemaVersion\":1,\"targets\":[]}",
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsync("/game/players/dev-local-1/move-stream", content);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostMoveStream_ShouldReturn400WithReason_WhenSecondStepExceedsHorizontalLimit()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b =>
|
||||
{
|
||||
b.ConfigureTestServices(services =>
|
||||
|
|
@ -105,8 +115,10 @@ public sealed class MoveStreamApiTests
|
|||
new PositionVector { X = -4.0, Y = 0.9, Z = -5.0 },
|
||||
],
|
||||
};
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move-stream", body);
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var rej = await response.Content.ReadFromJsonAsync<MoveCommandRejectedResponse>();
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
|||
await apply.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position;", conn))
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,48 +9,64 @@ public class PlayerTargetStateReaderTests
|
|||
[Fact]
|
||||
public void ComputeValidity_ShouldReturnInvalidTarget_WhenLockIdNotInRegistry()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var snap = new PositionSnapshot(0, 0, 0, 0);
|
||||
Assert.Equal(TargetValidity.InvalidTarget, PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap));
|
||||
// Act
|
||||
var validity = PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.InvalidTarget, validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeValidity_ShouldReturnNone_WhenLockNull()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
var snap = new PositionSnapshot(0, 0, 0, 0);
|
||||
Assert.Equal(TargetValidity.None, PlayerTargetStateReader.ComputeValidity(null, in snap));
|
||||
// Act
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(null, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.None, validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeValidity_AtExactLockRadius_ReturnsOk()
|
||||
{
|
||||
// Alpha anchor XZ (-3, -3), lockRadius 6 → horizontal distance 6 from (3, -3) is on boundary (inclusive).
|
||||
// Arrange
|
||||
// Alpha anchor XZ (-3, -3), lockRadius 6 -> horizontal distance 6 from (3, -3) is on boundary (inclusive).
|
||||
var snap = new PositionSnapshot(3, 0, -3, 0);
|
||||
Assert.Equal(
|
||||
TargetValidity.Ok,
|
||||
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
|
||||
// Act
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.Ok, validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
const double eps = 1e-6;
|
||||
var snap = new PositionSnapshot(3 + eps, 0, -3, 0);
|
||||
Assert.Equal(
|
||||
TargetValidity.OutOfRange,
|
||||
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
|
||||
// Act
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.OutOfRange, validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets()
|
||||
{
|
||||
// Arrange
|
||||
// NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can
|
||||
// flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m).
|
||||
var snap = new PositionSnapshot(0, 0, 0, 0);
|
||||
Assert.Equal(
|
||||
TargetValidity.Ok,
|
||||
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
|
||||
Assert.Equal(
|
||||
TargetValidity.Ok,
|
||||
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap));
|
||||
// Act
|
||||
var alphaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
|
||||
var betaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.Ok, alphaValidity);
|
||||
Assert.Equal(TargetValidity.Ok, betaValidity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ public class TargetingApiTests
|
|||
{
|
||||
[Fact]
|
||||
public async Task GetTarget_ShouldReturnNone_WhenNoLock()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.GetAsync("/game/players/dev-local-1/target");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
|
||||
Assert.NotNull(body);
|
||||
|
|
@ -29,9 +32,11 @@ public class TargetingApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldApplyLock_WhenInRange()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -40,6 +45,7 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -53,9 +59,11 @@ public class TargetingApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyUnknownTarget_WithReasonCode()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -64,6 +72,7 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = "not_a_real_target",
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -76,9 +85,11 @@ public class TargetingApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyOutOfRange_WhenBetaFarFromSpawn()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -87,6 +98,7 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -102,8 +114,10 @@ public class TargetingApiTests
|
|||
// NEO-24 follow-up #5: without the hint, spawn's stored position (-5, -5) denies beta (r=6,
|
||||
// anchor (3, 3)) — ~11.3 m out. With an advisory `positionHint` pinning the capsule inside
|
||||
// beta's ring, the server must accept. This is exactly the race-free path the client uses.
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -113,6 +127,7 @@ public class TargetingApiTests
|
|||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 },
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -127,8 +142,10 @@ public class TargetingApiTests
|
|||
{
|
||||
// Hint that places the capsule outside beta's ring must deny — hint is advisory only;
|
||||
// it does not bypass the radius check.
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -138,6 +155,7 @@ public class TargetingApiTests
|
|||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
PositionHint = new PositionVector { X = 50.0, Y = 0.0, Z = 50.0 },
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -151,10 +169,13 @@ public class TargetingApiTests
|
|||
{
|
||||
// Hint must be purely advisory for the range check. Confirm the stored `PositionState`
|
||||
// is untouched by a select-with-hint by reading it back through `GET /position`.
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var before = await client.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
|
||||
// Assert
|
||||
Assert.NotNull(before);
|
||||
|
||||
Assert.Equal(
|
||||
|
|
@ -179,18 +200,19 @@ public class TargetingApiTests
|
|||
[Fact]
|
||||
public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
})).StatusCode);
|
||||
// Act
|
||||
var setResponse = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
var clear = await client.PostAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -198,9 +220,11 @@ public class TargetingApiTests
|
|||
$"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}",
|
||||
Encoding.UTF8,
|
||||
"application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, clear.StatusCode);
|
||||
var body = await clear.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, clear.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Null(body.TargetState.LockedTargetId);
|
||||
|
|
@ -210,29 +234,34 @@ public class TargetingApiTests
|
|||
[Fact]
|
||||
public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
})).StatusCode);
|
||||
// Act
|
||||
var selectResponse = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
var move = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 50, Y = 0, Z = 50 },
|
||||
};
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
|
||||
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||
|
||||
var get = await client.GetAsync("/game/players/dev-local-1/target");
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
var body = await get.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, selectResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.OutOfRange, body.Validity);
|
||||
|
|
@ -240,19 +269,24 @@ public class TargetingApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task GetTarget_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.GetAsync("/game/players/missing-player/target");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/missing-player/target/select",
|
||||
|
|
@ -261,15 +295,18 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldReturnBadRequest_WhenSchemaVersionWrong()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -278,15 +315,18 @@ public class TargetingApiTests
|
|||
SchemaVersion = 999,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldReturnBadRequest_WhenTargetIdWhitespaceOnly()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -295,15 +335,18 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = " \t ",
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldBeCaseInsensitive_ForTargetId()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -312,6 +355,7 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = " PROTOTYPE_TARGET_ALPHA ",
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -322,7 +366,8 @@ public class TargetingApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldStayIdempotent_WhenSameTargetReselect()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
|
|
@ -331,8 +376,10 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
};
|
||||
// Act
|
||||
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
|
||||
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
var b1 = await first.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -342,9 +389,11 @@ public class TargetingApiTests
|
|||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyOutOfRange_WhenReselectingSameLockWhileOutOfRange()
|
||||
{
|
||||
{ // Arrange
|
||||
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
|
||||
await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
|
|
@ -368,6 +417,7 @@ public class TargetingApiTests
|
|||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using Npgsql;
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
{
|
||||
var d = services[i];
|
||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
(d.ServiceType == typeof(IHostedService) &&
|
||||
(d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
|
||||
|
|
@ -29,6 +31,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
}
|
||||
|
||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
[Fact]
|
||||
public async Task MethodName_ShouldExpectedOutcome_WhenScenario()
|
||||
{
|
||||
// Arrange
|
||||
// Create factory/client/dependencies and test data.
|
||||
|
||||
// Act
|
||||
// Invoke exactly one behavior/scenario under test.
|
||||
|
||||
// Assert
|
||||
// Verify outcome(s) with Assert.* calls.
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Maps hotbar loadout read/update APIs (NEO-29).</summary>
|
||||
public static class HotbarLoadoutApi
|
||||
{
|
||||
public const string ReasonSlotOutOfBounds = "slot_out_of_bounds";
|
||||
public const string ReasonUnknownAbility = "unknown_ability";
|
||||
public const string ReasonDuplicateSlot = "duplicate_slot";
|
||||
|
||||
public static WebApplication MapHotbarLoadoutApi(this WebApplication app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/game/players/{id}/hotbar-loadout",
|
||||
(string id, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
|
||||
{
|
||||
if (!positions.TryGetPosition(id, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!store.TryGetBindings(id, out var bindings))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(BuildLoadoutResponse(id, bindings));
|
||||
});
|
||||
|
||||
app.MapPost(
|
||||
"/game/players/{id}/hotbar-loadout",
|
||||
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != HotbarLoadoutUpdateRequest.CurrentSchemaVersion)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
if (!positions.TryGetPosition(id, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!store.TryGetBindings(id, out var before))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var updates = body.Slots ?? [];
|
||||
var parsedUpdates = new List<HotbarSlotBinding>(updates.Count);
|
||||
var seenSlots = new HashSet<int>();
|
||||
foreach (var update in updates)
|
||||
{
|
||||
if (update.SlotIndex < 0 || update.SlotIndex >= HotbarLoadoutResponse.SlotCountV1)
|
||||
{
|
||||
return Results.Json(
|
||||
new HotbarLoadoutUpdateResponse
|
||||
{
|
||||
Updated = false,
|
||||
ReasonCode = ReasonSlotOutOfBounds,
|
||||
Loadout = BuildLoadoutResponse(id, before),
|
||||
});
|
||||
}
|
||||
|
||||
if (!seenSlots.Add(update.SlotIndex))
|
||||
{
|
||||
return Results.Json(
|
||||
new HotbarLoadoutUpdateResponse
|
||||
{
|
||||
Updated = false,
|
||||
ReasonCode = ReasonDuplicateSlot,
|
||||
Loadout = BuildLoadoutResponse(id, before),
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(update.AbilityId) ||
|
||||
!PrototypeAbilityRegistry.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
|
||||
{
|
||||
return Results.Json(
|
||||
new HotbarLoadoutUpdateResponse
|
||||
{
|
||||
Updated = false,
|
||||
ReasonCode = ReasonUnknownAbility,
|
||||
Loadout = BuildLoadoutResponse(id, before),
|
||||
});
|
||||
}
|
||||
|
||||
parsedUpdates.Add(new HotbarSlotBinding(update.SlotIndex, normalizedAbilityId));
|
||||
}
|
||||
|
||||
if (!store.TryUpsertBindings(id, parsedUpdates, out var after))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(
|
||||
new HotbarLoadoutUpdateResponse
|
||||
{
|
||||
Updated = true,
|
||||
Loadout = BuildLoadoutResponse(id, after),
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HotbarLoadoutResponse BuildLoadoutResponse(string playerId, IReadOnlyDictionary<int, string> bindings)
|
||||
{
|
||||
var slots = new List<HotbarSlotStateJson>(HotbarLoadoutResponse.SlotCountV1);
|
||||
for (var i = 0; i < HotbarLoadoutResponse.SlotCountV1; i++)
|
||||
{
|
||||
bindings.TryGetValue(i, out var abilityId);
|
||||
slots.Add(
|
||||
new HotbarSlotStateJson
|
||||
{
|
||||
SlotIndex = i,
|
||||
AbilityId = abilityId,
|
||||
});
|
||||
}
|
||||
|
||||
return new HotbarLoadoutResponse
|
||||
{
|
||||
PlayerId = playerId.Trim(),
|
||||
Slots = slots,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Canonical hotbar loadout payload returned by loadout APIs (NEO-29).</summary>
|
||||
public sealed class HotbarLoadoutResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
public const int SlotCountV1 = 8;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("playerId")]
|
||||
public required string PlayerId { get; init; }
|
||||
|
||||
[JsonPropertyName("slotCount")]
|
||||
public int SlotCount { get; init; } = SlotCountV1;
|
||||
|
||||
/// <summary>Always includes v1 slots 0..7 in ascending order.</summary>
|
||||
[JsonPropertyName("slots")]
|
||||
public required IReadOnlyList<HotbarSlotStateJson> Slots { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST body for hotbar loadout updates.</summary>
|
||||
public sealed class HotbarLoadoutUpdateRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Requested slot bindings to apply. Slot entries are upserts; unspecified slots keep previous values.
|
||||
/// </summary>
|
||||
[JsonPropertyName("slots")]
|
||||
public IReadOnlyList<HotbarSlotBindingJson>? Slots { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST response for hotbar updates (applied or denied).</summary>
|
||||
public sealed class HotbarLoadoutUpdateResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
public bool Updated { get; init; }
|
||||
|
||||
[JsonPropertyName("loadout")]
|
||||
public required HotbarLoadoutResponse Loadout { get; init; }
|
||||
|
||||
/// <summary>Required on deny responses; omitted when <see cref="Updated"/> is true.</summary>
|
||||
[JsonPropertyName("reasonCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Wire shape for an individual slot state in GET/POST responses.</summary>
|
||||
public sealed class HotbarSlotStateJson
|
||||
{
|
||||
[JsonPropertyName("slotIndex")]
|
||||
public required int SlotIndex { get; init; }
|
||||
|
||||
/// <summary>Canonical lowercase ability id or null when the slot is unbound.</summary>
|
||||
[JsonPropertyName("abilityId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public string? AbilityId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Wire shape for an individual slot update in POST requests.</summary>
|
||||
public sealed class HotbarSlotBindingJson
|
||||
{
|
||||
[JsonPropertyName("slotIndex")]
|
||||
public int SlotIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("abilityId")]
|
||||
public string? AbilityId { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Registers hotbar loadout persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-29).</summary>
|
||||
public static class HotbarLoadoutServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddHotbarLoadoutStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var cs = configuration.GetConnectionString(PositionState.PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, PostgresPlayerHotbarLoadoutStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Persistence abstraction for per-player hotbar bindings (NEO-29).</summary>
|
||||
public interface IPlayerHotbarLoadoutStore
|
||||
{
|
||||
bool TryGetBindings(string playerId, out IReadOnlyDictionary<int, string> bindings);
|
||||
|
||||
bool TryUpsertBindings(string playerId, IReadOnlyList<HotbarSlotBinding> updates, out IReadOnlyDictionary<int, string> bindings);
|
||||
}
|
||||
|
||||
public readonly record struct HotbarSlotBinding(int SlotIndex, string AbilityId);
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Thread-safe in-memory hotbar loadout store; seeds configured dev player with an empty loadout.</summary>
|
||||
public sealed class InMemoryPlayerHotbarLoadoutStore(IOptions<GamePositionOptions> options) : IPlayerHotbarLoadoutStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, string>> byPlayer =
|
||||
CreateInitialMap(options.Value);
|
||||
|
||||
private static ConcurrentDictionary<string, ConcurrentDictionary<int, string>> CreateInitialMap(GamePositionOptions o)
|
||||
{
|
||||
var id = NormalizePlayerId(o.DevPlayerId);
|
||||
var map = new ConcurrentDictionary<string, ConcurrentDictionary<int, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
map[id] = new ConcurrentDictionary<int, string>();
|
||||
return map;
|
||||
}
|
||||
|
||||
public bool TryGetBindings(string playerId, out IReadOnlyDictionary<int, string> bindings)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var map))
|
||||
{
|
||||
bindings = EmptyBindings();
|
||||
return false;
|
||||
}
|
||||
|
||||
bindings = Clone(map);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryUpsertBindings(string playerId, IReadOnlyList<HotbarSlotBinding> updates, out IReadOnlyDictionary<int, string> bindings)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var map))
|
||||
{
|
||||
bindings = EmptyBindings();
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var update in updates)
|
||||
{
|
||||
map[update.SlotIndex] = update.AbilityId;
|
||||
}
|
||||
|
||||
bindings = Clone(map);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<int, string> Clone(ConcurrentDictionary<int, string> source)
|
||||
{
|
||||
var copy = new Dictionary<int, string>(source.Count);
|
||||
foreach (var kv in source)
|
||||
{
|
||||
copy[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<int, string> EmptyBindings() => new Dictionary<int, string>();
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Applies NEO-29 hotbar loadout table DDL once per process.</summary>
|
||||
public static class PostgresHotbarLoadoutBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V002__player_hotbar_loadout.sql");
|
||||
private static readonly object SchemaGate = new();
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NEO-29 DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
System.Threading.Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>PostgreSQL-backed hotbar loadout store keyed by normalized player id.</summary>
|
||||
public sealed class PostgresPlayerHotbarLoadoutStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerHotbarLoadoutStore
|
||||
{
|
||||
public bool TryGetBindings(string playerId, out IReadOnlyDictionary<int, string> bindings)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
bindings = EmptyBindings();
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresHotbarLoadoutBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
if (!PlayerExists(conn, norm))
|
||||
{
|
||||
bindings = EmptyBindings();
|
||||
return false;
|
||||
}
|
||||
|
||||
bindings = ReadBindings(conn, norm);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryUpsertBindings(string playerId, IReadOnlyList<HotbarSlotBinding> updates, out IReadOnlyDictionary<int, string> bindings)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
bindings = EmptyBindings();
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresHotbarLoadoutBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
|
||||
if (!PlayerExists(conn, norm, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
bindings = EmptyBindings();
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var update in updates)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_hotbar_loadout (player_id, slot_index, ability_id)
|
||||
VALUES (@pid, @slot, @ability)
|
||||
ON CONFLICT (player_id, slot_index)
|
||||
DO UPDATE SET ability_id = EXCLUDED.ability_id, updated_at = now();
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
cmd.Parameters.AddWithValue("slot", update.SlotIndex);
|
||||
cmd.Parameters.AddWithValue("ability", update.AbilityId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
bindings = ReadBindings(conn, norm, tx);
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<int, string> ReadBindings(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
string playerIdNormalized,
|
||||
Npgsql.NpgsqlTransaction? tx = null)
|
||||
{
|
||||
var map = new Dictionary<int, string>();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT slot_index, ability_id
|
||||
FROM player_hotbar_loadout
|
||||
WHERE player_id = @pid
|
||||
ORDER BY slot_index;
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
map[reader.GetInt32(0)] = reader.GetString(1);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<int, string> EmptyBindings() => new Dictionary<int, string>();
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Prototype ability allowlist for NEO-29 hotbar bindings.</summary>
|
||||
public static class PrototypeAbilityRegistry
|
||||
{
|
||||
public const string PrototypePulse = "prototype_pulse";
|
||||
public const string PrototypeGuard = "prototype_guard";
|
||||
public const string PrototypeDash = "prototype_dash";
|
||||
public const string PrototypeBurst = "prototype_burst";
|
||||
|
||||
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal)
|
||||
{
|
||||
PrototypePulse,
|
||||
PrototypeGuard,
|
||||
PrototypeDash,
|
||||
PrototypeBurst,
|
||||
};
|
||||
|
||||
public static bool TryNormalizeKnown(string rawAbilityId, out string normalized)
|
||||
{
|
||||
normalized = rawAbilityId.Trim().ToLowerInvariant();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Allowed.Contains(normalized);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ namespace NeonSprawl.Server.Game.PositionState;
|
|||
/// <summary>Registers position authority: PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set, otherwise in-memory (NS-15 / NS-17).</summary>
|
||||
public static class PositionStateServiceCollectionExtensions
|
||||
{
|
||||
private const string NeonSprawlConnectionStringName = "NeonSprawl";
|
||||
internal const string NeonSprawlConnectionStringName = "NeonSprawl";
|
||||
|
||||
public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ public sealed class InMemoryPlayerTargetLockStore : IPlayerTargetLockStore
|
|||
|
||||
var next = new PlayerLockRow(null, prev.Sequence + 1);
|
||||
rows[key] = next;
|
||||
// NEO-27 telemetry hook site: `target_changed` fires when lock id transitions.
|
||||
// TODO(E9.M1): emit cataloged telemetry event (player id, previous/next lock, cause=clear).
|
||||
return (null, next.Sequence);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +69,8 @@ public sealed class InMemoryPlayerTargetLockStore : IPlayerTargetLockStore
|
|||
|
||||
var next = new PlayerLockRow(targetIdLowercase, prev.Sequence + 1);
|
||||
rows[key] = next;
|
||||
// NEO-27 telemetry hook site: `target_changed` fires when lock id transitions.
|
||||
// TODO(E9.M1): emit cataloged telemetry event (player id, previous/next lock, cause=tab/select).
|
||||
return (next.LockIdLowercase, next.Sequence);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ namespace NeonSprawl.Server.Game.Targeting;
|
|||
/// <summary>Maps <c>GET/POST …/target</c> selection APIs (NEO-23).</summary>
|
||||
public static class TargetingApi
|
||||
{
|
||||
// NEO-27 note: these are target-selection deny reasons, not cast telemetry events.
|
||||
// Future ability flow (E1.M4 + E5.M1) will hook `ability_cast_denied` on cast endpoints.
|
||||
// TODO(E9.M1): map deny reason telemetry to cataloged event schema.
|
||||
public const string ReasonUnknownTarget = "unknown_target";
|
||||
public const string ReasonOutOfRange = "out_of_range";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddPositionStateStore(builder.Configuration);
|
||||
builder.Services.AddHotbarLoadoutStore(builder.Configuration);
|
||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -22,5 +24,6 @@ app.MapPositionStateApi();
|
|||
app.MapInteractionApi();
|
||||
app.MapInteractablesWorldApi();
|
||||
app.MapTargetingApi();
|
||||
app.MapHotbarLoadoutApi();
|
||||
|
||||
app.Run();
|
||||
|
|
|
|||
|
|
@ -201,6 +201,28 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
|
|||
|
||||
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
|
||||
|
||||
## Hotbar loadout (NEO-29)
|
||||
|
||||
Prototype server-owned hotbar bindings are available at:
|
||||
|
||||
- **`GET /game/players/{id}/hotbar-loadout`** → returns `HotbarLoadoutResponse` v1 with fixed `slotCount` **8** and `slots` array entries (`slotIndex`, nullable `abilityId`) for every slot.
|
||||
- **`POST /game/players/{id}/hotbar-loadout`** with `HotbarLoadoutUpdateRequest` v1 (`schemaVersion`, `slots[]`) upserts one or more slot bindings and returns `HotbarLoadoutUpdateResponse` v1.
|
||||
|
||||
**Prototype policy (kickoff decision):**
|
||||
|
||||
- Scope is **per player id** (same keying strategy as `PositionState` / `TargetState`).
|
||||
- Persistence mirrors NEO-8/NS-17: **Postgres when `ConnectionStrings:NeonSprawl` exists**, otherwise **in-memory fallback**.
|
||||
|
||||
**Validation deny reason codes (POST 200 with `updated=false`):**
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `slot_out_of_bounds` | `slotIndex` is outside v1 range `0..7`. |
|
||||
| `unknown_ability` | `abilityId` is empty/whitespace or not in the prototype allowlist. |
|
||||
| `duplicate_slot` | Request repeats the same `slotIndex` more than once. |
|
||||
|
||||
Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`.
|
||||
|
||||
## Solution
|
||||
|
||||
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
-- NEO-29: per-player hotbar slot bindings (prototype, v1).
|
||||
CREATE TABLE IF NOT EXISTS player_hotbar_loadout (
|
||||
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
|
||||
slot_index INTEGER NOT NULL,
|
||||
ability_id TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (player_id, slot_index)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE player_hotbar_loadout IS 'Persisted hotbar slot bindings per player (NEO-29).';
|
||||
Loading…
Reference in New Issue