Compare commits
30 Commits
d77462012c
...
2a9c1fd61f
| Author | SHA1 | Date |
|---|---|---|
|
|
2a9c1fd61f | |
|
|
986613fd26 | |
|
|
f1b4e50729 | |
|
|
71232c36ef | |
|
|
5e16fdccc8 | |
|
|
8bc2efb854 | |
|
|
15d405261f | |
|
|
b2d2500e6a | |
|
|
185af5a7f6 | |
|
|
1d891eb8bb | |
|
|
8c23feb7cf | |
|
|
491c75eca4 | |
|
|
cdf9d59bd9 | |
|
|
ae643bc81c | |
|
|
ff830813c7 | |
|
|
b9ec6ccb0f | |
|
|
0d71b873af | |
|
|
806777b6d5 | |
|
|
753ce84861 | |
|
|
f4a30b1b9d | |
|
|
8865329210 | |
|
|
b52862f6ee | |
|
|
e000da20ee | |
|
|
adde98e4a8 | |
|
|
6bc8014adf | |
|
|
8c3e8c0a03 | |
|
|
2617b65f79 | |
|
|
62ff210830 | |
|
|
90fcd1b717 | |
|
|
abffd29299 |
|
|
@ -29,7 +29,7 @@ Align recommendations with repo rules and docs, including:
|
|||
|
||||
For every review, **identify and cite** the documentation that defines intent for the change, then **check the implementation against it** (not only style and correctness).
|
||||
|
||||
1. **Story / implementation plans** — If the work maps to a ticket or plan under `docs/plans/` (e.g. `NEON-*-implementation-plan.md`), treat that document as acceptance criteria. If the diff diverges without an updated plan or an explicit “out of scope” note, call it out (blocking or non-blocking by severity).
|
||||
1. **Story / implementation plans** — If the work maps to a ticket or plan under `docs/plans/` (e.g. `NEON-*-implementation-plan.md`), treat that document as acceptance criteria. If the diff diverges without an updated plan or an explicit “out of scope” note, call it out (blocking or non-blocking by severity). Material **planning or implementation decisions** (options chosen, risks closed) should appear in that plan or related `docs/` per [planning-implementation-docs](planning-implementation-docs.md); if they exist only in PR/chat, note as a **should fix**.
|
||||
2. **Module docs** — Map the change to one or more modules in [`docs/decomposition/modules/module_dependency_register.md`](../../docs/decomposition/modules/module_dependency_register.md) and the corresponding `docs/decomposition/modules/E*_*.md` pages. Verify behavior matches **Purpose**, **Responsibilities**, **Key contracts**, and **Authority** / linked policy sections where relevant.
|
||||
3. **Cross-cutting policies** — When applicable, align with `docs/decomposition/modules/` policy docs (e.g. [`contracts.md`](../../docs/decomposition/modules/contracts.md), [`client_server_authority.md`](../../docs/decomposition/modules/client_server_authority.md), [`pvp_combat_integration.md`](../../docs/decomposition/modules/pvp_combat_integration.md), [`data_and_ops_policy.md`](../../docs/decomposition/modules/data_and_ops_policy.md)).
|
||||
4. **Implementation status** — If the register or [`documentation_and_implementation_alignment.md`](../../docs/decomposition/modules/documentation_and_implementation_alignment.md) tracks the module, note whether **Status** / the implementation tracking table should be updated after this merge.
|
||||
|
|
@ -80,6 +80,15 @@ Use this structure in **both** the saved document and (abbreviated if you want)
|
|||
|
||||
In **chat only**, you may use Cursor line-number **code citations** when referencing existing code. For hypothetical fixes, use normal fenced code blocks everywhere.
|
||||
|
||||
## Resolved suggestions (mandatory when feedback is addressed)
|
||||
|
||||
If you **implement** changes that resolve **blocking issues**, **suggestions**, or agreed **nits** from an existing **`docs/reviews/…`** file for the same work:
|
||||
|
||||
- **Edit that review document in place:** strike through the original bullets under **`## Suggestions`** / **`## Nits`** / **`## Blocking issues`** (`~~…~~`), append **`Done.`** / **`Addressed.`** / **`Deferred…`** plus a short note and optional commit SHA; optionally a one-line **Follow-up** preamble after **Scope** when appropriate. Full pattern: [planning-implementation-docs](planning-implementation-docs.md) **Code review follow-up**.
|
||||
- Do **not** add a **separate** section duplicating resolved items, and do **not** ship code-only fixes while those bullets still read as open.
|
||||
|
||||
The **initial** review does not need strikethroughs; this rule applies when **closing the loop** after the review exists.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do **not** skip writing `docs/reviews/…`; the document is mandatory output of this agent.
|
||||
|
|
|
|||
|
|
@ -29,3 +29,7 @@ alwaysApply: true
|
|||
## Scope
|
||||
|
||||
- Applies to **all** commits the agent might make, including **documentation-only** changes (e.g. `docs/plans/`, `docs/reviews/`, README), not only application source.
|
||||
|
||||
## Code review follow-up
|
||||
|
||||
When commits **address** feedback from a saved **`docs/reviews/…`** file, include an update to **that review file**: **strikethrough + Done** on the original **Suggestions** / **Nits** / **Blocking** bullets (not a separate resolved section). See [planning-implementation-docs](planning-implementation-docs.md) **Code review follow-up** and [code-review-agent](code-review-agent.md) **Resolved suggestions**.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Use this rule when the user is working in **`docs/`** (design, decomposition, pl
|
|||
1. **Same folder and upstream links** — Read every `docs/...` link from the target doc(s); verify paths exist and headings match intent.
|
||||
2. **Hybrid progression** — `docs/game-design/progression.md`, `skills.md`, `gigs.md`, `abilities.md`, `items.md`, `gathering.md`, `crafting.md`, `economy.md`, `death-loss-recovery.md`, `risk-security-bands.md`, `zones.md`: **Gig** = combat role; **Skill** = non-combat; **combat abilities** = gig kit (+ item channels per data); **Seams** live in `skills.md` unless a dedicated doc supersedes. **Combat encounters** → **gig XP** only (no default skill XP from the fight loop). **Gather / craft** → **skill XP** (see `gathering.md`, `crafting.md`, Epic 3)—not **gig** XP. **Mission/quest rewards** may grant **skill XP**, **currency**, or **items** when explicitly defined (`progression.md`, `economy.md`)—separate from the encounter **gig XP** path. **Economy** faucets/sinks and trade (`economy.md`) do not re-gate **craft** on **gig** (see `items.md` / Seams). **Death / loss / recovery** (`death-loss-recovery.md`): server-authoritative combat death; item stakes via **E3.M4** / **E5.M1**; **PvP** loss **E6.M3** vs **PvE** **open**; **cybernetics** vs external **gear** per **Seams**. **Risk / security** (`risk-security-bands.md`): **E4.M4** `SecurityTier` + **E6.M1**/**E6.M2** for **PvP** eligibility and **consent** UX—**not** client-only PvP toggles. **Zones** (`zones.md`): **place** **fiction** and **hooks** on the **same** **E4.M1** graph—**not** a second topology.
|
||||
3. **Decomposition** — Map claims to `docs/decomposition/epics/` and `docs/decomposition/modules/` where relevant; flag **conflicts** (e.g. “combat skill” vs skills-as-non-combat). **CI** enforces a minimal guard via [`scripts/check_decomposition_language.py`](../../scripts/check_decomposition_language.py) in the **PR gate** (extend the script if a vetted exception is needed).
|
||||
4. **Plans** — If work maps to `docs/plans/`, check for **acceptance criteria** vs design doc (same spirit as [code-review-agent](code-review-agent.md) plan alignment).
|
||||
4. **Plans** — If work maps to `docs/plans/`, check for **acceptance criteria** vs design doc (same spirit as [code-review-agent](code-review-agent.md) plan alignment). Flag plans that omit **documented decisions** or **resolved risks** when the conversation or diff shows they were settled—[planning-implementation-docs](planning-implementation-docs.md).
|
||||
|
||||
## Review checklist
|
||||
|
||||
|
|
@ -66,6 +66,10 @@ In the saved file, use **normal fenced code blocks** and **backtick paths** —
|
|||
|
||||
**Chat:** Short summary + **path to** `docs/reviews/YYYY-MM-DD-{slug}.md`.
|
||||
|
||||
## Resolved suggestions (mandatory when feedback is addressed)
|
||||
|
||||
If later edits **address** **blocking issues**, **suggestions**, or **nits** from a **docs-review** `docs/reviews/…` file, **update that review in place** with strikethrough + **`Done.`** on the original bullets per [planning-implementation-docs](planning-implementation-docs.md) **Code review follow-up** (same pattern as code reviews; no separate “resolved” section).
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do **not** skip writing `docs/reviews/…` unless the user explicitly asks for **chat-only** (then state that exception in chat and **do not** claim full agent output).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ alwaysApply: true
|
|||
|
||||
Follow the [Godot GDScript style guide](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html). Prefer clarity and consistency with existing scripts in the repo.
|
||||
|
||||
CI also enforces **`gdlint`** and **`gdformat`** (see `gdlintrc` and `.github/workflows/gdscript.yml`) on **`client/scripts/`** and **`client/test/`**. When this guide and personal preference differ, follow the **linted** version below so pushed files pass CI.
|
||||
|
||||
## Naming
|
||||
|
||||
- **Functions, variables, signals:** `snake_case`.
|
||||
|
|
@ -23,18 +25,43 @@ Follow the [Godot GDScript style guide](https://docs.godotengine.org/en/stable/t
|
|||
|
||||
## Formatting
|
||||
|
||||
- **One statement per line**; break long lines for readability (Godot line length is flexible—aim for ~100 characters unless a longer string is clearer).
|
||||
- **One statement per line**; CI enforces **max line length = 100**.
|
||||
- **Indentation:** use **tabs** for new Godot work (editor default). Do not mix tabs and spaces in the same file; match the file if it already uses spaces.
|
||||
- **Blank lines:** separate functions with two blank lines; sparing single blank lines inside functions for logical groups.
|
||||
- **Trailing commas** in multi-line collections/function args when it improves diffs.
|
||||
- No trailing whitespace.
|
||||
|
||||
## Structure
|
||||
|
||||
- **`class_name`** only when the type must be referenced globally; otherwise anonymous `extends` is fine.
|
||||
- Order loosely: `extends` → `class_name` → `signals` → `enums` → `const` → `export/@export` → `onready` → other vars → `_ready` / lifecycle → public methods → private helpers.
|
||||
- Declaration order should match `gdlint` `class-definitions-order`:
|
||||
`@tool` → `class_name` → `extends` → file/class docstrings → `signal` → `enum` → `const` → static vars → `@export` vars → public vars → private vars (`_foo`) → `@onready` public vars → `@onready` private vars → everything else.
|
||||
- Virtual overrides (`_ready`, `_process`, `_physics_process`, etc.): keep small; extract helpers with leading `_`.
|
||||
- **Scene / main script shape:** For `client/`, follow [godot-client-script-organization](godot-client-script-organization.md) — thin `main.gd`, split picking, networking, and similar concerns into child scripts.
|
||||
|
||||
## CI-enforced lint rules
|
||||
|
||||
- **Names:** keep functions, local vars, loop vars, arguments, signals, and class vars in `snake_case`; keep classes / enums in `PascalCase`; keep enum elements and true constants in `ALL_CAPS`.
|
||||
- **Signal callbacks:** `_on_Node_signal` style handlers are allowed; otherwise use normal `snake_case`.
|
||||
- **Preload / load identifiers:** when storing a preloaded script or type-like handle, prefer `PascalCase` names such as `CameraStateScript`; keep ordinary constants in `ALL_CAPS`.
|
||||
- Avoid duplicated `preload()`s for the same path in one file.
|
||||
- Avoid bare expressions that are not assigned / returned / awaited.
|
||||
- Remove unnecessary `pass`.
|
||||
- Keep files reasonably small: CI enforces **max 1000 lines**, **max 20 public methods**, **max 10 function arguments**, and **max 6 returns** per function.
|
||||
- Prefer early-return style. `gdlint` forbids `else` / `elif` branches that directly follow a `return`.
|
||||
- Keep unused arguments intentional and named clearly if you truly need them.
|
||||
|
||||
## Local verification
|
||||
|
||||
- Before pushing GDScript changes, run the same tools CI uses:
|
||||
`pip install "gdtoolkit==4.5.0"`
|
||||
`gdlint client/scripts client/test`
|
||||
`gdformat --check client/scripts client/test`
|
||||
- If formatting fails, run:
|
||||
`gdformat client/scripts client/test`
|
||||
- Install the repo’s local **pre-push** hook from the repo root to enforce those checks automatically:
|
||||
`./scripts/install-git-hooks.sh`
|
||||
|
||||
## Comments
|
||||
|
||||
- Use `#` comments; document non-obvious **why**, not what the next line literally does.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
description: Planning and implementation decisions must be written back into docs (plans, README, decomposition) so Jira chat and files stay aligned.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Planning and implementation documentation
|
||||
|
||||
Whenever you **decide** something during **story planning** or **implementation** (architecture pick, scope cut, “option A vs B”, test strategy, resolved risk), **reflect it in documentation** in the same pass or before the story is considered done—do not leave decisions only in chat.
|
||||
|
||||
## Where to write
|
||||
|
||||
| Situation | Update |
|
||||
|-----------|--------|
|
||||
| Ticketed story work | **`docs/plans/{JIRA_KEY}-implementation-plan.md`** — add or edit a **Decisions** subsection, **resolve** former open questions, refresh **Technical approach** / **Acceptance criteria** checkboxes, and **Files to add/modify** if reality diverged from kickoff. |
|
||||
| Cross-cutting or module contract | Relevant **`docs/decomposition/modules/*.md`**, **`docs/game-design/`**, or **`README`** / **`server/README.md`** / **`client/README.md`** when the decision affects how others integrate. |
|
||||
| Review findings | **`docs/reviews/…`** when using the code-review agent; link back to the plan if the review changed direction. When feedback is fixed, **strike through + `Done.`** on the original bullets in that review file — see **Code review follow-up** below. |
|
||||
|
||||
## Code review follow-up (resolved suggestions)
|
||||
|
||||
When **blocking issues**, **suggestions**, or agreed **nits** from a saved **`docs/reviews/YYYY-MM-DD-*.md`** are **fixed** in code or other docs, **edit that same review file** in the **same** change-set or the **next** commit—do **not** only fix the code and leave the review implying feedback is still open.
|
||||
|
||||
- **In place under the original headings** (`## Suggestions`, `## Nits`, `## Blocking issues`): strike through the resolved bullet with `~~…~~`, then add **`Done.`** / **`Addressed.`** / **`Deferred…`** and a one-line note (what changed, paths, optional commit SHA). Optionally add a one-line **Follow-up** note under the title (after **Scope**/**Base**) when all suggestions are done, e.g. “suggestions below are **done** (strikethrough + **Done.**)”. Same pattern as `docs/reviews/2026-03-30-NEON-5.md`, `2026-04-05-NEON-16.md`.
|
||||
- Do **not** add a **separate** “Resolved suggestions” / “Follow-up implemented” section for items that already live under Suggestions or Nits—the strikethrough row **is** the audit trail.
|
||||
- Items **declined** or **deferred** to a ticket: still strike through or annotate inline under the same bullet so the review stays honest.
|
||||
- Applies to **any** agent pass that implements review feedback—see [code-review-agent](code-review-agent.md) **Resolved suggestions**.
|
||||
|
||||
## What to capture
|
||||
|
||||
- **What** was chosen (or rejected) and **why** in one short paragraph or bullets.
|
||||
- **Outcomes** of verification (e.g. “`dotnet test` N passed; double-dispose safe on Npgsql 10”).
|
||||
- If the user **explicitly** chose an option (e.g. “Option 2”), record that label so future readers do not re-litigate.
|
||||
|
||||
## Agent behavior
|
||||
|
||||
- After **kickoff**, the plan is living: **amend it** when implementation choices differ from the draft.
|
||||
- Before **closing** or **handing off** a story, skim the plan: open questions should be **resolved** or restated as follow-up tickets with pointers.
|
||||
- After **implementing** review feedback, **edit the corresponding `docs/reviews/…` file** with **strikethrough + Done** on the original bullets per **Code review follow-up** above—this step is easy to skip; treat it as **required** whenever suggestions were listed in that review.
|
||||
|
||||
This complements [story-kickoff](story-kickoff.md) (plan creation) and [code-review-agent](code-review-agent.md) (documentation checked vs plans and decomposition).
|
||||
|
|
@ -27,9 +27,11 @@ If the user asks to **push** changes that sit on **`main`** and **`git push orig
|
|||
- Do **not** delete **`main`** or **`origin/*`**.
|
||||
- Do **not** assume the remote story branch should be deleted unless the user asks (many teams keep `origin/NEON-…` for history or delete via GitHub UI).
|
||||
|
||||
## 4. Jira (optional)
|
||||
## 4. Jira transition
|
||||
|
||||
- If the issue should move to **Done** (or equivalent), use Atlassian MCP when available; otherwise the user updates the board. This rule does **not** require Jira transitions.
|
||||
- Before finishing the story-end workflow, ask the user where the Jira story should move next: **`In Test`** or **`Done`**.
|
||||
- After the user chooses one of those two states, perform the Jira transition with Atlassian MCP when available.
|
||||
- If Atlassian MCP is unavailable or cannot complete the transition, tell the user and ask them to update the board manually.
|
||||
|
||||
## Related
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,11 @@ When the user starts work on a **Jira story** (e.g. issue key `NEON-2`, phrases
|
|||
|
||||
These three lists (**files to add**, **files to modify**, **tests**) must **always** be generated during kickoff—they are not optional prose and must not be left implicit inside “Technical approach” only.
|
||||
|
||||
- The plan is **living documentation**: any **decisions** during planning chat or implementation (options A/B, resolved risks, scope changes) must be **reflected in** `docs/plans/{KEY}-implementation-plan.md` (and other `docs/` when the decision affects integration). See [planning-implementation-docs](planning-implementation-docs.md).
|
||||
|
||||
## 5. After the plan
|
||||
|
||||
- Implement only after the user confirms. All implementation commits stay on the **same story branch** from step 1b; follow [git workflow](git-workflow.md).
|
||||
- Before handoff or merge, **reconcile the plan** with what shipped (acceptance checkboxes, **Decisions**, open questions) per [planning-implementation-docs](planning-implementation-docs.md).
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -31,3 +31,6 @@ client/addons/gdUnit4/GdUnitRunner.cfg
|
|||
# Content validation (optional local venv)
|
||||
.venv-content/
|
||||
/.venv-gd
|
||||
|
||||
# VS Code / Cursor — machine-local settings (e.g. Godot editor path)
|
||||
.vscode/settings.json
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
|
|||
|
||||
| Agent | Rule file | When to use |
|
||||
|--------|-----------|-------------|
|
||||
| **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/`; 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`; use when working in **documents** or @ mention |
|
||||
| **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`). **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). **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). **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). **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). **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). **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
|
||||
|
||||
**Commits tied to a Jira issue** must start the subject with the **issue key** and a colon (e.g. `NEON-5: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/jira-git-naming.md`](.cursor/rules/jira-git-naming.md).
|
||||
|
|
|
|||
|
|
@ -106,6 +106,16 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
|
|||
|
||||
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
|
||||
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`position_authority_client.gd`**, and **`ground_pick.gd`** (walkable collider check). **`main.gd`**, full **`_input` / ray pick** flows, and scene wiring are **not** automated here—use manual checks above.
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`position_authority_client.gd`**, **`ground_pick.gd`** (walkable collider check), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`**, full **`_input` / ray pick** flows, and scene wiring are **not** automated here—use manual checks above.
|
||||
|
||||
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
|
||||
|
||||
**Git hook (recommended):** install the repo’s local **pre-push** GDScript lint hook from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/install-git-hooks.sh
|
||||
```
|
||||
|
||||
It runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** before each push, preferring the repo-local **`.venv-gd/`** tools when present.
|
||||
|
||||
**Reports:** GdUnit writes under **`reports/`** (gitignored); ignore locally generated HTML/XML when committing.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,20 @@ interact={
|
|||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
camera_zoom_in={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"button_index":4,"factor":1.0,"pressed":false,"canceled":false,"double_click":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":61,"physical_keycode":61,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194437,"physical_keycode":4194437,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
camera_zoom_out={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"button_index":5,"factor":1.0,"pressed":false,"canceled":false,"double_click":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":45,"physical_keycode":45,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194435,"physical_keycode":4194435,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[physics]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
[gd_resource type="Resource" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/occlusion_policy.gd" id="1_occ_pol"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_occ_pol")
|
||||
enabled = true
|
||||
fade_alpha = 0.25
|
||||
occluder_group = "occluder"
|
||||
occluder_collision_mask = 1
|
||||
max_occluder_cast_depth = 4
|
||||
occluder_count_log_threshold = 0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[gd_resource type="Resource" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/zoom_band_config.gd" id="1_zoom_cfg"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_zoom_cfg")
|
||||
band_distances = PackedFloat32Array(14, 17, 19.5, 22.5, 25.709, 29, 34, 41, 50, 62, 78)
|
||||
default_band_index = 4
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
[ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"]
|
||||
[ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"]
|
||||
[ext_resource type="Script" uid="uid://b8x7c4m3r5t8t2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
|
||||
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
|
||||
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||
vertices = PackedVector3Array(-0.6999998, 0.25, -0.6999998, -0.10000038, 0.25, -0.6999998, -0.10000038, 0.25, -9.55, -9.55, 0.25, 0.05000019, -1, 0.25, 0.05000019, -9.55, 0.25, -9.55, 8.75, 0.25, -7.4500003, 8.75, 0.25, -6.4000006, 9.65, 0.25, -6.4000006, 9.65, 0.25, -9.55, 0.5, 0.25, -0.6999998, 6.6499996, 0.25, -5.2000003, 6.200001, 0.25, -5.5, 8.450001, 0.25, -7.75, 6.5, 0.25, -7.75, 6.200001, 0.25, -7.4500003, 7.1000004, 2.8000002, -6.8500004, 7.1000004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.8500004, 7.25, 0.25, -6.7000003, 7.25, 0.25, -6.25, 7.700001, 0.25, -6.25, 7.700001, 0.25, -6.7000003, 8.75, 0.25, -5.5, 7.550001, 0.25, 3.8000002, 7.550001, 0.25, 5, 9.65, 0.25, 5, 7.25, 0.25, 3.5, 0.9499998, 0.25, 0.5, 0.5, 0.25, 0.8000002, 4.55, 0.25, 3.9499998, 4.8500004, 0.25, 3.5, 8.450001, 0.25, -5.2000003, 0.9499998, 0.25, -0.39999962, -0.6999998, 0.25, 0.8000002, 4.55, 0.25, 6.200001, 4.55, 0.25, 9.65, -9.55, 0.25, 9.65, 5.45, 2.2, 4.4000006, 5.45, 2.2, 5.6000004, 6.6499996, 2.2, 5.6000004, 6.6499996, 2.2, 4.4000006, 5.6000004, 0.25, 4.55, 5.6000004, 0.25, 5.45, 6.5, 0.25, 5.45, 6.5, 0.25, 4.55, 7.550001, 0.25, 6.200001, 7.25, 0.25, 6.5, 9.800001, 0.40000004, 9.800001, 4.8500004, 0.25, 6.5)
|
||||
|
|
@ -89,6 +91,8 @@ shadow_enabled = true
|
|||
[node name="IsometricFollowCamera" type="Node3D" parent="World" unique_id=1124088856]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 10, 12)
|
||||
script = ExtResource("7_iso_cam")
|
||||
zoom_band_config = ExtResource("8_zoom_bands")
|
||||
occlusion_policy = ExtResource("9_occ_pol")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="World/IsometricFollowCamera" unique_id=1124088857]
|
||||
current = true
|
||||
|
|
@ -139,7 +143,7 @@ surface_material_override/0 = SubResource("Mat_move_reject_farpad")
|
|||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/MoveRejectFarPad" unique_id=1900033]
|
||||
shape = SubResource("BoxShape3D_move_reject_farpad")
|
||||
|
||||
[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763]
|
||||
[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763 groups=["occluder"]]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628]
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ var yaw: float = 0.0
|
|||
## Scene path to the follow anchor (typically `CharacterBody3D` / `Node3D`).
|
||||
var follow_target_path: NodePath = NodePath()
|
||||
|
||||
## Single-band follow distance until NEON-26 zoom bands (meters).
|
||||
## Effective follow distance used for framing this tick (meters) — active zoom band or fallback.
|
||||
var distance: float = 0.0
|
||||
|
||||
## Placeholder for discrete zoom steps (NEON-26).
|
||||
## Active discrete zoom band index when the rig uses a zoom-band resource; otherwise **0**.
|
||||
var zoom_band_index: int = 0
|
||||
|
||||
## World-space point the rig used as look-at focus last tick.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
extends Node3D
|
||||
|
||||
## NEON-25: client-local isometric follow; updates [CameraState] each `_process`.
|
||||
## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
|
||||
## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while
|
||||
## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here,
|
||||
## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg].
|
||||
|
||||
## TODO(E9.M1): map throttled product telemetry (for example `camera_zoom_changed`)
|
||||
## to this signal when schema exists.
|
||||
signal zoom_band_changed(new_index: int, distance: float)
|
||||
|
||||
const CameraStateScript := preload("res://scripts/camera_state.gd")
|
||||
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
|
||||
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
|
||||
|
||||
## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
|
||||
## When [member zoom_band_config] is null or has no bands, this is the sole follow distance.
|
||||
@export var follow_target_path: NodePath = NodePath("../Player")
|
||||
@export var follow_distance: float = 25.709
|
||||
@export var pitch_elevation_deg: float = 20.693
|
||||
@export var presentation_yaw_deg: float = 45.0
|
||||
@export var focus_vertical_offset: float = 0.0
|
||||
|
||||
## Designer-tunable discrete bands (`res://resources/isometric_zoom_bands.tres` on main rig).
|
||||
@export var zoom_band_config: Resource
|
||||
|
||||
## RayCast-based occluder fade config (`res://resources/isometric_occlusion_policy.tres`).
|
||||
@export var occlusion_policy: Resource
|
||||
|
||||
@export var allow_yaw: bool = false
|
||||
@export var max_yaw_deg: float = 45.0
|
||||
|
||||
|
|
@ -30,13 +44,17 @@ var camera_state:
|
|||
var _state = null
|
||||
var _smoothed_eye: Vector3 = Vector3.ZERO
|
||||
var _orbit_yaw_rad: float = 0.0
|
||||
var _zoom_band_index: int = 0
|
||||
var _warned_missing_follow_target: bool = false
|
||||
## Keys: Node3D occluder bodies. Values: Array of {mesh, surface, original} restore entries.
|
||||
var _occluder_overrides: Dictionary = {}
|
||||
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_state = CameraStateScript.new()
|
||||
_init_zoom_band_index()
|
||||
if camera == null:
|
||||
push_error("IsometricFollowCamera: expected child Camera3D")
|
||||
return
|
||||
|
|
@ -50,12 +68,11 @@ func _ready() -> void:
|
|||
)
|
||||
var focus0: Vector3 = t.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
|
||||
var yaw0: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
|
||||
_smoothed_eye = desired_eye_world(
|
||||
focus0, follow_distance, deg_to_rad(pitch_elevation_deg), yaw0
|
||||
)
|
||||
var dist0: float = _current_follow_distance()
|
||||
_smoothed_eye = desired_eye_world(focus0, dist0, deg_to_rad(pitch_elevation_deg), yaw0)
|
||||
global_position = _smoothed_eye
|
||||
camera.look_at(focus0, Vector3.UP)
|
||||
_sync_camera_state(focus0)
|
||||
_sync_camera_state(focus0, dist0)
|
||||
else:
|
||||
_smoothed_eye = global_position
|
||||
_warn_missing_follow_target_once()
|
||||
|
|
@ -63,10 +80,12 @@ func _ready() -> void:
|
|||
|
||||
func _process(delta: float) -> void:
|
||||
if camera == null or _state == null:
|
||||
_restore_all_occluders()
|
||||
return
|
||||
var target: Node3D = _resolve_target()
|
||||
if target == null:
|
||||
# No eye/state update until the path resolves — avoids chasing a freed or miswired node.
|
||||
_restore_all_occluders()
|
||||
_warn_missing_follow_target_once()
|
||||
return
|
||||
_warned_missing_follow_target = false
|
||||
|
|
@ -78,8 +97,9 @@ func _process(delta: float) -> void:
|
|||
|
||||
var focus: Vector3 = target.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
|
||||
var yaw_total: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
|
||||
var effective_dist: float = _current_follow_distance()
|
||||
var desired: Vector3 = desired_eye_world(
|
||||
focus, follow_distance, deg_to_rad(pitch_elevation_deg), yaw_total
|
||||
focus, effective_dist, deg_to_rad(pitch_elevation_deg), yaw_total
|
||||
)
|
||||
|
||||
if _smoothed_eye.distance_to(desired) >= snap_distance:
|
||||
|
|
@ -90,15 +110,75 @@ func _process(delta: float) -> void:
|
|||
|
||||
global_position = _smoothed_eye
|
||||
camera.look_at(focus, Vector3.UP)
|
||||
_sync_camera_state(focus)
|
||||
_sync_camera_state(focus, effective_dist)
|
||||
_update_occlusion(focus)
|
||||
|
||||
|
||||
func _sync_camera_state(focus: Vector3) -> void:
|
||||
func _exit_tree() -> void:
|
||||
_restore_all_occluders()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_echo():
|
||||
return
|
||||
if event.is_action_pressed("camera_zoom_in"):
|
||||
_apply_zoom_step(-1)
|
||||
elif event.is_action_pressed("camera_zoom_out"):
|
||||
_apply_zoom_step(1)
|
||||
|
||||
|
||||
## **Near** = lower band index. Zoom in → closer → decrease index (clamped).
|
||||
func _apply_zoom_step(delta_bands: int) -> void:
|
||||
if not _zoom_config_valid():
|
||||
return
|
||||
var cfg: Resource = zoom_band_config
|
||||
var new_idx: int = cfg.clamp_index(_zoom_band_index + delta_bands)
|
||||
if new_idx == _zoom_band_index:
|
||||
return
|
||||
_zoom_band_index = new_idx
|
||||
var d: float = _current_follow_distance()
|
||||
zoom_band_changed.emit(_zoom_band_index, d)
|
||||
|
||||
|
||||
func _init_zoom_band_index() -> void:
|
||||
_zoom_band_index = 0
|
||||
if not _zoom_config_valid():
|
||||
return
|
||||
var cfg: Resource = zoom_band_config
|
||||
_zoom_band_index = cfg.clamp_index(cfg.default_band_index)
|
||||
|
||||
|
||||
func _zoom_config_valid() -> bool:
|
||||
return (
|
||||
zoom_band_config != null
|
||||
and zoom_band_config.get_script() == ZoomBandConfigScript
|
||||
and zoom_band_config.band_count() > 0
|
||||
and zoom_band_config.all_band_distances_positive()
|
||||
)
|
||||
|
||||
|
||||
func _current_follow_distance() -> float:
|
||||
return effective_follow_distance(zoom_band_config, _zoom_band_index, follow_distance)
|
||||
|
||||
|
||||
static func effective_follow_distance(
|
||||
zoom_cfg: Resource, band_index: int, fallback_distance: float
|
||||
) -> float:
|
||||
if zoom_cfg == null or zoom_cfg.get_script() != ZoomBandConfigScript:
|
||||
return fallback_distance
|
||||
if zoom_cfg.band_count() == 0:
|
||||
return fallback_distance
|
||||
if not zoom_cfg.all_band_distances_positive():
|
||||
return fallback_distance
|
||||
return zoom_cfg.distance_at(band_index)
|
||||
|
||||
|
||||
func _sync_camera_state(focus: Vector3, effective_distance: float) -> void:
|
||||
# `CameraState.yaw` = orbit delta only; world-fixed diagonal framing =
|
||||
# `presentation_yaw_deg`.
|
||||
_state.follow_target_path = follow_target_path
|
||||
_state.distance = follow_distance
|
||||
_state.zoom_band_index = 0
|
||||
_state.distance = effective_distance
|
||||
_state.zoom_band_index = _zoom_band_index if _zoom_config_valid() else 0
|
||||
_state.focus_world = focus
|
||||
_state.yaw = _orbit_yaw_rad
|
||||
|
||||
|
|
@ -129,3 +209,114 @@ static func desired_eye_world(
|
|||
) -> Vector3:
|
||||
var h: float = distance * cos(pitch_elevation_rad)
|
||||
return focus + Vector3(h * sin(yaw_rad), distance * sin(pitch_elevation_rad), h * cos(yaw_rad))
|
||||
|
||||
|
||||
## Returns true when [member occlusion_policy] is a valid, enabled [OcclusionPolicy].
|
||||
## Mirrors [method _zoom_config_valid]; delegates to the static helper so tests can call
|
||||
## it without a live Node.
|
||||
func _occlusion_policy_valid() -> bool:
|
||||
return occlusion_policy_is_valid(occlusion_policy)
|
||||
|
||||
|
||||
## Static guard — safe to call from tests without a live scene.
|
||||
static func occlusion_policy_is_valid(policy: Resource) -> bool:
|
||||
return policy != null and policy.get_script() == OcclusionPolicyScript and policy.is_valid()
|
||||
|
||||
|
||||
## Cast iterative rays from the smoothed eye to the player focus.
|
||||
## Bodies in the [member occluder_group] that intersect the ray are faded;
|
||||
## bodies that leave the path have their materials restored.
|
||||
func _update_occlusion(focus: Vector3) -> void:
|
||||
if not _occlusion_policy_valid():
|
||||
_restore_all_occluders()
|
||||
return
|
||||
|
||||
var policy = occlusion_policy
|
||||
var space_state := get_world_3d().direct_space_state
|
||||
var newly_blocking: Array[Node3D] = []
|
||||
var excluded: Array[RID] = []
|
||||
|
||||
for _i in range(policy.max_occluder_cast_depth):
|
||||
var params := PhysicsRayQueryParameters3D.create(
|
||||
_smoothed_eye, focus, policy.occluder_collision_mask
|
||||
)
|
||||
params.exclude = excluded
|
||||
var result := space_state.intersect_ray(params)
|
||||
if result.is_empty():
|
||||
break
|
||||
var body = result.get("collider")
|
||||
if body is Node3D and (body as Node3D).is_in_group(policy.occluder_group):
|
||||
newly_blocking.append(body as Node3D)
|
||||
excluded.append(result.get("rid"))
|
||||
|
||||
if (
|
||||
policy.occluder_count_log_threshold > 0
|
||||
and newly_blocking.size() >= policy.occluder_count_log_threshold
|
||||
):
|
||||
push_warning(
|
||||
(
|
||||
"OcclusionPolicy: %d occluder(s) active (threshold %d)"
|
||||
% [newly_blocking.size(), policy.occluder_count_log_threshold]
|
||||
)
|
||||
)
|
||||
|
||||
var to_restore: Array = _occluder_overrides.keys()
|
||||
for body in to_restore:
|
||||
if body not in newly_blocking:
|
||||
_restore_occluder(body)
|
||||
|
||||
for body in newly_blocking:
|
||||
if body not in _occluder_overrides:
|
||||
_apply_occluder_fade(body)
|
||||
|
||||
|
||||
## Apply per-surface fade overrides to all [MeshInstance3D] children of [param body].
|
||||
## - [StandardMaterial3D]: duplicated and alpha-set to [member OcclusionPolicy.fade_alpha].
|
||||
## - null (no material): a plain [StandardMaterial3D] is created so the surface fades.
|
||||
## - Anything else (e.g. [ShaderMaterial]): skipped with [method push_warning].
|
||||
func _apply_occluder_fade(body: Node3D) -> void:
|
||||
var policy = occlusion_policy
|
||||
var overrides: Array = []
|
||||
for mesh_node in body.find_children("*", "MeshInstance3D", true, false):
|
||||
var mi: MeshInstance3D = mesh_node
|
||||
var n: int = mi.get_surface_override_material_count()
|
||||
for s in range(n):
|
||||
var saved: Material = mi.get_surface_override_material(s)
|
||||
var effective: Material = saved
|
||||
if effective == null and mi.mesh != null:
|
||||
effective = mi.mesh.surface_get_material(s)
|
||||
var faded: StandardMaterial3D
|
||||
if effective is StandardMaterial3D:
|
||||
faded = (effective as StandardMaterial3D).duplicate()
|
||||
elif effective == null:
|
||||
faded = StandardMaterial3D.new()
|
||||
else:
|
||||
push_warning(
|
||||
(
|
||||
"OcclusionPolicy: %s surface %d is %s — skipping fade."
|
||||
% [mi.get_path(), s, effective.get_class()]
|
||||
)
|
||||
)
|
||||
continue
|
||||
faded.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
faded.albedo_color.a = policy.fade_alpha
|
||||
mi.set_surface_override_material(s, faded)
|
||||
overrides.append({"mesh": mi, "surface": s, "original": saved})
|
||||
_occluder_overrides[body] = overrides
|
||||
|
||||
|
||||
## Restore saved surface override materials on [param body] and remove it from tracking.
|
||||
func _restore_occluder(body: Node3D) -> void:
|
||||
if not _occluder_overrides.has(body):
|
||||
return
|
||||
for entry in _occluder_overrides[body]:
|
||||
var mi: MeshInstance3D = entry["mesh"]
|
||||
if is_instance_valid(mi):
|
||||
mi.set_surface_override_material(entry["surface"], entry["original"])
|
||||
_occluder_overrides.erase(body)
|
||||
|
||||
|
||||
## Restore all currently faded occluders (called on policy disable or node exit).
|
||||
func _restore_all_occluders() -> void:
|
||||
for body in _occluder_overrides.keys():
|
||||
_restore_occluder(body)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
extends Resource
|
||||
|
||||
## OcclusionPolicy — config for the RayCast-based per-surface material fade used by
|
||||
## [IsometricFollowCamera] (NEON-27). No `class_name` — see repo Godot headless / CI notes.
|
||||
|
||||
## Master switch. When false, no rays are cast and any active overrides are restored.
|
||||
@export var enabled: bool = true
|
||||
|
||||
## Target alpha applied to occluder surfaces (0 = fully transparent, 1 = opaque).
|
||||
@export_range(0.0, 1.0) var fade_alpha: float = 0.25
|
||||
|
||||
## Scene group that opts a [CollisionObject3D] into occlusion fading.
|
||||
@export var occluder_group: String = "occluder"
|
||||
|
||||
## Physics collision mask for the ray. Default 1 (world geometry layer).
|
||||
## The Player is on layer 2 and is already excluded with this default.
|
||||
@export_flags_3d_physics var occluder_collision_mask: int = 1
|
||||
|
||||
## Max successive [method PhysicsDirectSpaceState3D.intersect_ray] calls per frame.
|
||||
## Caps cost when occluders are stacked along the ray.
|
||||
@export var max_occluder_cast_depth: int = 4
|
||||
|
||||
## When > 0, emits [method push_warning] when the active occluder count reaches this
|
||||
## value. Set to 0 to disable. Marker for stress-test reference (TODO E9.M1 telemetry).
|
||||
@export var occluder_count_log_threshold: int = 0
|
||||
|
||||
|
||||
## Returns true when the policy should be applied this frame.
|
||||
func is_valid() -> bool:
|
||||
return enabled and fade_alpha >= 0.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dtq0253dpm3kg
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
extends Resource
|
||||
|
||||
## Discrete follow distances (meters), **near → far** (ascending). Index **0** = closest.
|
||||
## No `class_name` — see repo Godot headless / CI notes.
|
||||
@export var band_distances: PackedFloat32Array = PackedFloat32Array([25.709])
|
||||
@export var default_band_index: int = 0
|
||||
|
||||
|
||||
func band_count() -> int:
|
||||
return band_distances.size()
|
||||
|
||||
|
||||
## **True** when every entry is **> 0**.
|
||||
## Empty arrays are vacuously true; callers also check [method band_count].
|
||||
func all_band_distances_positive() -> bool:
|
||||
for i in range(band_distances.size()):
|
||||
if band_distances[i] <= 0.0:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func clamp_index(i: int) -> int:
|
||||
var n := band_count()
|
||||
if n == 0:
|
||||
return 0
|
||||
return clampi(i, 0, n - 1)
|
||||
|
||||
|
||||
func distance_at(index: int) -> float:
|
||||
var n := band_count()
|
||||
if n == 0:
|
||||
return 0.0
|
||||
var ci := clamp_index(index)
|
||||
return band_distances[ci]
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b6lpmuv4rj0vp
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
# Static eye-position helper for res://scripts/isometric_follow_camera.gd (NEON-25 framing).
|
||||
# Static helpers for res://scripts/isometric_follow_camera.gd (NEON-25 framing, NEON-27 guard).
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const IsoScript := preload("res://scripts/isometric_follow_camera.gd")
|
||||
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
|
||||
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
|
||||
|
||||
|
||||
func test_desired_eye_matches_previous_static_camera_frame() -> void:
|
||||
|
|
@ -11,3 +13,52 @@ func test_desired_eye_matches_previous_static_camera_frame() -> void:
|
|||
)
|
||||
var want := Vector3(12.0, 10.0, 12.0)
|
||||
assert_that(eye.distance_to(want)).is_less(0.06)
|
||||
|
||||
|
||||
func test_effective_follow_distance_fallback_when_config_null() -> void:
|
||||
assert_that(IsoScript.effective_follow_distance(null, 3, 12.5)).is_equal(12.5)
|
||||
|
||||
|
||||
func test_effective_follow_distance_uses_config_when_valid() -> void:
|
||||
var cfg = ZoomBandConfigScript.new()
|
||||
cfg.band_distances = PackedFloat32Array([10.0, 20.0, 30.0])
|
||||
assert_that(IsoScript.effective_follow_distance(cfg, 1, 99.0)).is_equal(20.0)
|
||||
|
||||
|
||||
func test_effective_follow_distance_clamps_band_index() -> void:
|
||||
var cfg = ZoomBandConfigScript.new()
|
||||
cfg.band_distances = PackedFloat32Array([10.0, 20.0])
|
||||
assert_that(IsoScript.effective_follow_distance(cfg, 99, 1.0)).is_equal(20.0)
|
||||
assert_that(IsoScript.effective_follow_distance(cfg, -3, 1.0)).is_equal(10.0)
|
||||
|
||||
|
||||
func test_effective_follow_distance_empty_config_bands_falls_back() -> void:
|
||||
var cfg = ZoomBandConfigScript.new()
|
||||
cfg.band_distances = PackedFloat32Array()
|
||||
assert_that(IsoScript.effective_follow_distance(cfg, 0, 7.5)).is_equal(7.5)
|
||||
|
||||
|
||||
func test_effective_follow_distance_fallback_when_band_non_positive() -> void:
|
||||
var cfg = ZoomBandConfigScript.new()
|
||||
cfg.band_distances = PackedFloat32Array([10.0, 0.0, 30.0])
|
||||
assert_that(IsoScript.effective_follow_distance(cfg, 2, 99.0)).is_equal(99.0)
|
||||
|
||||
|
||||
func test_occlusion_policy_is_valid_null() -> void:
|
||||
assert_that(IsoScript.occlusion_policy_is_valid(null)).is_false()
|
||||
|
||||
|
||||
func test_occlusion_policy_is_valid_wrong_script() -> void:
|
||||
var wrong = ZoomBandConfigScript.new()
|
||||
assert_that(IsoScript.occlusion_policy_is_valid(wrong)).is_false()
|
||||
|
||||
|
||||
func test_occlusion_policy_is_valid_correct_script() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
assert_that(IsoScript.occlusion_policy_is_valid(p)).is_true()
|
||||
|
||||
|
||||
func test_occlusion_policy_is_valid_false_when_disabled() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
p.enabled = false
|
||||
assert_that(IsoScript.occlusion_policy_is_valid(p)).is_false()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
# Tests res://scripts/occlusion_policy.gd (NEON-27 occlusion config).
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
|
||||
|
||||
|
||||
func test_defaults_are_valid() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
assert_that(p.is_valid()).is_true()
|
||||
|
||||
|
||||
func test_defaults_match_spec() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
assert_that(p.enabled).is_true()
|
||||
assert_that(p.fade_alpha).is_equal(0.25)
|
||||
assert_that(p.occluder_group).is_equal("occluder")
|
||||
assert_that(p.occluder_collision_mask).is_equal(1)
|
||||
assert_that(p.max_occluder_cast_depth).is_equal(4)
|
||||
assert_that(p.occluder_count_log_threshold).is_equal(0)
|
||||
|
||||
|
||||
func test_is_valid_false_when_disabled() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
p.enabled = false
|
||||
assert_that(p.is_valid()).is_false()
|
||||
|
||||
|
||||
func test_is_valid_true_when_fade_alpha_zero() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
p.fade_alpha = 0.0
|
||||
assert_that(p.is_valid()).is_true()
|
||||
|
||||
|
||||
func test_is_valid_false_when_fade_alpha_negative() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
p.fade_alpha = -0.1
|
||||
assert_that(p.is_valid()).is_false()
|
||||
|
||||
|
||||
func test_is_valid_true_at_full_opaque() -> void:
|
||||
var p = OcclusionPolicyScript.new()
|
||||
p.fade_alpha = 1.0
|
||||
assert_that(p.is_valid()).is_true()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://1ypnjycsj3y5
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Tests res://scripts/zoom_band_config.gd (NEON-26 discrete bands).
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
|
||||
|
||||
|
||||
func test_empty_bands_band_count_zero() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array()
|
||||
assert_that(c.band_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_empty_bands_clamp_index_zero() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array()
|
||||
assert_that(c.clamp_index(0)).is_equal(0)
|
||||
assert_that(c.clamp_index(99)).is_equal(0)
|
||||
|
||||
|
||||
func test_empty_bands_distance_at_zero() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array()
|
||||
assert_that(c.distance_at(0)).is_equal(0.0)
|
||||
|
||||
|
||||
func test_single_band_clamp_always_zero() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array([42.0])
|
||||
assert_that(c.clamp_index(-1)).is_equal(0)
|
||||
assert_that(c.clamp_index(0)).is_equal(0)
|
||||
assert_that(c.clamp_index(9)).is_equal(0)
|
||||
|
||||
|
||||
func test_multi_band_clamp_and_distance_at() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array([10.0, 20.0, 30.0])
|
||||
assert_that(c.clamp_index(-5)).is_equal(0)
|
||||
assert_that(c.clamp_index(1)).is_equal(1)
|
||||
assert_that(c.clamp_index(99)).is_equal(2)
|
||||
assert_that(c.distance_at(1)).is_equal(20.0)
|
||||
assert_that(c.distance_at(100)).is_equal(30.0)
|
||||
|
||||
|
||||
func test_default_index_clamped_via_clamp_index() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array([1.0, 2.0, 3.0])
|
||||
c.default_band_index = 99
|
||||
assert_that(c.clamp_index(c.default_band_index)).is_equal(2)
|
||||
|
||||
|
||||
func test_all_band_distances_positive_vacuous_when_empty() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array()
|
||||
assert_that(c.all_band_distances_positive()).is_true()
|
||||
|
||||
|
||||
func test_all_band_distances_positive_true_when_all_positive() -> void:
|
||||
var c = ZoomBandConfigScript.new()
|
||||
c.band_distances = PackedFloat32Array([1.0, 2.5, 10.0])
|
||||
assert_that(c.all_band_distances_positive()).is_true()
|
||||
|
||||
|
||||
func test_all_band_distances_positive_false_when_zero_or_negative() -> void:
|
||||
var c0 = ZoomBandConfigScript.new()
|
||||
c0.band_distances = PackedFloat32Array([10.0, 0.0, 20.0])
|
||||
assert_that(c0.all_band_distances_positive()).is_false()
|
||||
var cn = ZoomBandConfigScript.new()
|
||||
cn.band_distances = PackedFloat32Array([5.0, -1.0])
|
||||
assert_that(cn.all_band_distances_positive()).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cr1ot54crlhwn
|
||||
|
|
@ -33,7 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende
|
|||
|
||||
## Implementation snapshot
|
||||
|
||||
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NEON-7** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NEON-7](../../plans/NEON-7-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-neon-4-neon-7)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NEON-5](../../plans/NEON-5-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NEON-3](../../plans/NEON-3-implementation-plan.md), [NEON-4](../../plans/NEON-4-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NEON-8** — client **`NavigationRegion3D` / `NavigationAgent3D`** for click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NEON-8](../../plans/NEON-8-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-neon-4-neon-8)). **`InteractionRequest`** + server-side horizontal range check ([NEON-6](../../plans/NEON-6-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-neon-6)). See [server README — Position persistence](../../../server/README.md#position-persistence-neon-5). Jira **[NEON-9](https://neon-sprawl.atlassian.net/browse/NEON-9)** (E1.M1 Feature) is **Done** for this prototype scope.
|
||||
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NEON-7** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NEON-7](../../plans/NEON-7-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-neon-4-neon-7)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NEON-5](../../plans/NEON-5-implementation-plan.md)); shared **NpgsqlDataSource** disposed on application shutdown ([NEON-15](../../plans/NEON-15-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NEON-3](../../plans/NEON-3-implementation-plan.md), [NEON-4](../../plans/NEON-4-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NEON-8** — client **`NavigationRegion3D` / `NavigationAgent3D`** for click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NEON-8](../../plans/NEON-8-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-neon-4-neon-8)). **`InteractionRequest`** + server-side horizontal range check ([NEON-6](../../plans/NEON-6-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-neon-6)). See [server README — Position persistence](../../../server/README.md#position-persistence-neon-5). Jira **[NEON-9](https://neon-sprawl.atlassian.net/browse/NEON-9)** (E1.M1 Feature) is **Done** for this prototype scope.
|
||||
- **Follow-on:** Client prediction/reconciliation; Epic 1 Slice 1 telemetry and movement-loop polish; optional **Protobuf** wire promotion for `MoveCommand` / `PositionState` per [contracts.md](contracts.md).
|
||||
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E1.M2 |
|
||||
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | In progress — follow + `CameraState` baseline shipped ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)); zoom, occlusion, hardening open ([NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)–[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)); see [dependency register](module_dependency_register.md) |
|
||||
| **Status** | In progress — follow + `CameraState` + **discrete zoom bands** + **occlusion policy** shipped ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)–[NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)); hardening open ([NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)); see [dependency register](module_dependency_register.md) |
|
||||
| **Jira** | Feature [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10); prototype backlog stories under parent [NEON-1](https://neon-sprawl.atlassian.net/browse/NEON-1) — [Jira backlog](#jira-backlog) |
|
||||
|
||||
## Purpose
|
||||
|
|
@ -55,11 +55,14 @@ Delivers an isometric follow camera that keeps the player readable during motion
|
|||
|
||||
See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, occlusion policy, **yaw fixed for players in prototype** (implementation still models yaw per [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)); optional telemetry such as throttled `camera_zoom_changed` and perf stress markers for occluders.
|
||||
|
||||
## Implementation snapshot (NEON-25, 2026-04-07)
|
||||
## Implementation snapshot (NEON-25 + NEON-26 + NEON-27, 2026-04-08)
|
||||
|
||||
- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`**, **`zoom_band_index`**, **`focus_world`**, **`follow_target_path`**).
|
||||
- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`** = effective follow distance; **`zoom_band_index`**; **`focus_world`**, **`follow_target_path`**).
|
||||
- **Seam:** **`allow_yaw`** / **`max_yaw_deg`** on the rig; orbit would adjust **`_orbit_yaw_rad`** (no input bound yet). **Presentation** compass uses **`presentation_yaw_deg`** separately from orbit **`yaw`** in state.
|
||||
- **Zoom:** single scalar **`follow_distance`** until [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26); **`zoom_band_index`** reserved on **`CameraState`**.
|
||||
- **Zoom:** **`ZoomBandConfig`** resource (`client/scripts/zoom_band_config.gd`); default **`client/resources/isometric_zoom_bands.tres`** on main rig. Bands are **distance-only** (ascending near→far); **pitch / FOV** unchanged vs NEON-25. Input: **`camera_zoom_in`** / **`camera_zoom_out`** in `client/project.godot` (mouse wheel + `=` / `-` + keypad **+** / **−**). **`follow_distance`** remains **fallback** when config is null or has no bands; state **`zoom_band_index`** is **0** in that case. **`zoom_band_changed`** signal on rig; **TODO(E9.M1)** for throttled `camera_zoom_changed` telemetry.
|
||||
- **Occlusion:** **`OcclusionPolicy`** resource (`client/scripts/occlusion_policy.gd`); default **`client/resources/isometric_occlusion_policy.tres`** on main rig. **Technique:** each `_process` frame, iterative `intersect_ray` from `_smoothed_eye` to player focus (up to `max_occluder_cast_depth` calls). Bodies tagged `"occluder"` that intersect the ray have their `MeshInstance3D` surfaces overridden with a transparent `StandardMaterial3D` copy (`fade_alpha = 0.25`). Materials restored instantly when the body clears the ray. **Null-material surfaces** (no mesh or override material set) receive a plain transparent `StandardMaterial3D`; other non-`StandardMaterial3D` surfaces are skipped with `push_warning`. **Prototype district:** `Obstacle` node in `main.tscn` tagged `"occluder"`. **Perf marker:** `occluder_count_log_threshold` export (default 0 = disabled) emits `push_warning` for stress-test reference. See [risks and telemetry](#risks-and-telemetry) for readability gate.
|
||||
|
||||
> **Prototype demo readability gate:** before shipping the prototype demo, the PR for NEON-27 must include a before/after screenshot or clip demonstrating that the player is no longer fully hidden by the `Obstacle`. This serves as the first data point for the occlusion-hiding-telegraphs risk documented below.
|
||||
|
||||
## Jira backlog
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
|||
|
||||
| Module | Register status | Snapshot | Plans / pointers |
|
||||
|--------|-----------------|----------|-------------------|
|
||||
| E1.M1 | Ready | Prototype milestone **Done** ([NEON-9](https://neon-sprawl.atlassian.net/browse/NEON-9)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEON-5](../../plans/NEON-5-implementation-plan.md)); Godot sync + path-follow ([NEON-4](../../plans/NEON-4-implementation-plan.md), [NEON-8](../../plans/NEON-8-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEON-6](../../plans/NEON-6-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEON-3](../../plans/NEON-3-implementation-plan.md), [NEON-4](../../plans/NEON-4-implementation-plan.md), [NEON-5](../../plans/NEON-5-implementation-plan.md), [NEON-6](../../plans/NEON-6-implementation-plan.md), [NEON-7](../../plans/NEON-7-implementation-plan.md), [NEON-8](../../plans/NEON-8-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
|
||||
| E1.M1 | Ready | Prototype milestone **Done** ([NEON-9](https://neon-sprawl.atlassian.net/browse/NEON-9)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEON-5](../../plans/NEON-5-implementation-plan.md)); shared **NpgsqlDataSource** disposed on host shutdown ([NEON-15](../../plans/NEON-15-implementation-plan.md)); Godot sync + path-follow ([NEON-4](../../plans/NEON-4-implementation-plan.md), [NEON-8](../../plans/NEON-8-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEON-6](../../plans/NEON-6-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEON-3](../../plans/NEON-3-implementation-plan.md), [NEON-4](../../plans/NEON-4-implementation-plan.md), [NEON-5](../../plans/NEON-5-implementation-plan.md), [NEON-6](../../plans/NEON-6-implementation-plan.md), [NEON-7](../../plans/NEON-7-implementation-plan.md), [NEON-8](../../plans/NEON-8-implementation-plan.md), [NEON-15](../../plans/NEON-15-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
|
||||
| E1.M2 | In progress | **Slice 2 prototype stories shipped:** fixed-yaw isometric follow + `CameraState` seam ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)); discrete zoom bands via `ZoomBandConfig` resource ([NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)); `OcclusionPolicy` RayCast-based material fade with `"occluder"` group tagging ([NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)). Open: integration hardening + dependent contract notes ([NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)). Client-local; no server use of camera pose. | [NEON-25](../../plans/NEON-25-implementation-plan.md), [NEON-26](../../plans/NEON-26-implementation-plan.md), [NEON-27](../../plans/NEON-27-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | Planned |
|
||||
| E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | Planned |
|
||||
|
||||
**E1.M2 note:** Client follow rig + per-tick `CameraState` baseline is in repo ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)). Discrete zoom bands, occlusion policy, and integration hardening remain ([NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)–[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)).
|
||||
**E1.M2 note:** Client follow rig + per-tick `CameraState` + **discrete zoom bands** (`ZoomBandConfig`, [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)) + **`OcclusionPolicy`** RayCast fade ([NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)) are in repo ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)–[NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)). Integration hardening remains ([NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)).
|
||||
|
||||
### Epic 2 — Skills and Progression Framework
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
# NEON-15 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEON-15 |
|
||||
| **Title** | Dispose NpgsqlDataSource on application shutdown |
|
||||
| **Jira** | [NEON-15](https://neon-sprawl.atlassian.net/browse/NEON-15) |
|
||||
| **Parent** | [NEON-13 — Tech Debt](https://neon-sprawl.atlassian.net/browse/NEON-13) |
|
||||
| **Related** | NS-17 code review — [docs/reviews/2026-03-30-NS-17.md](../reviews/2026-03-30-NS-17.md) (suggestion #2) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Ensure the shared `NpgsqlDataSource` registered when `ConnectionStrings:NeonSprawl` is set is **released cleanly on host shutdown**, without breaking **in-memory** overrides or **Postgres** integration tests / `WebApplicationFactory`.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Explicit, observable shutdown cleanup for the PostgreSQL path (align with Npgsql 10 + ASP.NET Core hosting patterns; no new analyzer warnings).
|
||||
- Keep **single** shared data source for `PostgresPositionStateStore` and `PostgresDevPlayerSeedHostedService`.
|
||||
- Update test host overrides that strip Postgres registrations so they remain consistent.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Connection pooling tuning, migration tooling, or schema changes.
|
||||
- Client / Godot code.
|
||||
|
||||
## Decisions (planning + implementation)
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| **Lifecycle / double dispose** | **Option 2** — internal shutdown `IHostedService` calls `DisposeAsync` on the shared `NpgsqlDataSource`; no non-disposable holder. Rationale: smallest change; **Npgsql 10** + full `dotnet test` showed clean teardown (no failures from a second dispose path). |
|
||||
| **ConfigureAwait** | Not used in `StopAsync` — matches existing server hosted-service style (`PostgresDevPlayerSeedHostedService`). |
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Graceful shutdown disposes the shared `NpgsqlDataSource` (or a documented alternative if upstream guidance prefers another pattern).
|
||||
- [x] `dotnet test` still passes (in-memory and Postgres integration paths).
|
||||
- [x] No new analyzer warnings; align with Npgsql / ASP.NET Core guidance for the installed package version.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Shutdown ordering:** `IHostedService.StopAsync` runs in **reverse registration order**. Register **`NpgsqlDataSourceShutdownHostedService` before** `PostgresDevPlayerSeedHostedService` so the dispose hook runs **last** among hosted services (after the seed service’s `StopAsync`), while ordinary request handling has already wound down per host shutdown semantics.
|
||||
2. **Disposal:** In that hosted service’s `StopAsync`, `cancellationToken.ThrowIfCancellationRequested()` then `await dataSource.DisposeAsync()` (Npgsql has no `DisposeAsync(CancellationToken)`). `StartAsync` is a no-op.
|
||||
3. **Double disposal:** The root `ServiceProvider` may still dispose the same singleton during host teardown. **Settled:** Option 2 kept; **verification** — `dotnet test` on solution (**36** tests passed) including in-memory and Postgres factory dispose paths; no change required to store/seed constructors.
|
||||
4. **Registration clarity:** `AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(trimmed))` so the service type is explicit to DI and analyzers.
|
||||
5. **Shutdown verification:** `WebApplicationFactory` disposal in integration tests exercises host stop with a live `NpgsqlDataSource` when `ConnectionStrings__NeonSprawl` is set. **Optional:** Run the server with a real connection string, Ctrl+C, confirm clean pool-related logs for extra operator confidence.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/PositionState/NpgsqlDataSourceShutdownHostedService.cs` | Internal `IHostedService` that disposes the shared `NpgsqlDataSource` on shutdown (registered before the seed hosted service so it stops last). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs` | Register `NpgsqlDataSource` with an explicit service type if adjusted; register the shutdown hosted service **before** `PostgresDevPlayerSeedHostedService`; keep the existing branch for in-memory vs Postgres. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Extend the descriptor removal loop to drop the new shutdown hosted service when forcing the in-memory store (mirror the `PostgresDevPlayerSeedHostedService` pattern). |
|
||||
| `server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs` | **N/A (shipped)** — holder pattern not needed after Option 2 verification. |
|
||||
| `server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs` | **N/A (shipped)** — same. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Path / action | What to cover |
|
||||
|---------------|----------------|
|
||||
| `dotnet test` (full server test project) | Regression: **InMemoryWebApplicationFactory** suites unchanged; **PostgresWebApplicationFactory** / `PostgresIntegrationHarness` / `SecondProcess_ShouldReadLastPosition_AfterFirstFactoryDisposed` still pass (factory dispose + multi-factory scenario). |
|
||||
| **No new test file required** for kickoff | If shutdown-only logic is covered indirectly by existing factories and `await using` tests; if implementation adds a holder or non-obvious lifecycle, add a focused test in `NeonSprawl.Server.Tests` that builds a Postgres factory, disposes it, and asserts clean teardown (optional follow-up). |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
**Resolved:** Double-dispose risk accepted under **Option 2**; **Npgsql 10** + full test run showed no shutdown regressions. **None** outstanding for this story.
|
||||
|
||||
## PR / review
|
||||
|
||||
**Merge / PR description:** Call out **two themes** so reviewers do not miss non-server files: (1) **Server** — `NpgsqlDataSourceShutdownHostedService`, explicit `AddSingleton<NpgsqlDataSource>`, and `InMemoryWebApplicationFactory` stripping; (2) **Repo process** — [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md) and cross-links in `AGENTS.md`, `story-kickoff`, code-review, and docs-review agent rules.
|
||||
|
||||
Cross-check [decomposition — server / persistence](../decomposition/README.md) if referenced for lifecycle expectations; keep **NEON-15** in commit subjects per [jira-git-naming](../../.cursor/rules/jira-git-naming.md).
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# NEON-26 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEON-26 |
|
||||
| **Title** | E1.M2: ZoomBandConfig + discrete zoom input (clamped) |
|
||||
| **Jira** | [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26) |
|
||||
| **Parent** | [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) |
|
||||
| **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Add **data-driven discrete zoom bands** so the player can adjust framing within limits; **`CameraState`** reports the active band and **effective distance** while **preserving yaw** and the NEON-25 orbit seam.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`ZoomBandConfig`** — `Resource` (no `class_name`; match [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md)) holding an ordered list of **follow distances** (meters per band), plus a **default band index** (clamped to valid range when applied).
|
||||
- **`isometric_follow_camera.gd`** — `@export` reference to `ZoomBandConfig`; **internal active band index** clamped to `[0, band_count - 1]`; each frame use **distance for the active band** in `desired_eye_world` (same pitch / presentation yaw as today).
|
||||
- **Input:** cycle bands **in** / **out** (e.g. mouse wheel up/down and/or `+`/`-` keys) via **`InputMap` actions** in `project.godot`; handle in **`_unhandled_input`** (or equivalent) on the rig so UI can steal focus later if needed.
|
||||
- **`CameraState`:** set **`zoom_band_index`** to the active index and **`distance`** to the **effective** distance used for framing (not the legacy single export alone when bands are active).
|
||||
- **Designer tuning:** one **Resource** asset (e.g. `res://resources/...tres` or inline default) editable in the inspector; rig points at it.
|
||||
- **Optional:** stub signal / throttled `print` / `TODO(E9.M1)` for `camera_zoom_changed` telemetry when a schema exists.
|
||||
|
||||
**Out of scope** (per Jira)
|
||||
|
||||
- Smooth analog zoom between bands (unless a one-line lerp is explicitly chosen later — default **no**).
|
||||
- Occlusion policy (**NEON-27**).
|
||||
- Yaw orbit input (**NEON-25** seam stays; do not bind rotate).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Zoom input only moves between **defined bands** (clamped; no out-of-range index).
|
||||
- [x] At each band, player remains **readably framed** during motion (manual playtest + distances chosen around current NEON-25 baseline).
|
||||
- [x] `CameraState` reflects **active band** and **effective distance**, and still exposes **yaw** unchanged in meaning.
|
||||
- [x] Config lives in **one tunable place** for designers (`ZoomBandConfig` resource referenced by the rig).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`zoom_band_config.gd`** — `extends Resource` with e.g. `@export var band_distances: PackedFloat32Array` (length ≥ 1) and `@export var default_band_index: int`. Provide **`band_count`**, **`clamp_index(i: int) -> int`**, **`distance_at(index: int) -> float`** (uses clamped index). Validate in `_validate_property` or getter: if empty at runtime, treat as single band using rig fallback (see below).
|
||||
2. **`isometric_follow_camera.gd`** — Add **`@export var zoom_band_config: Resource`** (no `class_name` on the config script); at runtime require **`get_script() == preload("…/zoom_band_config.gd")`** so headless CI and typed exports stay aligned. Conceptually this is **`ZoomBandConfig`**. Keep **`follow_distance`** as **fallback** when config is null, empty, or has non‑positive band distances (preserves NEON-25 scenes/tests). Track **`_zoom_band_index`** initialized from config default (clamped). On zoom-in/out actions: adjust index with `clamp_index`, no op at ends (or wrap — **clamp** per story). **`_process`:** compute effective distance from config + index or fallback export. Replace uses of raw `follow_distance` in `desired_eye_world` with that value. **`_sync_camera_state`:** pass effective distance into state (`zoom_band_index` + `distance`, plus existing yaw / path / focus).
|
||||
3. **`project.godot`** — Add **`camera_zoom_in`** and **`camera_zoom_out`** (or one action with axis — prefer two actions for wheel + keys) with mouse wheel and key events as in Jira examples.
|
||||
4. **Scene / resource** — Add a default **`ZoomBandConfig`** `.tres` under `res://resources/` (or co-located under `client/resources/`) with **≥ 3** bands bracketing **~25.709** m so readability can be checked; assign on **`IsometricFollowCamera`** in `main.tscn`.
|
||||
5. **Telemetry stub** — Optional `signal zoom_band_changed(new_index: int, distance: float)` or `TODO` comment referencing E9.M1; no external schema required for this story.
|
||||
|
||||
**Decisions (pre-implementation)**
|
||||
|
||||
| Topic | Choice |
|
||||
|--------|--------|
|
||||
| **Band parameter** | **Distance-only** per step (same pitch / FOV as NEON-25); document in module snapshot. FOV-per-band deferred unless trivial. |
|
||||
| **Bounds at ends** | **Clamp** (no wrap): input at min/max does nothing extra. |
|
||||
| **Config null / empty** | Use **`follow_distance`** export only; **`zoom_band_index`** in state = `0`. |
|
||||
| **Invalid band values** | Any **≤ 0** distance in **`band_distances`** treats the config as unusable (same fallback as null/empty). |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `client/scripts/zoom_band_config.gd` | `Resource` type for ordered band distances + default index; clamp helpers (no `class_name`). |
|
||||
| `client/resources/isometric_zoom_bands.tres` | Default `ZoomBandConfig` instance for designers (≥ 3 bands around current baseline distance). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/isometric_follow_camera.gd` | Wire config, band index, input handling, effective distance for eye position, correct `CameraState` sync (replace hard-coded `zoom_band_index = 0`). |
|
||||
| `client/scripts/camera_state.gd` | Tighten comments only if needed so **`distance`** = effective follow distance and **`zoom_band_index`** = active band are unambiguous (fields already exist). |
|
||||
| `client/project.godot` | Register **`camera_zoom_in`** / **`camera_zoom_out`** input actions. |
|
||||
| `client/scenes/main.tscn` | Assign **`zoom_band_config`** on the follow rig to the new `.tres` (or leave null to exercise fallback — prefer assigned for acceptance “designer tunable”). |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Update **implementation snapshot** for NEON-26: `ZoomBandConfig` path, input actions, discrete bands replace “single scalar until NEON-26”. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `client/test/zoom_band_config_test.gd` | **New:** `clamp_index` for empty / single / multi band; `distance_at` matches configured values; default index clamped when out of range. |
|
||||
| `client/test/isometric_follow_camera_test.gd` | **Change:** add a test for a **small static helper** on the rig script (e.g. resolve effective follow distance from optional config + band index + fallback export) so headless CI covers band selection **without** simulating `InputEvent`. |
|
||||
|
||||
**Manual verification:** in running client, wheel and keys cycle bands; confirm framing stays readable at each step and `CameraState` matches (debugger or brief log).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Band count = 1:** Still valid; zoom input should no-op (clamped range size 1).
|
||||
- **Readability AC** is partly subjective; pick band spread conservatively and adjust `.tres` after first playtest.
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
# NEON-27 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEON-27 |
|
||||
| **Title** | E1.M2: OcclusionPolicy — keep player readable through geometry |
|
||||
| **Jira** | [NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27) |
|
||||
| **Parent** | [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) |
|
||||
| **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Implement `OcclusionPolicy` so static geometry between the camera and the player does not fully hide the player silhouette or combat telegraphs.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Choose and implement a prototype-appropriate occlusion technique: **RayCast-based material fade** (see [Technical approach](#technical-approach)).
|
||||
- New `OcclusionPolicy` **Resource** (`occlusion_policy.gd`) — data-driven config for fade alpha, fade duration, affected collision mask, cast depth cap, and optional perf-log threshold; follows the same no-`class_name` convention as `ZoomBandConfig`.
|
||||
- Wire `OcclusionPolicy` into `isometric_follow_camera.gd` as an `@export` so it runs consistently in the same camera controller stack as follow + zoom.
|
||||
- Tag the prototype district `Obstacle` node (a 2 × 2 × 2 box at `(6, 0, 5)`) with the **`"occluder"`** group; assign the new `.tres` on the `IsometricFollowCamera` rig in `main.tscn`.
|
||||
- Optional perf marker: when `occluder_count_log_threshold > 0` and the active occluder count meets or exceeds the threshold, emit `push_warning` for later stress-test reference.
|
||||
- Update `E1_M2_IsometricCameraController.md` with an implementation snapshot and the readability risk gate note required by acceptance criteria.
|
||||
|
||||
**Out of scope** (per Jira)
|
||||
|
||||
- Perfect transparency for all scene assets; full art pipeline integration.
|
||||
- Server awareness of occlusion.
|
||||
- Smooth occlusion handling for moving/dynamic objects (static geometry only for prototype).
|
||||
- Yaw orbit or camera nudge approach (would break framing; fixed rig stays).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] In a documented test setup, occlusion **no longer fully hides** the player in the common worst case (before/after screenshot or short clip note in PR).
|
||||
- **Before:** obstacle fully opaque, player visible only when clear of the geometry.
|
||||
- **After:** obstacle fades to ~25% alpha when between the camera and player; player silhouette and slot indicator remain readable. Screenshots: `assets/Screenshot_20260408_234113-*.png` (faded), `assets/Screenshot_20260408_234125-*.png` (restored).
|
||||
- [x] Policy is **configurable or data-driven** enough to iterate without rewriting core follow logic (`OcclusionPolicy` resource swappable in inspector; `fade_alpha`, `occluder_group`, and `occluder_collision_mask` are exported).
|
||||
- [x] Risk called out in module doc: occlusion hiding telegraphs — link to **readability checklist** or gate note for prototype demo.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Technique: RayCast-based per-surface material fade
|
||||
|
||||
Each `_process` frame, after the camera eye is positioned, cast a ray from the smoothed eye position (`_smoothed_eye`) to the player's focus point. Any `StaticBody3D` (or similar) intersecting the ray that is tagged with the **`"occluder"`** group has its `MeshInstance3D` children' surfaces overridden with a **faded copy** of their materials (alpha reduced to `fade_alpha`). When a body leaves the ray path, its original surface materials are restored.
|
||||
|
||||
**Why this technique:** pure GDScript — no custom shader required for prototype; configurable; reversible; respects combat readability by making geometry semi-transparent rather than fully hiding it. Camera nudge would break the fixed isometric framing. A cutout shader would require art pipeline work outside the prototype scope.
|
||||
|
||||
**Why group-based (`"occluder"`) rather than collision-layer filtering alone:** the prototype `Floor` (`StaticBody3D`) shares the default collision layer 1 with the `Obstacle`. Filtering by group is explicit and avoids false hits from the floor geometry without requiring separate collision-layer assignments.
|
||||
|
||||
**Player exclusion:** the `Player` is on collision layer 2 (independent of layer 1); the default `occluder_collision_mask` of `1` already excludes the player body without needing to add it to the ray exclusion list. Confirmed from `main.tscn`: `Player.collision_layer = 2`.
|
||||
|
||||
**Material override strategy:** for each `MeshInstance3D` child of a blocking body, iterate surfaces. For each surface, save the current `surface_override_material` (may be null). Resolve the effective material: prefer the surface override; fall back to `mesh.surface_get_material(i)`. Three cases:
|
||||
- **`StandardMaterial3D`:** `duplicate()` it, set `transparency = BaseMaterial3D.TRANSPARENCY_ALPHA` and `albedo_color.a = fade_alpha`, apply as the surface override.
|
||||
- **null (no material assigned):** create a plain `StandardMaterial3D` with `transparency = TRANSPARENCY_ALPHA` and `albedo_color.a = fade_alpha`. This handles the common prototype case where a mesh has no material set (e.g. the `Obstacle` which uses a plain `BoxMesh`); restoring to `null` returns the surface to Godot's default rendering exactly.
|
||||
- **Anything else (e.g. `ShaderMaterial`):** skip that surface silently and emit `push_warning` — do not substitute a plain gray material.
|
||||
|
||||
Restore by setting `surface_override_material` back to the saved value (or `null`) when the body is no longer blocking.
|
||||
|
||||
**Fade timing:** instant — set `albedo_color.a` to `fade_alpha` on entry, restore immediately on exit. No Tween; no `fade_duration` export. A smooth-fade pass can be added post-prototype if the pop is distracting during the demo playtest. State: `_occluder_overrides: Dictionary` mapping `Node3D → Array[{mesh, surface, original_mat}]`.
|
||||
|
||||
### Step-by-step implementation
|
||||
|
||||
1. **`occlusion_policy.gd`** — `extends Resource`, no `class_name`. Exported fields:
|
||||
- `enabled: bool = true`
|
||||
- `fade_alpha: float = 0.25` — target alpha when occluding (0 = invisible, 1 = opaque).
|
||||
- `occluder_group: String = "occluder"` — group that tags scene nodes as potential occluders.
|
||||
- `occluder_collision_mask: int = 1` — physics layers the ray checks.
|
||||
- `max_occluder_cast_depth: int = 4` — max successive intersect_ray calls per frame (caps cost).
|
||||
- `occluder_count_log_threshold: int = 0` — if > 0, push_warning when active count ≥ threshold.
|
||||
- Validation helper: `func is_valid() -> bool` — returns `true` if `enabled` and `fade_alpha >= 0`.
|
||||
|
||||
2. **`isometric_follow_camera.gd`** changes:
|
||||
- Add `@export var occlusion_policy: Resource`.
|
||||
- Add private state: `_occluder_overrides: Dictionary = {}`.
|
||||
- Add `_occlusion_policy_valid() -> bool` (mirrors `_zoom_config_valid`): checks script identity and `is_valid()`.
|
||||
- Call `_update_occlusion(focus)` at end of `_process` after the eye / look_at update.
|
||||
- `_update_occlusion(focus: Vector3) -> void`: if policy invalid, call `_restore_all_occluders()` and return. Otherwise, cast iterative rays (up to `max_occluder_cast_depth`), filter hits by `occluder_group`; restore bodies no longer blocking; fade newly blocking bodies. Emit perf warning when threshold exceeded.
|
||||
- `_apply_occluder_fade(body: Node3D) -> void`: enumerate `MeshInstance3D` descendants, save overrides, create faded `StandardMaterial3D` copies (instant); skip non-`StandardMaterial3D` surfaces with `push_warning`.
|
||||
- `_restore_occluder(body: Node3D) -> void`: restore original surface override materials (instant), remove from `_occluder_overrides`.
|
||||
- `_restore_all_occluders() -> void`: call `_restore_occluder` for all entries (on disable/null policy).
|
||||
|
||||
3. **`isometric_occlusion_policy.tres`** — default `OcclusionPolicy` resource (fade_alpha=0.25, mask=1, depth=4, threshold=0). Assign to `IsometricFollowCamera` in `main.tscn`.
|
||||
|
||||
4. **`main.tscn`** — Add `"occluder"` group to the `Obstacle` node; assign `occlusion_policy` resource on `IsometricFollowCamera`.
|
||||
|
||||
5. **`E1_M2_IsometricCameraController.md`** — Add NEON-27 implementation snapshot; add readability risk gate note (links to acceptance criteria: before-/after screenshot in PR required at prototype demo gate).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Choice | Rationale |
|
||||
|-------|--------|-----------|
|
||||
| **Occluder identification** | Explicit `"occluder"` group tag (Option A) | Precise; failure mode is obvious ("wall doesn't fade — tag it"); no false positives as scene grows; collision layers stay single-purpose. |
|
||||
| **Fade timing** | Instant alpha override — no Tween | Eliminates Tween race conditions (stuck-transparent geometry); simpler state; satisfies AC; smooth-fade pass deferred to post-prototype if pop is distracting at demo. |
|
||||
| **Non-`StandardMaterial3D` surfaces** | Skip silently + `push_warning` | Avoids unexpected appearance change; warning surfaces future mismatches without hiding them. |
|
||||
| **Null surface material** | Create plain `StandardMaterial3D` with fade alpha | The prototype `Obstacle` (`BoxMesh`, no material assigned) hits this path. A null material means Godot renders the default; overriding with a transparent `StandardMaterial3D` achieves the fade, and restoring to `null` returns to the default exactly. |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `client/scripts/occlusion_policy.gd` | `Resource` type holding fade and detection config; `is_valid()` helper; no `class_name`. |
|
||||
| `client/resources/isometric_occlusion_policy.tres` | Default `OcclusionPolicy` instance for the prototype rig. |
|
||||
| `client/test/occlusion_policy_test.gd` | GdUnit4 unit tests for the policy resource (see [Tests](#tests)). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/isometric_follow_camera.gd` | Add `occlusion_policy` export, `_update_occlusion` pipeline, occluder override state, fade/restore helpers, `_occlusion_policy_valid` guard. |
|
||||
| `client/scenes/main.tscn` | Tag `Obstacle` with `"occluder"` group; assign `isometric_occlusion_policy.tres` on `IsometricFollowCamera`. |
|
||||
| `client/test/isometric_follow_camera_test.gd` | Add test for `_occlusion_policy_valid`-equivalent static path (null policy → false; wrong-script resource → false; valid resource → true) via a static helper if possible; otherwise document as manual-only. |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Add NEON-27 implementation snapshot; add readability risk gate note per AC. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `client/test/occlusion_policy_test.gd` | **New.** `is_valid()` returns true with defaults; returns false when `enabled = false`; `fade_alpha` edge cases (0.0 valid, negative invalid if applicable). Default field values match resource. |
|
||||
| `client/test/isometric_follow_camera_test.gd` | **Change.** Add a static-helper test for occlusion policy guard: confirm that a null resource and a wrong-script resource are rejected, and a correct-script `OcclusionPolicy` resource is accepted (no physics engine needed — tests the guard function logic only). |
|
||||
|
||||
**Manual verification:** Walk the player behind the `Obstacle` node in the running client. The obstacle should fade to semi-transparent (alpha ~0.25) as the player passes behind it, then restore when the player moves clear. Confirm `CameraState` / camera framing are unaffected. Capture a brief clip or before/after screenshot for the PR.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Rapid toggle at wall edges:** with instant override, a player sidestepping at the edge of an occluder may cause per-frame toggle. This is visually acceptable for prototype; if it proves distracting, a one-frame hysteresis (require N consecutive frames clear before restoring) can be added without a structural change.
|
||||
- **Shared vs. per-instance materials:** if the `Obstacle` in the future uses a shared `StandardMaterial3D` from the mesh resource directly (no override set), `duplicate()` ensures we don't mutate the shared asset. This is already handled by the override strategy above, but must be verified at runtime.
|
||||
- **`max_occluder_cast_depth` performance:** each `intersect_ray` call with growing exclusion list is O(scene complexity). Cap of 4 is conservative for the prototype district; tune if the perf log fires.
|
||||
- **Scene grows (NEON-29):** when the prototype district expands (NEON-29), add representative occluders to the `"occluder"` group and rerun the manual verification pass.
|
||||
- **Readability gate:** the risk documented in the module doc (occlusion hiding telegraphs) must be surfaced in the prototype demo checklist. Before-/after evidence in this PR's description serves as the first data point.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Code review: NEON-15
|
||||
|
||||
**Date:** 2026-04-08
|
||||
**Scope:** Branch `NEON-15-dispose-npgsql-datasource` vs `main` — [NEON-15](https://neon-sprawl.atlassian.net/browse/NEON-15) (dispose `NpgsqlDataSource` on shutdown), plus planning-doc rule cross-links.
|
||||
**Base:** `main` (range `main...HEAD`, commits `abffd29` → `62ff210`).
|
||||
|
||||
**Follow-up:** **Suggestions** and **nits** below are **done** (strikethrough + **Done.**).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
The branch adds `NpgsqlDataSourceShutdownHostedService`, registers it **before** `PostgresDevPlayerSeedHostedService` so `StopAsync` runs **last** (correct use of reverse shutdown order), switches to `AddSingleton<NpgsqlDataSource>(…)`, and extends `InMemoryWebApplicationFactory` so the new hosted service is stripped with the rest of the Postgres path. The implementation matches the written plan and acceptance criteria. A separate commit adds `.cursor/rules/planning-implementation-docs.md` and wires it into kickoff, code-review, docs-review, and `AGENTS.md`; that is coherent and self-consistent.
|
||||
|
||||
Residual risk is low and already acknowledged in the plan: the root `ServiceProvider` may still dispose the same `NpgsqlDataSource` after hosted services stop; the team accepted **Option 2** after green tests. No API or authority contract changes.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEON-15-implementation-plan.md` | **Matches** — Decisions (Option 2, ConfigureAwait), AC checked, technical approach and file lists align with the diff; open questions resolved per [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md). |
|
||||
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches (post-review update)** — Snapshot links NEON-15 for shared `NpgsqlDataSource` shutdown — `8c3e8c0`. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches (post-review update)** — E1.M1 row snapshot + plans column include NEON-15 — `8c3e8c0`. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **N/A** — no status or dependency change required for a shutdown-only fix. |
|
||||
| `docs/decomposition/modules/client_server_authority.md`, `contracts.md` | **N/A** — no authority or wire-shape change. |
|
||||
| `.cursor/rules/planning-implementation-docs.md`, `story-kickoff.md`, `code-review-agent.md`, `docs-review-agent.md`, `AGENTS.md` | **Matches** — rule text is clear; relative links in agent rules remain valid. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
_None._
|
||||
|
||||
## Suggestions
|
||||
|
||||
**All done.**
|
||||
|
||||
1. ~~**Traceability (optional):** Add `NEON-15` / `docs/plans/NEON-15-implementation-plan.md` to the E1.M1 **Plans / pointers** column in `documentation_and_implementation_alignment.md` (or a one-line note under Postgres in `E1_M1_InputAndMovementRuntime.md`) so future readers find shutdown lifecycle work next to NEON-5.~~ **Done.** E1.M1 snapshot + plans list + module implementation snapshot updated — `8c3e8c0`.
|
||||
2. ~~**PR narrative:** Call out the **two themes** in the branch (server disposal + planning-doc rule) so reviewers do not miss the `.cursor/` and `AGENTS.md` changes if they only skim `server/`.~~ **Done.** `docs/plans/NEON-15-implementation-plan.md` **PR / review** section lists server vs repo-process themes — `8c3e8c0`.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~**`StopAsync` ignores `cancellationToken`:**~~ **Done.** `ThrowIfCancellationRequested()` before `DisposeAsync()`; Npgsql still has no cancellable dispose.
|
||||
- ~~**Plan / manual Ctrl+C:**~~ **Done.** Plan **Technical approach** #5 documents `WebApplicationFactory` disposal coverage + optional local run.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test NeonSprawl.sln` — **Passed** (36 tests) at review time.
|
||||
- Optional: run `NeonSprawl.Server` with `ConnectionStrings:NeonSprawl` set, stop the process, confirm clean shutdown logs.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Code review — NEON-26 (ZoomBandConfig + discrete zoom)
|
||||
|
||||
**Date:** 2026-04-08
|
||||
**Scope:** Branch `NEON-26-zoom-band-config-discrete-input` vs `origin/main` (NEON-26 zoom bands + follow-up `.tres` tuning commits). Issue **NEON-26**; no PR URL supplied.
|
||||
**Base:** `origin/main` @ `88653292104eddfc1db702f97b02a088fb80c1c7` (merge-base with HEAD at review time).
|
||||
**Follow-up:** Suggestions and nits below were implemented in-repo (README, plan typing note, positive-distance validation, `_sync_camera_state(effective_distance)`, telemetry TODO wording) and re-reviewed after `ff83081`.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
The branch delivers **data-driven discrete zoom** via `ZoomBandConfig`, **InputMap** actions, **`_unhandled_input`** on the follow rig, and **`CameraState`** updates for effective distance and band index, with **clamp** semantics and **`follow_distance`** fallback when config is missing, empty, or invalid. **GdUnit** coverage was added for the resource and **`effective_follow_distance`**. Module and dependency-register docs were updated. Overall risk is **low**: client-local camera only, no server coupling; logic is straightforward. Re-review after the follow-up commit found **no additional issues**.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEON-26-implementation-plan.md` | **Matches** — technical approach updated to document **`Resource` export + `get_script()`** and positive-distance fallback; aligns with implementation. |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Matches** — implementation snapshot updated for NEON-26 (config path, input actions, distance-only bands, fallback, signal + E9.M1 TODO). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M2 note reflects zoom bands shipped. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` (camera client-local) | **Matches** — no server use of camera pose introduced. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **N/A** for mandatory table edits; E1.M2 status remains “In progress” appropriately (NEON-27/28 open). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**`client/README.md` (testing section)** — The bullet that lists covered scripts is now slightly stale: it does not mention **`isometric_follow_camera.gd`**, **`camera_state.gd`**, or **`zoom_band_config.gd`**, though **`client/test/`** includes suites for them. Per [testing-expectations](../../.cursor/rules/testing-expectations.md), consider extending that sentence so contributors know camera/zoom logic is under GdUnit.~~ **Done.** Scope bullet extended; short **Camera zoom** note added for Input Map / layout remapping.
|
||||
|
||||
2. ~~**`ZoomBandConfig` validation** — Designer-editable **`band_distances`** could theoretically include **non-positive** values, which would produce odd framing. Optional: `_validate_property` or a short runtime guard in the rig when loading config (non-blocking for prototype).~~ **Done.** **`all_band_distances_positive()`** on the resource; **`_zoom_config_valid()`** and **`effective_follow_distance`** fall back when any distance is ≤ 0; tests added.
|
||||
|
||||
3. ~~**Plan vs implementation (typing)** — As above, add a brief note to **`NEON-26-implementation-plan.md`** that **`zoom_band_config` is exported as `Resource`** (or untyped) with script identity checked at runtime, so future readers are not confused by the plan’s “ZoomBandConfig” type mention.~~ **Done.** Technical approach step 2 rewritten accordingly.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~**`_current_follow_distance()`** is invoked multiple times per **`_process`** / **`_sync_camera_state`**; a single local **`var d`** per frame would avoid redundant calls (micro-optimization, clarity).~~ **Done.** **`_sync_camera_state(focus, effective_distance)`** takes the distance computed once per frame (and **`_ready`** passes **`dist0`**).
|
||||
|
||||
- ~~**Keyboard zoom:** **`=`** without **Shift** may not match all layouts’ “+” expectations; wheel and keypad bindings mitigate this. Document in README or input remap note if QA reports gaps.~~ **Done.** Documented under **Camera zoom** in **`client/README.md`**.
|
||||
|
||||
- ~~**Signal name vs telemetry TODO:** Comment mentions **`camera_zoom_changed`**; signal is **`zoom_band_changed`** — harmless but slightly inconsistent wording for whoever wires E9.M1 later.~~ **Done.** TODO now references mapping product telemetry to **`zoom_band_changed`**.
|
||||
|
||||
## Verification
|
||||
|
||||
- From `client/`: Godot **4.6** headless import (if needed) then GdUnit:
|
||||
`godot --headless --import --path . --quit-after 8`
|
||||
`godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test`
|
||||
- **Observed on re-review:** GdUnit passed locally: **28 test cases, 0 failures**.
|
||||
- **Manual:** F5, cycle zoom (wheel, `=`, `-`, keypad), confirm framing and that **`CameraState`**-consumers (if any) see band index + distance; confirm **`zoom_band_config` cleared** falls back to **`follow_distance`**.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Code review — NEON-27 (OcclusionPolicy)
|
||||
|
||||
**Date:** 2026-04-08
|
||||
**Scope:** Branch `NEON-27-occlusion-policy` vs `origin/main` (NEON-27 occlusion policy resource, camera integration, scene wiring, tests, and docs). Issue **NEON-27**; no PR URL supplied.
|
||||
**Base:** `origin/main` @ `1d891eb8bb42ccc5e8a74b4fcdb349b9165f3454` (merge-base with HEAD at review time).
|
||||
**Follow-up:** Previously requested changes below are done and were re-reviewed against the updated branch state.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
The branch adds a data-driven `OcclusionPolicy` resource, wires it into `IsometricFollowCamera`, tags the prototype obstacle as an `"occluder"`, and documents the prototype readability gate in the module doc. Re-review confirms the prior cleanup bug is fixed: occluders are now restored on the `_process()` early-return paths as well as on node exit, so transient target-resolution failures no longer strand transparent geometry. The plan and decomposition tracking docs have also been brought back into alignment with the implemented null-material behavior and shipped E1.M2 scope. Residual risk is low and limited to the manual runtime behavior that still needs Godot-side verification.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEON-27-implementation-plan.md` | **Matches** — technical approach now reflects the implemented three-way material handling, including the explicit null-material fallback. |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Matches** — implementation snapshot and readability gate note align with the code and scene changes. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M2 still correctly reads **In progress**, and the note now reflects follow + zoom + occlusion shipped with NEON-28 remaining. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — implementation tracking now includes `E1.M2` with current shipped scope and pointers. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — the change stays client-local and does not introduce any server use of camera pose. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
1. ~~**`client/scripts/isometric_follow_camera.gd` — occluders are never restored when the follow target disappears mid-occlusion.** `_process()` returns immediately when `follow_target_path` no longer resolves, but that path does not call `_restore_all_occluders()`. If the player target is freed, renamed, or otherwise temporarily unavailable while an obstacle is faded, the saved surface overrides stay applied until the camera node exits the tree. Because this leaves world geometry stuck semi-transparent in a recoverable runtime failure mode, it should be fixed before merge by restoring all active occluders on the early-return path (and similarly on any other “cannot update camera this frame” exit that can strand state).~~ **Done.** `_process()` now restores all occluders before both early returns (`camera/_state` unavailable and unresolved `follow_target_path`), so the fade state is cleared when camera updates cannot proceed.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**`docs/plans/NEON-27-implementation-plan.md` — reconcile the null-material story.** The plan currently says null materials are skipped in the “Material override strategy” section, but the implementation and Decisions table intentionally create a transparent `StandardMaterial3D` for null-material surfaces. Update the technical-approach prose so the plan is a single source of truth.~~ **Done.** The “Material override strategy” section now documents the three explicit cases: `StandardMaterial3D`, null material, and non-`StandardMaterial3D`.
|
||||
|
||||
2. ~~**`docs/decomposition/modules/module_dependency_register.md` and `docs/decomposition/modules/documentation_and_implementation_alignment.md` — refresh implementation tracking after merge.** `E1.M2` has meaningful shipped work now (follow, zoom, occlusion), so the register note and tracking table should reflect that status explicitly instead of implying occlusion is still pending or leaving the implementation table empty for this module.~~ **Done.** The register note now says NEON-25 through NEON-27 are in repo, and the implementation alignment table now includes an `E1.M2` row with current scope and pointers.
|
||||
|
||||
## Nits
|
||||
|
||||
None.
|
||||
|
||||
## Verification
|
||||
|
||||
- From `client/`: Godot 4.6 headless import, then GdUnit:
|
||||
`godot --headless --import --path . --quit-after 8`
|
||||
`godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test`
|
||||
- Manual: run the prototype scene, walk the player behind the `Obstacle`, confirm it fades and restores normally, then test the failure path by making `follow_target_path` unresolved or removing the target while the obstacle is faded; geometry should restore immediately instead of staying transparent.
|
||||
- Review environment note: I could not run the automated Godot command here because no `godot` binary was available on `PATH`.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
cd "$repo_root"
|
||||
|
||||
gdlint_bin=""
|
||||
gdformat_bin=""
|
||||
|
||||
if [[ -x "$repo_root/.venv-gd/bin/gdlint" ]]; then
|
||||
gdlint_bin="$repo_root/.venv-gd/bin/gdlint"
|
||||
elif command -v gdlint >/dev/null 2>&1; then
|
||||
gdlint_bin="$(command -v gdlint)"
|
||||
fi
|
||||
|
||||
if [[ -x "$repo_root/.venv-gd/bin/gdformat" ]]; then
|
||||
gdformat_bin="$repo_root/.venv-gd/bin/gdformat"
|
||||
elif command -v gdformat >/dev/null 2>&1; then
|
||||
gdformat_bin="$(command -v gdformat)"
|
||||
fi
|
||||
|
||||
if [[ -z "$gdlint_bin" || -z "$gdformat_bin" ]]; then
|
||||
echo "pre-push: missing gdtoolkit commands (gdlint/gdformat)." >&2
|
||||
echo "Install locally with:" >&2
|
||||
echo " python3 -m venv .venv-gd" >&2
|
||||
echo " .venv-gd/bin/pip install \"gdtoolkit==4.5.0\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push: running gdlint on client/scripts and client/test"
|
||||
"$gdlint_bin" client/scripts client/test
|
||||
|
||||
echo "pre-push: running gdformat --check on client/scripts and client/test"
|
||||
"$gdformat_bin" --check client/scripts client/test
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
hook_dir="$repo_root/.git/hooks"
|
||||
hook_path="$hook_dir/pre-push"
|
||||
|
||||
mkdir -p "$hook_dir"
|
||||
|
||||
cat >"$hook_path" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
exec "$repo_root/scripts/git-hooks/pre-push" "$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$hook_path"
|
||||
echo "Installed pre-push hook at $hook_path"
|
||||
|
|
@ -21,7 +21,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
(d.ServiceType == typeof(IHostedService) &&
|
||||
d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService)))
|
||||
(d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
|
||||
d.ImplementationType == typeof(NpgsqlDataSourceShutdownHostedService))))
|
||||
{
|
||||
services.RemoveAt(i);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Npgsql;
|
||||
|
||||
namespace NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
/// <summary>Disposes the shared <see cref="NpgsqlDataSource"/> when the host stops. Registered before <see cref="PostgresDevPlayerSeedHostedService"/> so <see cref="IHostedService.StopAsync"/> runs last (NEON-15).</summary>
|
||||
internal sealed class NpgsqlDataSourceShutdownHostedService(NpgsqlDataSource dataSource) : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await dataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ public static class PositionStateServiceCollectionExtensions
|
|||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
var trimmed = cs.Trim();
|
||||
services.AddSingleton(_ => Npgsql.NpgsqlDataSource.Create(trimmed));
|
||||
services.AddSingleton<Npgsql.NpgsqlDataSource>(_ => Npgsql.NpgsqlDataSource.Create(trimmed));
|
||||
services.AddHostedService<NpgsqlDataSourceShutdownHostedService>();
|
||||
services.AddHostedService<PostgresDevPlayerSeedHostedService>();
|
||||
services.AddSingleton<IPositionStateStore, PostgresPositionStateStore>();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue