Compare commits
9
Commits
57068b61cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53e296ef5 | ||
|
|
fdbf811808 | ||
|
|
1fba43272a | ||
|
|
18e13ca80c | ||
|
|
8c021c134f | ||
|
|
29d26bd036 | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 |
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Edit|Write|apply_patch",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
|
||||||
"timeout": 5,
|
|
||||||
"statusMessage": "Checking UI changes"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
|
||||||
"timeout": 30,
|
|
||||||
"statusMessage": "Design deep pass"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,3 +42,7 @@ tmp/
|
|||||||
# Python caches
|
# Python caches
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
|
.pi
|
||||||
|
.mesh
|
||||||
|
.worktrees/
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Defines and maintains game-data-capture domain contracts, taxonomy, evidence schemas, state transitions, and trust boundaries.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 80
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/contracts/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/domain/**": allow
|
|
||||||
"tools/game-data-capture/tests/contract/**": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-001.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own shared contracts for the game-data-capture utility. Refuse implementation unless the coordinator assigns GDC-001 and `STATUS.md` marks it `in_progress`. Read `AGENTS.md`, the plan README, GDC-001, and dependency handoffs before editing.
|
|
||||||
|
|
||||||
Model source evidence without guesses. Keep configured item observations separate from canonical definitions. Support composite screenshots and explicit abstention. Design JSON Schemas that Python and TypeScript can validate.
|
|
||||||
|
|
||||||
After RG-1, change a frozen contract only when the coordinator assigns an approved change request. Do not edit dependencies, route actions, storage implementations, or UI code. Add contract tests and write `handoffs/GDC-001.md`. Do not commit or push.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
description: Coordinates the game-data-capture implementation, delegates dependency-ready tasks, reviews every handoff, and enforces release gates.
|
|
||||||
mode: primary
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 120
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
external_directory: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tmp/game-data-capture-plan/**": allow
|
|
||||||
task:
|
|
||||||
"*": deny
|
|
||||||
"gdc-*": allow
|
|
||||||
"explore": allow
|
|
||||||
"reviewer": allow
|
|
||||||
bash: ask
|
|
||||||
question: allow
|
|
||||||
todowrite: allow
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"webapp-testing": allow
|
|
||||||
browser: ask
|
|
||||||
browser_*: ask
|
|
||||||
---
|
|
||||||
|
|
||||||
You coordinate the SHD.build game-data-capture project. You inspect and delegate; you do not implement source changes.
|
|
||||||
|
|
||||||
Before work:
|
|
||||||
|
|
||||||
1. Read `AGENTS.md` and every applicable repository instruction.
|
|
||||||
2. Read `tmp/game-data-capture-plan/README.md`, `STATUS.md`, the assigned task, and dependency handoffs.
|
|
||||||
3. Inspect Git status and preserve unrelated work.
|
|
||||||
4. Confirm dependencies and the current review gate. Refuse to delegate a task unless `STATUS.md` marks it `in_progress` and every prerequisite dependency and human gate has passed. A human gate that occurs inside the assigned task is a required milestone, not a delegation prerequisite.
|
|
||||||
|
|
||||||
Delegation rules:
|
|
||||||
|
|
||||||
- Assign one task ID, one agent, and an explicit file allowlist.
|
|
||||||
- Run workers in parallel only when their files do not overlap.
|
|
||||||
- Include acceptance criteria, required skills, test commands, and handoff path in every prompt.
|
|
||||||
- Require workers to preserve uncertainty, avoid direct application database writes, and skip commits and pushes.
|
|
||||||
- Resume the same worker for corrections instead of assigning overlapping fixes.
|
|
||||||
|
|
||||||
Review rules:
|
|
||||||
|
|
||||||
- Read every changed file and test. Do not accept a worker's summary as evidence.
|
|
||||||
- Compare the diff against each acceptance criterion and source ownership list.
|
|
||||||
- Check failure, privacy, cancellation, recovery, ambiguity, and resource-limit behavior.
|
|
||||||
- Reject tests that pass after removing the intended behavior.
|
|
||||||
- Route defects to the owning worker. You may edit only the ignored plan, status, and handoff files.
|
|
||||||
- Apply the security criteria in each task during interim review. Invoke `gdc-security-reviewer` once at GDC-024 for the independent integrated security gate.
|
|
||||||
- Do not open a downstream wave before its gate passes.
|
|
||||||
- Run the task's validation commands after worker review. Ask for shell approval when OpenCode requires it.
|
|
||||||
- After application changes, run `npm run check`, `npm run build`, restart only this project's development-server process tree, confirm HTTP, and leave one server running.
|
|
||||||
|
|
||||||
Update `STATUS.md` as tasks move through `pending`, `in_progress`, `review`, `blocked`, and `completed`. Record gate verdicts in the relevant handoff.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Writes tested installation, privacy, capture, OCR, video, export, ISAC handoff, recovery, and troubleshooting documentation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.4-mini
|
|
||||||
variant: low
|
|
||||||
steps: 60
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/README.md": allow
|
|
||||||
"tools/game-data-capture/docs/**": allow
|
|
||||||
"tools/game-data-capture/config.example.toml": allow
|
|
||||||
"docs/architecture.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-023.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"stop-slop": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-023. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-022 passed review. Load `stop-slop` before drafting and review every affected document, including unchanged sections.
|
|
||||||
|
|
||||||
Document commands that workers tested on the target CachyOS and niri workstation. Separate required from optional dependencies. State cloud behavior, retention, and privacy directly. Include no real key, private corpus text, or unsupported behavior.
|
|
||||||
|
|
||||||
Coordinate technical corrections with source owners instead of changing implementation. Write `handoffs/GDC-023.md` and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements deterministic evidence archives, checksums, verification, and bounded reviewed-draft handoffs for ISAC.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 80
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/evidence/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/export/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/evidence/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/export/**": allow
|
|
||||||
"tools/game-data-capture/tests/contract/test_export_golden.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-016.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-016. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-013 passed review. Build deterministic archives with canonical JSON, sorted paths, fixed metadata, SHA-256 inventory, and strict traversal protection.
|
|
||||||
|
|
||||||
Keep evidence archives separate from bounded ISAC handoffs. Exclude screenshots from handoffs by default. Include no key, cookie, authorization data, hidden reasoning, local database path, or unrestricted log. Preserve export lineage rather than rewriting an old archive.
|
|
||||||
|
|
||||||
Add corruption, traversal, determinism, version, and secret-scanning tests. Write `handoffs/GDC-016.md` and do not commit or push.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements the authenticated ISAC capture-intake boundary, handoff validation, dependency planning, permission display, and mutation safeguards.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 110
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/types.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/schema.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/plan.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/*.test.ts": allow
|
|
||||||
"src/routes/isac/capture/**": allow
|
|
||||||
"src/routes/isac/+layout.svelte": allow
|
|
||||||
"src/app.css": allow
|
|
||||||
"docs/DESIGN_SYSTEM.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-018.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"frontend-design": allow
|
|
||||||
"impeccable": allow
|
|
||||||
"stop-slop": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-018. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, GDC-016 and H4 passed, and dependency handoffs were read. Preserve the existing ISAC trust boundary. Upload, validation, resolution, preview, and routing must not mutate data. Require `data.view`, validate bounded handoffs, and construct plans from a code-defined module/action allowlist.
|
|
||||||
|
|
||||||
Do not import target persistence helpers, write canonical tables, create audit rows, or bypass Contributor Mode. Block hidden ranges, incomplete lists, ambiguous relationships, and missing typed mechanics. State whether confirmation would publish or propose.
|
|
||||||
|
|
||||||
Use project UI skills and load `stop-slop` before any design-system edit. Update the design system only for reusable decisions. Add permission, version, bounds, and no-mutation tests. Write `handoffs/GDC-018.md` and do not commit or push.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adapts reviewed Skill Chip and OS Protocol capture drafts into existing ISAC editors without fabricating relationships or mechanic programs.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/adapters/skillchips.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/protocols.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/progression.test.ts": allow
|
|
||||||
"src/routes/isac/skillchips/+page.svelte": allow
|
|
||||||
"src/routes/isac/protocols/+page.svelte": allow
|
|
||||||
"src/routes/isac/skillchips/capture-intake.test.ts": allow
|
|
||||||
"src/routes/isac/protocols/capture-intake.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-020.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-020. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-018 and GDC-010 passed review.
|
|
||||||
|
|
||||||
Prefill only reviewed evidence. Resolve abilities and Attributes without fuzzy auto-selection. Keep shared Protocol profiles separate. Do not fabricate `conditionalRule`; require a reviewed contract-valid program. Preserve capacity checks, icon reset, result tokens, Contributor Mode, readiness refresh, and audit behavior.
|
|
||||||
|
|
||||||
Tests must assert audit actor, category, action, target, and complete owned relationship evidence. Write `handoffs/GDC-020.md` and do not commit or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adapts reviewed Specialization, Ability, Focus, assignment, tree-node, and rank drafts while protecting complete graph replacement semantics.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 110
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/adapters/specializations.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/specializations.test.ts": allow
|
|
||||||
"src/routes/isac/specializations/+page.svelte": allow
|
|
||||||
"src/routes/isac/specializations/capture-intake.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-021.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-021. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-018 and GDC-011 passed review.
|
|
||||||
|
|
||||||
Require positive parent resolution. Do not guess tree positions, rank costs, Ability targets, intrinsic attacks, or mechanics. Require a complete current-state comparison and confirmation for node updates. Pause after preparing the first action plan until the owner passes H5. Preserve canonical actions, readiness refresh, editor lifecycle, Contributor Mode, and audit behavior.
|
|
||||||
|
|
||||||
Tests must assert audit actor, category, action, target, and complete graph evidence. Write `handoffs/GDC-021.md` and do not commit or push.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adapts reviewed Weapon and Gear capture drafts into existing ISAC editors while preserving validation, moderation, audit, and editor lifecycle.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/adapters/weapons.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/gear.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/weapon-gear.test.ts": allow
|
|
||||||
"src/routes/isac/weapons/+page.svelte": allow
|
|
||||||
"src/routes/isac/gear/+page.svelte": allow
|
|
||||||
"src/routes/isac/weapons/capture-intake.test.ts": allow
|
|
||||||
"src/routes/isac/gear/capture-intake.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-019.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"import-weapon-screenshot": allow
|
|
||||||
"import-talent-screenshot": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-019. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-018 and GDC-009 passed review. Read current editor and action contracts before relying on field names.
|
|
||||||
|
|
||||||
Prefill only reviewed evidence. Do not auto-select ambiguous Talents, Brands, or Attributes. Keep hidden ranges, incomplete pools, augments, and mod slots unresolved. Do not auto-submit. Preserve existing actions, result tokens, add/edit lifecycle, Contributor Mode, and audit behavior.
|
|
||||||
|
|
||||||
Tests must assert audit actor, category, action, target, and before/after relationship evidence. Write `handoffs/GDC-019.md` and do not commit or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements automatic OpenAI Responses API vision fallback, structured schemas, retries, duplicate suppression, and secret-safe handling.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/openai_provider.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/prompts.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/schemas.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/privacy.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_openai_provider.py": allow
|
|
||||||
"tools/game-data-capture/tests/contract/test_openai_schema.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-012.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-012. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-007 and GDC-008 passed review. Use a fixed configurable OpenAI vision model through the Responses API with `store: false`, no tools, original image detail when supported, and strict Structured Outputs.
|
|
||||||
|
|
||||||
Automatic fallback has owner approval and no cost ceiling. Keep retries bounded and suppress duplicate requests. Send useful crops when possible. Treat local OCR as untrusted input and validate every response locally. Never log or persist keys, headers, base64 payloads, unrestricted responses, or hidden reasoning.
|
|
||||||
|
|
||||||
Use fake clients in tests. Do not run a live request without coordinator approval. Write `handoffs/GDC-012.md` and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements evidence extraction for Skill Chips and OS Protocols, including instance values, shared profiles, bonuses, and Talent prose.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/skill_chip.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/os_protocol.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_skill_chip.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_os_protocol.py": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_progression_items.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-010.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-010. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 passed review. Read current Skill Chip, OS Protocol, Attribute, mechanic, and Specialization Ability contracts before editing.
|
|
||||||
|
|
||||||
Separate configured instance quality, level, rolls, and upgrades from reusable definitions. Keep the OS Protocol secondary profile separate from an individual Protocol. Capture Talent prose and values without inventing events, reducers, state, expressions, or scopes. Missing pools, endpoints, profiles, abilities, or Attributes remain unresolved.
|
|
||||||
|
|
||||||
Add ambiguity and interpolation tests. Write `handoffs/GDC-010.md` and do not commit or push.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements the game-data-capture Python package, configuration, diagnostics, dependency lock, and approved web and OCR dependency integration.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/pyproject.toml": allow
|
|
||||||
"tools/game-data-capture/uv.lock": allow
|
|
||||||
"tools/game-data-capture/.python-version": allow
|
|
||||||
"tools/game-data-capture/config.example.toml": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/__init__.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/__main__.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/cli.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/config.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/paths.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/logging.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/doctor.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/test_config.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/test_cli.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-002.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-007.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own Python packaging and dependency integration. The coordinator assigns GDC-002, the dependency-integration phase of GDC-005, or the dependency-integration phase of GDC-007. Refuse implementation unless `STATUS.md` marks that task `in_progress`. Read the task and dependency handoffs before editing.
|
|
||||||
|
|
||||||
Keep imports side-effect free. Use Python 3.12 through `uv`, XDG runtime paths, typed configuration, redacted logs, and explicit dependency pins. Leave runtime and subsystem wiring to `gdc-runtime-integration`.
|
|
||||||
|
|
||||||
Do not absorb storage, OCR, web, media, or provider implementations into core modules. Request contract changes through your handoff. Add focused tests, write the task-specific handoff, and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements capture grouping, duplicate reuse, correction history, merge and split behavior, review states, undo, and ISAC-readiness checks.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/grouping/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/review/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/grouping/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/review/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_review_lifecycle.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-013.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-013. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-009 through GDC-012 passed review.
|
|
||||||
|
|
||||||
Preserve every source and model result. Exact duplicate reuse may be automatic; semantic merges require evidence or user confirmation. Keep evidence-reviewed separate from ISAC-ready. Append correction and undo history, reject stale mutations, and block readiness for missing ranges, pools, relationships, or mechanics.
|
|
||||||
|
|
||||||
Do not edit runtime, storage, provider, extractor, or web code. Write `handoffs/GDC-013.md` and do not commit or push.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description: Builds the local capture-labeling and evidence-review interface with accessible states, responsive layouts, and local-only assets.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/templates/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/static/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_capture_labeling.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_review_ui.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-014.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"frontend-design": allow
|
|
||||||
"impeccable": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own the interface portion of GDC-005 or GDC-014. Refuse implementation unless the coordinator assigns one task, `STATUS.md` marks it `in_progress`, the backend handoff exists, and dependencies passed review. Load `frontend-design` and `impeccable` before design work. The coordinator performs browser validation with `webapp-testing`.
|
|
||||||
|
|
||||||
Build a restrained local operations interface for dense evidence review. Cover keyboard navigation, focus-visible, pending, disabled, error, success, empty, narrow, and reduced-motion states. Use text with color for state. Load no CDN asset. Never auto-arm capture, approve evidence, export, delete, or submit to ISAC.
|
|
||||||
|
|
||||||
Coordinate API needs through the handoff instead of editing backend files. Provide desktop and narrow-layout acceptance cases for coordinator browser validation, update the assigned handoff, and do not commit or push.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: Wires completed capture, web, OCR, provider, video, review, and export services into CLI commands and one supervised runtime.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/cli.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/runtime.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/coordinator.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/supervisor.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/events.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/evaluation/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/pipeline/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_runtime_cli.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-004.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-014.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-015.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-016.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-017.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You integrate completed services; you do not reimplement them. The coordinator assigns one integration phase of GDC-004, GDC-005, GDC-014, GDC-015, GDC-016, or GDC-017. Refuse implementation unless `STATUS.md` marks that task `in_progress`, the subsystem owner completed its handoff section, and dependencies passed review.
|
|
||||||
|
|
||||||
Wire command and lifecycle boundaries through typed ports. Keep SQLite writes in the main process, start one GPU worker, bound provider and media work, reconcile durable jobs, and stop children in order. Aggregate subsystem evaluation interfaces into versioned reports without duplicating OCR, classifier, or extractor policy. Do not edit `pipeline/jobs.py`; `gdc-storage` owns job leases.
|
|
||||||
|
|
||||||
Append an integration section to the assigned handoff. Do not absorb subsystem policy, commit, or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements offline screen-family classification, view and Gear-slot labels, composite record types, region detection, abstention, and evaluation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
external_directory: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/classifier.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/regions.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_classifier.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_regions.py": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_classifier.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-008.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-008. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, H3 selected the OCR engine, and GDC-007 passed review.
|
|
||||||
|
|
||||||
Classify family, view, exact Gear slot, record types, confidence, and abstention. Support composite screens. Use normalized geometry or detected anchors rather than fixed 4K coordinates. Evaluate by capture session and report confusion matrices, class recall, false positives, and abstention. Never call a network service.
|
|
||||||
|
|
||||||
Do not edit OCR, provider, extractor, or domain contracts. Write `handoffs/GDC-008.md` and do not commit or push.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
description: Performs independent read-only security, privacy, data-integrity, and ISAC-boundary review for the complete game-data-capture implementation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-024.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-024 and remain read-only outside its ignored handoff. Refuse review unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-022 and GDC-023 passed. Report findings first, ordered by severity.
|
|
||||||
|
|
||||||
Inspect clipboard helper environments, media limits, subprocess commands, storage recovery, paths and symlinks, GPU queues, OpenAI secrets and payloads, localhost security, XSS, CSRF, archives, ISAC permissions, moderation, audit evidence, editor lifecycle, and data semantics. Read tests and identify assertions that cannot catch regressions.
|
|
||||||
|
|
||||||
Each finding needs affected files, reproduction, impact, required correction, and verification. High or critical findings block release. Recheck owner fixes and record dispositions. Do not patch implementation, commit, or push.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements extraction for Specialization identities, overview screens, abilities, focuses, trees, nodes, ranks, effects, and costs.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 110
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/specialization/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/specialization/**": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_specializations.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-011.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-011. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 passed review. Read the full Specialization route, readiness code, Skill Chip capacity rules, mechanic contracts, and architecture documentation before editing.
|
|
||||||
|
|
||||||
Model overview screens as composite evidence. Preserve parent, coordinate, rank, effect, cost, cadence, charge, and intrinsic-damage uncertainty. Do not emit derived readiness or infer executable mechanics from prose. Link fragments only with positive identity evidence.
|
|
||||||
|
|
||||||
Test incomplete trees, scrolling, free Ability nodes, multi-rank nodes, modified Ability values, and ambiguous parents. Write `handoffs/GDC-011.md` and do not commit or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements utility-local SQLite migrations, content-addressed blobs, durable jobs, provenance, recovery, safe purge behavior, and approved GDC-005 corpus persistence.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 80
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/migrations/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/storage/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/jobs.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/storage/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/storage/**": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-003.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-003 persistence or the approved persistence phase of GDC-005. Refuse implementation unless the coordinator assigns one task, `STATUS.md` marks it `in_progress`, and its dependencies passed review. Read the frozen contracts and dependency handoffs before editing.
|
|
||||||
|
|
||||||
Keep the utility database separate from SHD.build. Use checksummed additive migrations, foreign keys, WAL, atomic blob installation, compare-and-swap transitions, and recoverable leases. Model capture occurrences separately from blob identity. Test crashes at transaction and filesystem boundaries.
|
|
||||||
|
|
||||||
For GDC-005, implement only the reviewed durable corpus-port operations described in its task and handoff. Do not edit domain contracts, pipeline policy, routes, or application database code. Write the assigned task handoff and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adds cross-subsystem integration, contract, recovery, synthetic-media, secret-scanning, and ISAC workflow tests without editing production code.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.3-codex-spark
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/tests/integration/test_end_to_end.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_crash_recovery.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_secret_exclusion.py": allow
|
|
||||||
"tools/game-data-capture/tests/fixtures/e2e/**": allow
|
|
||||||
"src/lib/isac/capture-intake/end-to-end.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-022.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-022. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and every dependency passed review. Add tests only after subsystem contracts and implementations exist. Do not repair production code; report each failure to its owner.
|
|
||||||
|
|
||||||
Use synthetic or approved fixtures. Keep proprietary screenshots and private corpus data out of Git. Test end-to-end state, restart recovery, provider failures, media bounds, numeric disagreements, secret exclusion, manager publication, Contributor Mode proposals, and canonical readback. Assert audit actor, category, action, target, and before/after relationship evidence. Ensure tests fail when intended behavior is removed.
|
|
||||||
|
|
||||||
Write `handoffs/GDC-022.md` and do not commit or push.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements bounded FFmpeg and ffprobe video ingestion, tiled stability analysis, sharp-frame selection, lineage, cancellation, and recovery.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/video/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/video/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_video_import.py": allow
|
|
||||||
"tools/game-data-capture/tests/fixtures/video/**": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-015.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-015. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 and GDC-013 passed review.
|
|
||||||
|
|
||||||
Run FFmpeg and ffprobe with argument arrays, private paths, limits, timeouts, bounded stderr, cancellation, and process-group cleanup. Extract no audio. Use tiled motion so animated backgrounds do not hide stable UI. Preserve parent hashes, timestamps, metrics, commands, and policy versions.
|
|
||||||
|
|
||||||
Do not edit web routes, runtime integration, OCR, or storage. Use generated media fixtures. Write `handoffs/GDC-015.md` and do not commit or push.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Benchmarks local GPU OCR and implements preprocessing, the persistent OCR worker, confidence calibration, and OCR evaluation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
external_directory: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/ocr/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/preprocess.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/ocr/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_preprocess.py": allow
|
|
||||||
"tools/game-data-capture/tests/gpu/**": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_ocr.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-007.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own the OCR phase of GDC-007. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and H2 passed. Do not select an OCR engine or threshold before the private corpus passes H2.
|
|
||||||
|
|
||||||
Measure exact numbers, labels, transcript accuracy, latency, VRAM, CUDA provider, model hashes, and confidence calibration. Keep raw OCR separate from normalized values. Never call a network service from local OCR.
|
|
||||||
|
|
||||||
Return dependency pin requests to `gdc-python-core`; do not edit lock files. Write the OCR section of `handoffs/GDC-007.md` and do not commit or push.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements niri and Wayland clipboard image intake, private spooling, and watcher process supervision.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/ingest/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/ingest/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_clipboard_watch.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-004.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-004. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and its dependencies passed review. Use the existing niri focused-window shortcut and `wl-paste`; do not automate game input.
|
|
||||||
|
|
||||||
Run child processes without a shell. Bound bytes, dimensions, stderr, queues, retries, and shutdown. Keep the clipboard helper small and remove OpenAI secrets from its environment. Request coordinator approval for live clipboard or compositor commands; never read clipboard text.
|
|
||||||
|
|
||||||
Do not edit storage, OCR, provider, video, or web code. Add failure tests, write `handoffs/GDC-004.md`, and do not commit or push.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements evidence-preserving extraction for weapons and Gear across Mask, Gloves, Backpack, Body Armor, Holster, and Knee Pads.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/weapon.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/gear.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_weapon.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_gear.py": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_weapon_gear.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-009.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"import-weapon-screenshot": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-009. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 passed review. Read the current Weapon and Gear actions, scaling documentation, and weapon screenshot import skill before editing.
|
|
||||||
|
|
||||||
Preserve displayed values as observations until evidence establishes canonical endpoints. Treat weapon Talent grids as complete unordered sets and mark cropped lists incomplete. Use exact Gear slot rules. Do not guess Talent, Brand, or Attribute IDs, hidden ranges, native tiers, mod slots, or compatibility pools.
|
|
||||||
|
|
||||||
Add per-slot and per-view regression tests. Write `handoffs/GDC-009.md` and do not commit or push.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements the loopback FastAPI service, authenticated local sessions, capture and review APIs, request bounds, and browser security.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/*.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/routes/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/web/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_capture_labeling.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_web_security.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_review_ui.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-014.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own the backend portion of GDC-005 or GDC-014. Refuse implementation unless the coordinator assigns one task, `STATUS.md` marks it `in_progress`, and its dependencies passed review. Bind only to loopback and defend against DNS rebinding, cross-origin mutation, CSRF, XSS, path traversal, oversized uploads, and unauthorized artifacts.
|
|
||||||
|
|
||||||
Use opaque IDs and escaped text. Keep paths, keys, cookies, database internals, and unrestricted OCR/provider content out of responses. Do not embed SQL in routes or duplicate domain policy.
|
|
||||||
|
|
||||||
Coordinate route contracts with `gdc-review-ui`, but do not edit templates or static assets. Add failure-path tests, update the assigned handoff, and do not commit or push.
|
|
||||||
@@ -69,11 +69,27 @@ The `.agents/skills/` directory contains repo-specific skill files. Key skills:
|
|||||||
|
|
||||||
Use `skill` tool to load one when the task matches.
|
Use `skill` tool to load one when the task matches.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Track issues in this repository's Gitea Issues using `tea`. See
|
||||||
|
`docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
Use the five canonical triage labels without overrides. See
|
||||||
|
`docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Use the single-context domain documentation layout. See `docs/agents/domain.md`.
|
||||||
|
|
||||||
## Verify commands
|
## Verify commands
|
||||||
|
|
||||||
Run in order after source changes:
|
Run in order after source changes:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
npm run check # svelte-kit sync + svelte-check (typecheck)
|
npm run check # svelte-kit sync + svelte-check (typecheck)
|
||||||
npm run build # secretless Vite production build
|
npm run build # secretless Vite production build
|
||||||
```
|
```
|
||||||
@@ -115,21 +131,39 @@ need database state use the embedded Turso engine.
|
|||||||
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
|
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
|
||||||
via 308.
|
via 308.
|
||||||
|
|
||||||
|
## Long-running development processes
|
||||||
|
|
||||||
|
Use the installed `pi-background-tasks` extension's `bg_run` tool for
|
||||||
|
development servers, watchers, and other persistent commands. Do not use
|
||||||
|
foreground shell sessions, `nohup`, `&`, or process-tree/PID discovery when a
|
||||||
|
tracked task exists.
|
||||||
|
|
||||||
|
- Use `isAgent: false` and a concise, descriptive task name.
|
||||||
|
- Retain the returned task ID and output path. Inspect a task with `bg_status`
|
||||||
|
and read its output with `bg_logs`.
|
||||||
|
- Stop a task with `bg_kill <task-id>`. To restart it, start a new `bg_run` task
|
||||||
|
and retain its new task ID and output path; do not search for and kill
|
||||||
|
processes.
|
||||||
|
- Do not leave duplicate tracked tasks for the same development service.
|
||||||
|
|
||||||
## Development server lifecycle
|
## Development server lifecycle
|
||||||
|
|
||||||
After changing source code, styles, configuration, dependencies, or routes,
|
After changing source code, styles, configuration, dependencies, or routes,
|
||||||
restart the development server before handing work back.
|
restart the development server before handing work back.
|
||||||
|
|
||||||
1. Inspect running processes and stop only this project's development-server
|
1. Start `npm run dev` from the repository root with `bg_run`, using
|
||||||
process tree.
|
`isAgent: false` and a descriptive task name. Retain its task ID and output
|
||||||
2. Start one hidden `npm run dev` process from the repository root. Do not leave
|
path.
|
||||||
duplicate project development servers running.
|
2. Before starting a replacement, stop the existing tracked development-server
|
||||||
3. Confirm the server accepts an HTTP request before completing the task.
|
task with `bg_kill <task-id>` so only one server runs.
|
||||||
4. Leave the verified development server running so the project owner can review
|
3. Confirm the task logs show the server started, then confirm it accepts an
|
||||||
the changes.
|
HTTP request before completing the task.
|
||||||
|
4. Leave the verified tracked task running so the project owner can review the
|
||||||
|
changes.
|
||||||
|
|
||||||
Database maintenance may require the server to remain stopped while the embedded
|
Database maintenance may require the server to remain stopped while the embedded
|
||||||
database is locked. Restart and verify the server after maintenance completes.
|
database is locked. Stop its tracked task first, then restart and verify it
|
||||||
|
after maintenance completes.
|
||||||
|
|
||||||
## SHD design-system maintenance
|
## SHD design-system maintenance
|
||||||
|
|
||||||
@@ -245,7 +279,7 @@ database. The command refuses to merge into a populated database.
|
|||||||
|
|
||||||
## Development database maintenance
|
## Development database maintenance
|
||||||
|
|
||||||
1. Stop only this project's development-server process tree.
|
1. Stop this project's tracked development-server task with `bg_kill <task-id>`.
|
||||||
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
||||||
3. Apply the migration or maintenance operation. Verify schema state, record
|
3. Apply the migration or maintenance operation. Verify schema state, record
|
||||||
counts, and critical entities.
|
counts, and critical entities.
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
# SHD.build Domain
|
||||||
|
|
||||||
|
SHD.build is an independent community intelligence platform for _The Division
|
||||||
|
Resurgence_. This glossary defines the language used for player planning, shared
|
||||||
|
content, Game Data, governance, equipment, progression, and modeled mechanics.
|
||||||
|
|
||||||
|
## Product and people
|
||||||
|
|
||||||
|
**SHD.build**: The community platform described by this glossary. Always
|
||||||
|
capitalize `SHD` and write the product name as `SHD.build`. _Avoid_: SHD Build,
|
||||||
|
SHD Builder
|
||||||
|
|
||||||
|
**Agent**: Any person using SHD.build, whether anonymous or authenticated.
|
||||||
|
_Avoid_: User, visitor, player when referring to product access
|
||||||
|
|
||||||
|
**Anonymous Agent**: An Agent who is not signed in to SHD.build. _Avoid_:
|
||||||
|
Visitor, guest
|
||||||
|
|
||||||
|
**Registered Agent**: An Agent with an SHD.build account. _Avoid_: Registered
|
||||||
|
user, member
|
||||||
|
|
||||||
|
**Modeling Session**: An interactive agentic work session that prepares one
|
||||||
|
Mechanic Program at a time for a Registered Agent to confirm in chat before the
|
||||||
|
session submits it through an existing ISAC action. _Avoid_: Agent, Modeling
|
||||||
|
Client, bulk job
|
||||||
|
|
||||||
|
**Contributor**: A Registered Agent who has accepted the contributor guidelines
|
||||||
|
and may submit proposed Game Data changes for review. _Avoid_: Editor when the
|
||||||
|
Agent cannot change Canonical Game Data directly
|
||||||
|
|
||||||
|
**Operator**: A Registered Agent performing an authorized task in ISAC.
|
||||||
|
Contributor, reviewer, moderator, and administrator authority remain distinct
|
||||||
|
from this situational term. _Avoid_: Admin as a generic name for anyone using
|
||||||
|
ISAC
|
||||||
|
|
||||||
|
**Oversight**: The human review function for proposed Game Data, moderated
|
||||||
|
Community Content, and identity assets. Use the qualified names Data Oversight,
|
||||||
|
Community Oversight, Guide Oversight, and Avatar Oversight when the review area
|
||||||
|
matters. _Avoid_: Moderation as a name for every review function
|
||||||
|
|
||||||
|
## Planning and shared content
|
||||||
|
|
||||||
|
**Builder**: The planning tool an Agent uses to configure a Loadout and inspect
|
||||||
|
supported calculations. _Avoid_: Build when referring to the tool
|
||||||
|
|
||||||
|
**Loadout**: A complete planned character configuration, including selected
|
||||||
|
Equipment, progression choices, and combat assumptions. A Loadout may exist
|
||||||
|
without becoming a saved Build. _Avoid_: Build when persistence, ownership, or
|
||||||
|
sharing matters
|
||||||
|
|
||||||
|
**Loadout Draft**: An editable Loadout that has not become a saved Build.
|
||||||
|
Anonymous and Registered Agents may both work with drafts. _Avoid_: Build draft
|
||||||
|
|
||||||
|
**Build**: A saved, owned, and shareable planning artifact whose central content
|
||||||
|
is a Loadout. A Build may be Private, Unlisted, or Public and may derive from
|
||||||
|
another Build. _Avoid_: Loadout when persistence, ownership, or sharing matters
|
||||||
|
|
||||||
|
**Guide**: A revisioned long-form community article that may include snapshots
|
||||||
|
of one or more Builds. A Guide is prose-led content rather than a Loadout
|
||||||
|
container. _Avoid_: Build guide when referring to every Guide
|
||||||
|
|
||||||
|
**News Article**: A revisioned editorial or syndicated article published through
|
||||||
|
the News channel. _Avoid_: Guide, post
|
||||||
|
|
||||||
|
**Article Revision**: An immutable edition of a Guide or News Article. Editors
|
||||||
|
select one revision for publication while later revisions may await review.
|
||||||
|
_Avoid_: Version when referring to published article history
|
||||||
|
|
||||||
|
**Community Content**: Agent-authored comments, profile biographies, custom
|
||||||
|
avatars, and Guides governed by the shared moderation lifecycle. _Avoid_:
|
||||||
|
User-generated content, UGC
|
||||||
|
|
||||||
|
**Community Note**: A visible comment that Community Oversight pins to a Public
|
||||||
|
Database discussion. It remains Community Content and follows the same removal
|
||||||
|
rules as other comments. _Avoid_: Official correction, staff note
|
||||||
|
|
||||||
|
**Content Report**: An Agent's request for Oversight to review visible Community
|
||||||
|
Content, with no immediate change to the content's visibility. _Avoid_: Takedown
|
||||||
|
request
|
||||||
|
|
||||||
|
**Public Database**: The Agent-facing reference view of Canonical Game Data.
|
||||||
|
_Avoid_: Database when the term could mean application storage
|
||||||
|
|
||||||
|
## Game Data and governance
|
||||||
|
|
||||||
|
**Game Data**: Community-maintained structured facts about _The Division
|
||||||
|
Resurgence_ used by the Public Database, Builder, Builds, and contribution
|
||||||
|
workflow. _Avoid_: Source data, builder data, reference data
|
||||||
|
|
||||||
|
**Canonical Game Data**: The authoritative, approved body of Game Data. _Avoid_:
|
||||||
|
Live data, production data
|
||||||
|
|
||||||
|
**Canonical Record**: One authoritative record within Canonical Game Data.
|
||||||
|
_Avoid_: Approved proposal
|
||||||
|
|
||||||
|
**Contribution Proposal**: A pending request from a Contributor to create or
|
||||||
|
change Canonical Game Data. Approval still requires the proposed change to
|
||||||
|
satisfy the canonical rules for its record family. _Avoid_: Draft record,
|
||||||
|
pending record
|
||||||
|
|
||||||
|
**ISAC**: The operational console for Game Data, contribution review, community
|
||||||
|
governance, identity and access management, audit history, and system
|
||||||
|
operations. _Avoid_: Admin panel, calculation engine
|
||||||
|
|
||||||
|
**Registry**: An ISAC workspace containing one family of related records and its
|
||||||
|
management controls. _Avoid_: Table, list page
|
||||||
|
|
||||||
|
**Audit Event**: An immutable account of a consequential operation or review
|
||||||
|
decision, including the actor, target, and change evidence. _Avoid_: Audit-log
|
||||||
|
row, activity log
|
||||||
|
|
||||||
|
**Compensating Reversion**: A new operation that restores an earlier state while
|
||||||
|
preserving the original Audit Event and recording the reversal. _Avoid_:
|
||||||
|
Rollback, undo when either implies rewriting history
|
||||||
|
|
||||||
|
## Equipment and progression
|
||||||
|
|
||||||
|
**Equipment**: The items and enhancements selected for a Loadout, including
|
||||||
|
Weapons, Gear, Weapon Mods, Gear Mods, Skill Chips, and an OS Protocol. _Avoid_:
|
||||||
|
Item when the equipment relationship matters
|
||||||
|
|
||||||
|
**Gear**: Equippable protective equipment assigned to a character slot. Gear may
|
||||||
|
provide core values, Attributes, a Brand Set, a Talent, and augmentation
|
||||||
|
capacity. _Avoid_: Armor as a collective name for all Gear
|
||||||
|
|
||||||
|
**Body Armor**: The canonical name for the equippable torso Gear slot. _Avoid_:
|
||||||
|
Vest
|
||||||
|
|
||||||
|
**Weapon**: An equippable firearm with a Weapon Category, damage and handling
|
||||||
|
properties, progression values, and applicable Talents and Mods. _Avoid_: Gun
|
||||||
|
when referring to the modeled record
|
||||||
|
|
||||||
|
**Weapon Category**: The classification that groups Weapons as Shotgun, Rifle,
|
||||||
|
MMR, AR, LMG, or SMG. _Avoid_: Weapon type, weapon family
|
||||||
|
|
||||||
|
**Weapon Mod**: An enhancement fitted to a compatible Weapon slot. Regular and
|
||||||
|
Exotic Weapon Mods follow different selection rules. _Avoid_: Attachment
|
||||||
|
|
||||||
|
**Gear Mod**: An enhancement fitted to Gear according to the Gear's available
|
||||||
|
mod capacity. _Avoid_: Tactical Augment
|
||||||
|
|
||||||
|
**Tactical Augment**: A numbered augmentation position recorded on selected Gear
|
||||||
|
in a Loadout. _Avoid_: Gear Mod
|
||||||
|
|
||||||
|
**Attribute**: A named measurable game property with defined units and
|
||||||
|
calculation meaning. _Avoid_: Stat when referring to the canonical Attribute
|
||||||
|
catalog
|
||||||
|
|
||||||
|
**Core Attribute**: One of the three primary character alignments: Firepower,
|
||||||
|
Toughness, or Engineering. _Avoid_: Main stat, primary stat
|
||||||
|
|
||||||
|
**Brand Set**: A Gear identity whose bonuses depend on how many pieces from that
|
||||||
|
set the Loadout equips. _Avoid_: Gear set
|
||||||
|
|
||||||
|
**Quality**: The item classification Standard, Specialized, Superior, or
|
||||||
|
High-end. Quality governs available or scaled properties for applicable
|
||||||
|
Equipment. _Avoid_: Rarity
|
||||||
|
|
||||||
|
**Exotic**: A special scope used by certain Weapons, Talents, and Weapon Mods.
|
||||||
|
Exotic is not a fifth Quality. _Avoid_: Exotic quality
|
||||||
|
|
||||||
|
**Item Level**: An Equipment progression coordinate used to resolve
|
||||||
|
level-dependent values. _Avoid_: Tier, Quality
|
||||||
|
|
||||||
|
**Equipment Tier**: The T0, T1, or T2 progression band that controls Equipment
|
||||||
|
capabilities and values. _Avoid_: Talent Tier, Item Level
|
||||||
|
|
||||||
|
**Talent Tier**: The Tier I or Tier II value variant of a Talent. _Avoid_:
|
||||||
|
Equipment Tier, T1, T2 when the distinction could be unclear
|
||||||
|
|
||||||
|
**Reference Level**: The Item Level at which an observed value or range applies.
|
||||||
|
_Avoid_: Base level
|
||||||
|
|
||||||
|
**Hidden Base**: A level-independent value inferred from observed Equipment
|
||||||
|
values and the game's progression rules. _Avoid_: Displayed Value, Raw Value
|
||||||
|
|
||||||
|
**Raw Value**: A calculated value before the presentation rules that reproduce
|
||||||
|
the game's displayed number. _Avoid_: Hidden Base, Displayed Value
|
||||||
|
|
||||||
|
**Displayed Value**: The player-visible number produced after applying the
|
||||||
|
game's presentation rules to a Raw Value. _Avoid_: Raw Value
|
||||||
|
|
||||||
|
**OS Protocol**: A single equipped progression item with a Quality, Core
|
||||||
|
Attribute alignment, primary effect, inherited secondary profile, and
|
||||||
|
upgradeable Talent. _Avoid_: Protocol when the operating-system meaning could be
|
||||||
|
inferred
|
||||||
|
|
||||||
|
**Skill**: A selectable combat capability associated with a Specialization.
|
||||||
|
Skills may own Skill Chip Sets and may have explicit compatibility relationships
|
||||||
|
with another Skill. _Avoid_: Ability or Specialization Ability for the
|
||||||
|
selectable game concept
|
||||||
|
|
||||||
|
**Signature Skill**: A Specialization's defining Skill, distinguished from its
|
||||||
|
other selectable Skills. _Avoid_: Signature ability
|
||||||
|
|
||||||
|
**Skill Chip**: An Equipment selection that modifies the Skill occupying its
|
||||||
|
Loadout position. Skill Chips belong to a Skill Chip Set and may provide
|
||||||
|
primary, secondary, and set effects. _Avoid_: Chip when the equipment family is
|
||||||
|
unclear
|
||||||
|
|
||||||
|
**Skill Chip Set**: A reusable Skill Chip definition associated with a Skill,
|
||||||
|
including its set bonus and optional Talent. An explicit compatibility
|
||||||
|
relationship may let another Skill equip the set. _Avoid_: Skill set
|
||||||
|
|
||||||
|
**Specialization**: A character progression identity containing Focuses, Skills,
|
||||||
|
and progression choices. _Avoid_: Class
|
||||||
|
|
||||||
|
**Focus**: A path within a Specialization aligned to one Core Attribute and
|
||||||
|
defining a selection of Skills and progression choices. _Avoid_: Subclass,
|
||||||
|
specialization path
|
||||||
|
|
||||||
|
**Talent**: A named conditional behavior owned by its source, such as a Weapon,
|
||||||
|
Gear item, OS Protocol, or Skill Chip Set. Different Talent sources may use
|
||||||
|
different progression variants. _Avoid_: Perk, passive
|
||||||
|
|
||||||
|
## Modeled mechanics
|
||||||
|
|
||||||
|
**Mechanic Program**: The structured, source-owned description of a Talent or
|
||||||
|
other conditional behavior. It captures the conditions, state changes, and
|
||||||
|
outcomes needed to evaluate that behavior. _Avoid_: Script, Trigger record
|
||||||
|
|
||||||
|
**Event**: An observed occurrence on a combat timeline that a Mechanic Program
|
||||||
|
may inspect. _Avoid_: Trigger when referring to the occurrence itself
|
||||||
|
|
||||||
|
**Trigger Condition**: The criteria that an Event must satisfy before a mechanic
|
||||||
|
can proceed toward Activation. A Trigger Condition is part of a Mechanic
|
||||||
|
Program, not a standalone Game Data record. _Avoid_: Trigger as an entity
|
||||||
|
|
||||||
|
**Activation**: A successful mechanic occurrence after its Event, conditions,
|
||||||
|
chance, and Cooldown requirements resolve. _Avoid_: Proc when precision matters
|
||||||
|
|
||||||
|
**Cooldown**: The period after a successful Activation during which the same
|
||||||
|
mechanic cannot activate again. A failed chance does not begin the Cooldown.
|
||||||
|
_Avoid_: Delay
|
||||||
|
|
||||||
|
**Mechanic State**: A value retained across Events for one mechanic, such as a
|
||||||
|
flag, count, stack, duration, mark, or resource. _Avoid_: Runtime state when
|
||||||
|
discussing game behavior
|
||||||
|
|
||||||
|
**Outcome**: The effect emitted by an activated mechanic, such as an Attribute
|
||||||
|
change, damage, restoration, resource change, or marker. _Avoid_: Effect when
|
||||||
|
the source Talent and emitted result could be confused
|
||||||
|
|
||||||
|
**Combat Profile**: The encounter assumptions and observed rates used to project
|
||||||
|
a Loadout's combat performance. _Avoid_: Simulation when no event simulation
|
||||||
|
occurs
|
||||||
|
|
||||||
|
**Scenario Input**: An explicit encounter assumption required to evaluate a
|
||||||
|
mechanic whose result cannot be determined from the Loadout alone. _Avoid_:
|
||||||
|
Guess, default assumption
|
||||||
|
|
||||||
|
**Ready**: The mechanic readiness state indicating that typed Game Data can
|
||||||
|
evaluate the mechanic without additional Scenario Inputs. _Avoid_: Complete
|
||||||
|
|
||||||
|
**Requires Assumptions**: The mechanic readiness state indicating that
|
||||||
|
evaluation needs one or more explicit Scenario Inputs. _Avoid_: Ready,
|
||||||
|
unsupported
|
||||||
|
|
||||||
|
**Captured Only**: The mechanic readiness state indicating that SHD.build
|
||||||
|
preserves the authored behavior but cannot calculate it. _Avoid_: Unsupported
|
||||||
|
when the behavior has still been recorded
|
||||||
|
|
||||||
|
**Extra Health**: Temporary Health granted in addition to an Agent's normal
|
||||||
|
Health. Each grant remains distinct from later changes to the current Extra
|
||||||
|
Health total. _Avoid_: Health, healing, bonus armor
|
||||||
|
|
||||||
|
**Hit Taken**: A hit received by an Agent, including one absorbed by Extra
|
||||||
|
Health. A Hit Taken does not require normal Health loss. _Avoid_: Taking Damage
|
||||||
|
|
||||||
|
**Taking Damage**: A loss of an Agent's normal Health. A hit absorbed by Extra
|
||||||
|
Health does not count as Taking Damage. _Avoid_: Hit Taken
|
||||||
@@ -1422,6 +1422,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
|
|||||||
CHECK(charge_policy<>'none' OR charge_count=1)
|
CHECK(charge_policy<>'none' OR charge_count=1)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
||||||
|
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
|
||||||
|
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||||
|
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY(ability_id,compatible_ability_id),
|
||||||
|
CHECK(ability_id<compatible_ability_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
|
||||||
|
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
|
||||||
|
-- db-migrate.mjs installs insert/update triggers for active same-specialization
|
||||||
|
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
|
||||||
|
-- this semicolon-delimited schema file.
|
||||||
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: shd-build-production
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${SHD_IMAGE:-shd-build-production:local}
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
image: ${SHD_IMAGE:-shd-build-production:local}
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
init: true
|
init: true
|
||||||
command:
|
command:
|
||||||
- scheduler
|
- scheduler
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ describe('container runtime startup', () => {
|
|||||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||||
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||||
|
{
|
||||||
|
key: 'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
|
value: 'totp-encryption-keyring',
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
writeExport(sharedExport, [
|
writeExport(sharedExport, [
|
||||||
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
||||||
@@ -220,6 +224,7 @@ describe('container runtime startup', () => {
|
|||||||
APP_ORIGIN: 'https://stage.shd.build',
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS: 'totp-encryption-keyring',
|
||||||
ORIGIN: 'https://stage.shd.build',
|
ORIGIN: 'https://stage.shd.build',
|
||||||
PASSKEY_RP_ID: 'shd.build',
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
SCHEDULER_TOKEN: schedulerToken,
|
SCHEDULER_TOKEN: schedulerToken,
|
||||||
|
|||||||
+91
-38
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
|
|||||||
### Command Grid builder
|
### Command Grid builder
|
||||||
|
|
||||||
The production builder uses Command Grid as its loadout workbench. Keep the
|
The production builder uses Command Grid as its loadout workbench. Keep the
|
||||||
entire build visible in a dense matrix while one connected editor configures the
|
entire build visible in a compact matrix while one connected editor configures
|
||||||
selected cell. The full-width Specialization and Focus cell is the root choice.
|
the selected cell. The editor uses two lanes when its container has enough
|
||||||
Its three supplied abilities appear directly below it, followed by two weapons,
|
width. Equipment identity and base values occupy the first lane; allocations,
|
||||||
six gear positions, and one full-width OS Protocol.
|
rolls, Talents, and upgrades occupy the second. The lanes stack when either
|
||||||
|
would become too narrow. The full-width Specialization and Focus cell is the
|
||||||
|
root choice. Its three supplied abilities appear directly below it, followed by
|
||||||
|
two weapons, six gear positions, and one full-width OS Protocol.
|
||||||
|
|
||||||
Matrix cells summarize selected Talent names, Attribute names and values, Brand
|
Matrix cells show the slot and equipped item at rest. The selected cell reveals
|
||||||
Sets, or Skill Chips. Do not replace those choices with configuration counts.
|
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
|
||||||
Selecting an ability opens its three attached Skill Chip positions. Selecting
|
progressive disclosure keeps the matrix scannable without replacing configured
|
||||||
equipment opens the searchable real-data catalog and its compatible
|
choices with counts. Selecting an ability opens its three attached Skill Chip
|
||||||
configuration controls. Do not display a prototype option that the current
|
positions. Selecting equipment opens the searchable real-data catalog and its
|
||||||
loadout schema cannot save.
|
compatible configuration controls. Large Talent pools show the selected Talent
|
||||||
|
at rest and open their search grid through an explicit Change action. Do not
|
||||||
|
display a prototype option that the current loadout schema cannot save.
|
||||||
|
|
||||||
|
Builder cells use one leading icon track. Specializations, abilities, Skill
|
||||||
|
Chips, and OS Protocols use registered artwork; weapons and gear use static
|
||||||
|
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
|
||||||
|
the icon beside the set name and quality so empty and configured slots share one
|
||||||
|
scan path. Visible builder labels stay short; full validation text remains
|
||||||
|
available through the cell's accessible name or title.
|
||||||
|
|
||||||
|
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
|
||||||
|
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
|
||||||
|
warm recolor so players can distinguish the pair before reading the labels.
|
||||||
|
Every tile retains `T1` or `T2`, its full tier name, and selected or unavailable
|
||||||
|
text. Disable Tier II when the canonical Talent has no authored Tier II data.
|
||||||
|
Weapon equipment tier and Talent tier are independent selections.
|
||||||
|
|
||||||
Equipment readiness belongs at the point of resolution. Mark an incomplete
|
Equipment readiness belongs at the point of resolution. Mark an incomplete
|
||||||
matrix cell with a compact red text indicator and status rail, then let the cell
|
matrix cell with a compact red text indicator and status rail, then let the cell
|
||||||
@@ -361,20 +380,19 @@ and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
|||||||
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
||||||
current player errors.
|
current player errors.
|
||||||
|
|
||||||
The builder uses three workbench columns on wide screens, moves the live summary
|
The builder uses two primary workbench columns on wide screens: the compact
|
||||||
below the matrix and editor at intermediate widths, and stacks all three regions
|
matrix and the two-lane editor. The live summary spans both columns below them.
|
||||||
on narrow screens. Every grid track and editor child must accept content
|
Narrow screens stack the matrix, editor, and summary. Every grid track and
|
||||||
shrinkage. At 560px and below, stack attribute picker/value pairs and section
|
editor child must accept content shrinkage. At 560px and below, stack Attribute
|
||||||
headings; keep the command actions in two equal columns. Validation markers use
|
picker/value pairs and section headings; keep the command actions in two equal
|
||||||
short visible labels while their full messages remain available to assistive
|
columns. Validation markers use short visible labels while their full messages
|
||||||
technology and pointer users.
|
remain available to assistive technology and pointer users.
|
||||||
|
|
||||||
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
||||||
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
||||||
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
||||||
feedback, and textual readiness states. At narrower desktop widths, keep the
|
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
|
||||||
matrix and editor together and move live results below them. On mobile, stack
|
live results in that order.
|
||||||
the matrix, editor, and live results in that order.
|
|
||||||
|
|
||||||
The command header contains the required build name, local and account save
|
The command header contains the required build name, local and account save
|
||||||
actions, account visibility, and optional sharing notes. Sharing notes include a
|
actions, account visibility, and optional sharing notes. Sharing notes include a
|
||||||
@@ -2283,6 +2301,13 @@ active abilities, each ability has exactly three active Skill Chip definitions,
|
|||||||
tree rows are contiguous, at least one paid node exists, and the tree uses the
|
tree rows are contiguous, at least one paid node exists, and the tree uses the
|
||||||
configured available point total.
|
configured available point total.
|
||||||
|
|
||||||
|
The ability editor may pair the open ability with one active ability from the
|
||||||
|
same specialization for Skill Chip sharing. Present this as an explicit optional
|
||||||
|
relationship, exclude the current ability, and disable abilities that already
|
||||||
|
belong to another pair. Do not infer compatibility from names or Focus
|
||||||
|
membership. Saving either side updates the shared pair and includes it in the
|
||||||
|
ability audit snapshot. Archiving either ability clears the pair.
|
||||||
|
|
||||||
Compose focus identity from uploaded specialization artwork and the canonical
|
Compose focus identity from uploaded specialization artwork and the canonical
|
||||||
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
|
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
|
||||||
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
|
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
|
||||||
@@ -2365,9 +2390,11 @@ display `ADVANCED`, and stay visible in the generated script. State rows expose
|
|||||||
label, agent or target scope, initial value, bounds, best-case value, and
|
label, agent or target scope, initial value, bounds, best-case value, and
|
||||||
optional expiry. A state change that writes a target pool uses a `STATE OWNER`
|
optional expiry. A state change that writes a target pool uses a `STATE OWNER`
|
||||||
segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner
|
segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner
|
||||||
remains visible in the generated script. Outcome rows expose kind, target scope,
|
remains visible in the generated script. The `DERIVED VALUE` operation writes
|
||||||
Attribute or resource when applicable, value basis, state scaling, phase, and
|
the branch reducer's numeric result into the selected pool and displays that
|
||||||
every source-specific value column. The Value Model control supports static
|
link as a text readout instead of a numeric input. Outcome rows expose kind,
|
||||||
|
target scope, Attribute or resource when applicable, value basis, state scaling,
|
||||||
|
phase, and every source-specific value column. The Value Model control supports
|
||||||
values, live Attribute totals, percentage-normalized Attribute values, event
|
values, live Attribute totals, percentage-normalized Attribute values, event
|
||||||
fields, reducer output, source-defined scenario controls, and OS Protocol
|
fields, reducer output, source-defined scenario controls, and OS Protocol
|
||||||
upgrade scaling where the source permits it. Selecting either Attribute model
|
upgrade scaling where the source permits it. Selecting either Attribute model
|
||||||
@@ -2379,6 +2406,15 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
|
|||||||
field and describes the configured value as the total delivered across that
|
field and describes the configured value as the total delivered across that
|
||||||
duration; switching back to instant clears the duration.
|
duration; switching back to instant clears the duration.
|
||||||
|
|
||||||
|
Selecting `DERIVE A SCALAR VALUE` exposes a `DERIVED VALUE SOURCE`
|
||||||
|
`SegmentedChoice`. `STATIC` shows the numeric value input. `EVENT FIELD` shows
|
||||||
|
the numeric fields registered for the branch event, including the proximity
|
||||||
|
snapshot's allied-Agent count; it never asks the author to type a field path.
|
||||||
|
Keep an existing expression that falls outside these manual patterns visible as
|
||||||
|
disabled `ADVANCED` data. The source and field controls retain `SegmentedChoice`
|
||||||
|
radio semantics, visible legends, arrow-key operation, focus-visible treatment,
|
||||||
|
and text labels.
|
||||||
|
|
||||||
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
||||||
Targeting control. Selecting it creates an event-target-centered enemy area with
|
Targeting control. Selecting it creates an event-target-centered enemy area with
|
||||||
a five-meter starting radius, reveals the required radius field, and shows a
|
a five-meter starting radius, reveals the required radius field, and shows a
|
||||||
@@ -2396,13 +2432,15 @@ source program, where authors may edit its exposed branch, state, and outcome
|
|||||||
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
||||||
an inline confirmation that states all existing branches will be discarded.
|
an inline confirmation that states all existing branches will be discarded.
|
||||||
|
|
||||||
Builder Controls define source-specific scenario assumptions for the planned
|
Builder Controls define source-specific scenario assumptions for player
|
||||||
player simulation. ISAC authors choose toggle, slider, or numeric input and
|
simulation. ISAC authors choose toggle, slider, or numeric input and provide a
|
||||||
provide a label, Agent guidance, default value, best-case value, and numeric
|
label, Agent guidance, default value, best-case value, and numeric bounds when
|
||||||
bounds when applicable. The current player UI does not render these controls or
|
applicable. The calculation timeline builder can consume values supplied for
|
||||||
read their saved values. It uses an explicit `COMING SOON` panel for the future
|
these controls, including `allies-within-30m-with-agent-granted-buff`. Current
|
||||||
advanced scenario surface. The authored contract still requires the runtime to
|
Builder and shared Build pages do not render the controls or let players change
|
||||||
report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
those values. They use an explicit `COMING SOON` panel for the future advanced
|
||||||
|
scenario surface. The authored contract still requires the runtime to report
|
||||||
|
`REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
||||||
missing; the editor and runtime never invent a cadence.
|
missing; the editor and runtime never invent a cadence.
|
||||||
|
|
||||||
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
||||||
@@ -2453,7 +2491,10 @@ The panel presents five explicit states:
|
|||||||
polite live region, and disables repeat actions.
|
polite live region, and disables repeat actions.
|
||||||
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
|
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
|
||||||
bounded answer control. The author may answer, revise the source text, or
|
bounded answer control. The author may answer, revise the source text, or
|
||||||
return to manual authoring.
|
return to manual authoring. For an unresolved numbered numeric placeholder,
|
||||||
|
show a required clarification for every source value column whose number is
|
||||||
|
missing. Do not prefill, infer, or suggest a percentage. Keep
|
||||||
|
`APPLY TO EDITOR` unavailable until the author answers each required variant.
|
||||||
- `CAPABILITY GAP` names the unsupported behavior and displays the developer
|
- `CAPABILITY GAP` names the unsupported behavior and displays the developer
|
||||||
handoff, supported approximation, and optional workaround in separate labeled
|
handoff, supported approximation, and optional workaround in separate labeled
|
||||||
blocks. Do not present a capability gap as validation failure.
|
blocks. Do not present a capability gap as validation failure.
|
||||||
@@ -2477,6 +2518,13 @@ applying the draft would replace configured branches, pools, controls, or
|
|||||||
outcomes. The standard ISAC save lifecycle begins only after the author reviews
|
outcomes. The standard ISAC save lifecycle begins only after the author reviews
|
||||||
and saves the resulting form.
|
and saves the resulting form.
|
||||||
|
|
||||||
|
An applied AI draft stores each clarified number in the corresponding
|
||||||
|
human-facing outcome-variant value. A scaling expression may retain the
|
||||||
|
draft-only `variant_value` reference so later saves preserve the link to that
|
||||||
|
displayed value. The runtime adapter resolves the reference separately for each
|
||||||
|
variant. Show the value and generated formula in the ordinary review controls;
|
||||||
|
do not require an author to inspect or edit raw JSON.
|
||||||
|
|
||||||
Use text labels with status colors. Orange marks Generate, Answer, and Apply
|
Use text labels with status colors. Orange marks Generate, Answer, and Apply
|
||||||
actions. Blue identifies modeled information, yellow marks assumptions or
|
actions. Blue identifies modeled information, yellow marks assumptions or
|
||||||
unsupported coverage, and red marks request or validation failures. Never use
|
unsupported coverage, and red marks request or validation failures. Never use
|
||||||
@@ -2575,7 +2623,7 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
|||||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
||||||
panels.
|
panels.
|
||||||
|
|
||||||
Loadout schema version 5 retains `maximum`, `custom`, conditional state,
|
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
|
||||||
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
||||||
prototype data rather than a compatibility format. The current player UI ignores
|
prototype data rather than a compatibility format. The current player UI ignores
|
||||||
the simulation fields. When player simulation ships, one required two-option
|
the simulation fields. When player simulation ships, one required two-option
|
||||||
@@ -2630,12 +2678,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
|
|||||||
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
||||||
Its identity combines Specialization artwork and the primary Attribute mark.
|
Its identity combines Specialization artwork and the primary Attribute mark.
|
||||||
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
||||||
shows definitions assigned to that ability and excludes definitions assigned
|
shows the ability's three definitions. When ISAC explicitly pairs that ability
|
||||||
elsewhere. Skill Chips have no item-level control. The editor uses the shared
|
with one compatible ability, the picker also shows the partner's three
|
||||||
quality profiles for direct rolls. It labels the four ability-scoped primary
|
definitions and labels shared entries. The relationship is bidirectional and
|
||||||
values `NOT YET DEFINED` and excludes them from current Agent and combat totals.
|
does not apply to other abilities in the same specialization. Players still
|
||||||
Secondary capacity is Standard zero, Specialized one, Superior two, and High-end
|
equip three chips in each ability panel. Skill Chips have no item-level control.
|
||||||
two; each equipped chip uses distinct selections from the shared pool.
|
The editor uses the shared quality profiles for direct rolls. It labels the four
|
||||||
|
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
|
||||||
|
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
|
||||||
|
target the ability represented by the panel. Secondary capacity is Standard
|
||||||
|
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
|
||||||
|
distinct selections from the shared pool.
|
||||||
|
|
||||||
The focus tree groups nodes by row and uses segmented rank controls. Disabled
|
The focus tree groups nodes by row and uses segmented rank controls. Disabled
|
||||||
ranks remain visible with their lock state. The builder permits a row after the
|
ranks remain visible with their lock state. The builder permits a row after the
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ Use these event boundaries:
|
|||||||
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
||||||
tick as a separate event.
|
tick as a separate event.
|
||||||
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
||||||
received while the Agent already has Extra Health. Use
|
received while the Agent already has Extra Health. Use `extra_health_granted`
|
||||||
`extra_health_granted` for the grant action.
|
for the grant action.
|
||||||
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
||||||
it when the Agent receives a grant, when the remaining amount changes, and
|
it when the Agent receives a grant, when the remaining amount changes, and
|
||||||
when it is depleted to zero. Every event requires the boolean
|
when it is depleted to zero. Every event requires the boolean
|
||||||
@@ -35,8 +35,8 @@ Use these event boundaries:
|
|||||||
`values.currentExtraHealth`, which must agree with that status. This state
|
`values.currentExtraHealth`, which must agree with that status. This state
|
||||||
event is distinct from the `extra_health_granted` grant action.
|
event is distinct from the `extra_health_granted` grant action.
|
||||||
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
||||||
round to load. A canceled reload does not count. A weapon that loads one
|
round to load. A canceled reload does not count. A weapon that loads one round
|
||||||
round at a time produces one completed-reload event for each round.
|
at a time produces one completed-reload event for each round.
|
||||||
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
||||||
not count as taking damage.
|
not count as taking damage.
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
|
|||||||
|
|
||||||
- The benefit remains active while the Agent has at least one persistent
|
- The benefit remains active while the Agent has at least one persistent
|
||||||
deployed Skill object.
|
deployed Skill object.
|
||||||
- Destruction or timed expiration removes that object from the active count.
|
- Destruction or timed expiration removes that object from the active count. The
|
||||||
The benefit ends when no qualifying objects remain.
|
benefit ends when no qualifying objects remain.
|
||||||
- Instant-use Skills do not qualify.
|
- Instant-use Skills do not qualify.
|
||||||
- Multiple deployed Skill objects do not multiply the benefit.
|
- Multiple deployed Skill objects do not multiply the benefit.
|
||||||
|
|
||||||
@@ -75,10 +75,28 @@ meters:
|
|||||||
|
|
||||||
- Moving within 30 meters of an ally who is already downed qualifies.
|
- Moving within 30 meters of an ally who is already downed qualifies.
|
||||||
- Each qualifying presence refreshes the ten-second duration.
|
- Each qualifying presence refreshes the ten-second duration.
|
||||||
- Leaving the radius or reviving the ally does not cancel the current
|
- Leaving the radius or reviving the ally does not cancel the current ten-second
|
||||||
ten-second duration.
|
duration.
|
||||||
- The wearer does not count as an ally.
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
|
## Close and Personal
|
||||||
|
|
||||||
|
Close and Personal uses a proximity-snapshot qualifying set:
|
||||||
|
|
||||||
|
- Include each distinct allied Agent whose distance from the source Agent is
|
||||||
|
less than or equal to 30 meters and who has at least one buff granted by that
|
||||||
|
source Agent.
|
||||||
|
- Count an ally once when several of the source Agent's buffs qualify.
|
||||||
|
- Do not include an ally whose qualifying buffs come only from other sources.
|
||||||
|
|
||||||
|
Record the resolved non-negative integer count in
|
||||||
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff`. The
|
||||||
|
calculation builder supplies it from the
|
||||||
|
`allies-within-30m-with-agent-granted-buff` scenario input. The confirmed rule
|
||||||
|
defines this qualifying set but does not provide Close and Personal's base
|
||||||
|
percentage. Keep that percentage unresolved until source evidence or an author's
|
||||||
|
clarification supplies it.
|
||||||
|
|
||||||
## Emergency Retreat
|
## Emergency Retreat
|
||||||
|
|
||||||
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
||||||
@@ -93,9 +111,9 @@ qualifies when the weapon had at least one round to load. This includes each
|
|||||||
round inserted by a weapon that reloads one round at a time. A canceled reload
|
round inserted by a weapon that reloads one round at a time. A canceled reload
|
||||||
does not qualify.
|
does not qualify.
|
||||||
|
|
||||||
Resolve the Extra Health amount from the Agent's displayed Firepower when
|
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
|
||||||
Second Wind activates. Include all equipped-item and active-buff contributions
|
Wind activates. Include all equipped-item and active-buff contributions in that
|
||||||
in that Firepower value:
|
Firepower value:
|
||||||
|
|
||||||
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
||||||
- Tier II grants Extra Health equal to 840% of displayed Firepower.
|
- Tier II grants Extra Health equal to 840% of displayed Firepower.
|
||||||
@@ -103,19 +121,39 @@ in that Firepower value:
|
|||||||
Second Wind adds its Extra Health to Extra Health from other sources. Its
|
Second Wind adds its Extra Health to Extra Health from other sources. Its
|
||||||
five-second duration and five-second cooldown start when it activates. Expiry
|
five-second duration and five-second cooldown start when it activates. Expiry
|
||||||
removes only the Second Wind contribution. At the five-second boundary, the
|
removes only the Second Wind contribution. At the five-second boundary, the
|
||||||
prior grant can expire as a qualifying reload completion grants a new amount.
|
prior grant can expire as a qualifying reload completion grants a new amount. If
|
||||||
If Second Wind supplies the Agent's only Extra Health, the old amount expires
|
Second Wind supplies the Agent's only Extra Health, the old amount expires and
|
||||||
and the new amount begins at that boundary.
|
the new amount begins at that boundary.
|
||||||
|
|
||||||
## Nothing to Lose
|
## Nothing to Lose
|
||||||
|
|
||||||
Nothing to Lose uses a forward-looking deployment window:
|
Nothing to Lose uses a forward-looking deployment window:
|
||||||
|
|
||||||
- Each Health-damage instance makes its own 50% activation roll. Damage
|
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
|
||||||
absorbed by Extra Health does not roll.
|
by Extra Health does not roll.
|
||||||
- A successful activation applies to every Skill deployed during the five
|
- A successful activation applies to every Skill deployed during the five
|
||||||
seconds after that damage instance. Several Skills can qualify during the
|
seconds after that damage instance. Several Skills can qualify during the same
|
||||||
same window.
|
window.
|
||||||
- Calculate each reduction from the Skill's total cooldown after equipment
|
- Calculate each reduction from the Skill's total cooldown after equipment
|
||||||
modifiers.
|
modifiers.
|
||||||
- A Skill can receive no more than three reductions during one cooldown cycle.
|
- A Skill can receive no more than three reductions during one cooldown cycle.
|
||||||
|
|
||||||
|
## OS Protocol Talents
|
||||||
|
|
||||||
|
Happy Survival restores Health once per second while it has a qualifying nearby
|
||||||
|
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
|
||||||
|
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
|
||||||
|
|
||||||
|
Explosive Charge requires three seconds of uninterrupted firing. Completion
|
||||||
|
opens an eight-second window to deploy the next Skill. The window ends when the
|
||||||
|
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
|
||||||
|
the five-meter explosion. The shared Talent cooldown rule starts its five-second
|
||||||
|
cooldown when that explosion activates.
|
||||||
|
|
||||||
|
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
|
||||||
|
Health grant when the same Talent activates again. Extra Health grants from
|
||||||
|
different sources remain separate.
|
||||||
|
|
||||||
|
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
|
||||||
|
Health. Losing all Extra Health resets the hit count. The full-Health condition
|
||||||
|
checks Health immediately before the incoming hit resolves.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Domain docs
|
||||||
|
|
||||||
|
Engineering skills must consult this repository's domain documentation before
|
||||||
|
exploring code.
|
||||||
|
|
||||||
|
## Read before exploring
|
||||||
|
|
||||||
|
- Read `CONTEXT.md` at the repository root.
|
||||||
|
- If `CONTEXT-MAP.md` exists, use it to find the `CONTEXT.md` files relevant to
|
||||||
|
the task.
|
||||||
|
- Read ADRs under `docs/adr/` that affect the area you will change. In a
|
||||||
|
multi-context repository, also check `src/<context>/docs/adr/`.
|
||||||
|
|
||||||
|
If these files do not exist, proceed without comment. Do not suggest creating
|
||||||
|
them before the project resolves domain terms or architectural decisions. The
|
||||||
|
`/domain-modeling` skill, reached through `/grill-with-docs` and
|
||||||
|
`/improve-codebase-architecture`, creates them when needed.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
This repository uses the single-context layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── docs/adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
A repository with multiple bounded contexts can use this layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── CONTEXT-MAP.md
|
||||||
|
├── docs/adr/
|
||||||
|
└── src/
|
||||||
|
├── ordering/
|
||||||
|
│ ├── CONTEXT.md
|
||||||
|
│ └── docs/adr/
|
||||||
|
└── billing/
|
||||||
|
├── CONTEXT.md
|
||||||
|
└── docs/adr/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use glossary terms
|
||||||
|
|
||||||
|
Use the terms defined in `CONTEXT.md` in issue titles, refactor proposals,
|
||||||
|
hypotheses, and test names. Do not substitute synonyms that the glossary
|
||||||
|
rejects.
|
||||||
|
|
||||||
|
If a needed concept is absent, reconsider whether the project uses that concept.
|
||||||
|
If it represents a real gap, record it for `/domain-modeling`.
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
Call out any proposal that conflicts with an ADR:
|
||||||
|
|
||||||
|
> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Issue tracker: Gitea
|
||||||
|
|
||||||
|
Issues and specs for this repo live as Gitea issues. Use the
|
||||||
|
[`tea`](https://gitea.com/gitea/tea) CLI for all operations.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
Run commands inside the clone so `tea` can infer the repository and login from
|
||||||
|
its Git remote. If the clone has multiple Gitea remotes or logins, pass
|
||||||
|
`--remote <name>` or `--login <name>`. Use `--repo <owner>/<repo>` only outside
|
||||||
|
the clone.
|
||||||
|
|
||||||
|
Before the first operation, verify the instance with
|
||||||
|
`tea logins list --output json`. If it is missing, ask the user to run
|
||||||
|
`tea login add`; never ask for or handle their access token.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `tea issues create --title "..." --description "..."`.
|
||||||
|
For a multi-line body, use shell command substitution around a quoted heredoc:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea issues create --title "..." --description "$(cat <<'EOF'
|
||||||
|
Multi-line issue body.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Read an issue with comments**:
|
||||||
|
`tea issues <number> --comments --fields index,state,author,url,title,body,assignees,labels,comments --output json`.
|
||||||
|
- **List issues**:
|
||||||
|
`tea issues list --state open --fields index,state,author,url,title,body,assignees,labels,comments --output json`,
|
||||||
|
adding `--labels "label-one,label-two"`, `--assignee <user>`, or another
|
||||||
|
supported filter as needed. Paginate with `--page` and `--limit`; do not
|
||||||
|
assume the default first 30 results are complete.
|
||||||
|
- **Comment on an issue**: `tea comments add <number> --description "..."`.
|
||||||
|
- **Apply or remove labels**: `tea issues edit <number> --add-labels "..."` or
|
||||||
|
`--remove-labels "..."`. Separate multiple labels with commas.
|
||||||
|
- **Assign or unassign**: `tea issues edit <number> --add-assignees "..."` or
|
||||||
|
`--remove-assignees "..."`.
|
||||||
|
- **Close**: post the closing explanation with
|
||||||
|
`tea comments add <number> --description "..."`, then run
|
||||||
|
`tea issues close <number>`.
|
||||||
|
- **Reopen**: `tea issues reopen <number>`.
|
||||||
|
- **Raw API fallback**: use `tea api '<endpoint>'` only for a Gitea feature
|
||||||
|
without a first-class `tea` command. Prefer `{owner}` and `{repo}`
|
||||||
|
placeholders so repository context remains explicit.
|
||||||
|
|
||||||
|
Use `--output json` for machine-readable reads. Do not scrape table output.
|
||||||
|
|
||||||
|
## Pull requests as a triage surface
|
||||||
|
|
||||||
|
**PRs as a request surface: no.** _(Set this to `yes` if this repo treats
|
||||||
|
external pull requests as feature requests; `/triage` reads this flag.)_
|
||||||
|
|
||||||
|
When set to `yes`, PRs use the same labels and states as issues:
|
||||||
|
|
||||||
|
- **Read a PR with comments and diff**:
|
||||||
|
`tea pulls <number> --comments --fields index,state,author,url,title,body,diff,assignees,labels,comments --output json`.
|
||||||
|
- **List open PRs**:
|
||||||
|
`tea pulls list --state open --fields index,state,author,author-id,url,title,body,assignees,labels,comments --output json`.
|
||||||
|
Determine whether an author is external from the repository's Gitea membership
|
||||||
|
data; the PR listing does not classify author membership.
|
||||||
|
- **Comment**: `tea comments add <number> --description "..."`.
|
||||||
|
- **Apply or remove labels**: `tea pulls edit <number> --add-labels "..."` or
|
||||||
|
`--remove-labels "..."`.
|
||||||
|
- **Close**: `tea pulls close <number>`.
|
||||||
|
|
||||||
|
Gitea pull requests use the repository's issue index space, so `#42` may refer
|
||||||
|
to either surface. If the context is unclear, inspect it with
|
||||||
|
`tea pulls 42 --output json`; if that command reports no PR, read it with
|
||||||
|
`tea issues 42 --output json`.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Gitea issue with `tea issues create`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run
|
||||||
|
`tea issues <number> --comments --fields index,state,author,url,title,body,assignees,labels,comments --output json`.
|
||||||
|
|
||||||
|
## Wayfinding operations
|
||||||
|
|
||||||
|
`/wayfinder` uses one **map** issue with linked **child** issues. These
|
||||||
|
conventions store relationships in issue bodies, so they work across Gitea
|
||||||
|
versions.
|
||||||
|
|
||||||
|
- **Map**: create one issue labelled `wayfinder:map` containing the Notes,
|
||||||
|
Decisions-so-far, and Fog sections. Use
|
||||||
|
`tea issues create --labels "wayfinder:map"` with its title and description.
|
||||||
|
- **Child ticket**: start the body with `Part of #<map>` and apply a
|
||||||
|
`wayfinder:<type>` label: `research`, `prototype`, `grilling`, or `task`. Add
|
||||||
|
each child as a task-list item in the map body to preserve frontier order.
|
||||||
|
- **Blocking**: put `Blocked by: #<n>, #<n>` near the top of the child body. A
|
||||||
|
ticket becomes unblocked after every referenced blocker closes.
|
||||||
|
- **Frontier query**: list all open children, order them by the map's task list,
|
||||||
|
and discard tickets with an open blocker or an assignee. Select the first
|
||||||
|
remaining ticket. Fetch each referenced blocker; do not infer its state from
|
||||||
|
map text.
|
||||||
|
- **Claim**: resolve the active username with `tea whoami`, then run
|
||||||
|
`tea issues edit <n> --add-assignees "<username>"` as the session's first
|
||||||
|
write. Do not assume `@me` works.
|
||||||
|
- **Resolve**: run `tea comments add <n> --description "<answer>"`, close the
|
||||||
|
child with `tea issues close <n>`, then append a context pointer and link to
|
||||||
|
the map's Decisions-so-far section.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Triage labels
|
||||||
|
|
||||||
|
Engineering skills use five canonical triage roles. This table maps each role to
|
||||||
|
the label string in this repository's issue tracker.
|
||||||
|
|
||||||
|
| Canonical role | Tracker label | Meaning |
|
||||||
|
| ----------------- | ----------------- | -------------------------------------- |
|
||||||
|
| `needs-triage` | `needs-triage` | A maintainer must evaluate the issue |
|
||||||
|
| `needs-info` | `needs-info` | Waiting for more information |
|
||||||
|
| `ready-for-agent` | `ready-for-agent` | Fully specified and ready for an agent |
|
||||||
|
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||||
|
| `wontfix` | `wontfix` | Maintainers will not act on the issue |
|
||||||
|
|
||||||
|
When a skill names a canonical role, use the corresponding tracker label.
|
||||||
+85
-31
@@ -553,6 +553,24 @@ builder-ready only when each of its three assigned abilities has all three
|
|||||||
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
||||||
focuses that reference both the previous and resulting ability.
|
focuses that reference both the previous and resulting ability.
|
||||||
|
|
||||||
|
`specialization_ability_skill_chip_links` may pair two active abilities from the
|
||||||
|
same specialization. Operators create each pair explicitly; the application does
|
||||||
|
not infer compatibility from ability names, focus membership, or shared
|
||||||
|
specialization. The pair is bidirectional, and an ability may belong to only one
|
||||||
|
pair. This permits compatible abilities such as two Tactical Link variants to
|
||||||
|
share definitions without making unrelated pairs such as two Smart Cover
|
||||||
|
variants compatible. Archiving either ability removes the pair; restoring an
|
||||||
|
ability does not recreate it.
|
||||||
|
|
||||||
|
A linked ability can equip its own three definitions or its partner's three
|
||||||
|
definitions, so each of its three Builder positions offers at most six choices.
|
||||||
|
The loadout still equips three Skill Chips per ability and nine per focus.
|
||||||
|
Ability-scoped primary values and 3-piece Talent outcomes bind to the ability
|
||||||
|
that owns the equipped position, not the definition's original owner. Two-piece
|
||||||
|
and 3-piece thresholds remain local to one equipped ability and never combine
|
||||||
|
matching definitions across ability panels. ISAC audit snapshots and game-data
|
||||||
|
transfers include the compatibility pair.
|
||||||
|
|
||||||
Every equipped quality counts toward the two-piece threshold. After the loadout
|
Every equipped quality counts toward the two-piece threshold. After the loadout
|
||||||
meets that threshold, only equipped High-end chips increase the interpolation
|
meets that threshold, only equipped High-end chips increase the interpolation
|
||||||
step. For `h` High-end chips, where `h` is zero through three, a configured
|
step. For `h` High-end chips, where `h` is zero through three, a configured
|
||||||
@@ -584,6 +602,9 @@ state, with sprinting taking precedence. Enabled Skill activation profiles emit
|
|||||||
the deployed ability. An `on_event` Skill modifier applies to that activation
|
the deployed ability. An `on_event` Skill modifier applies to that activation
|
||||||
until the ability's next deployment or the encounter ends.
|
until the ability's next deployment or the encounter ends.
|
||||||
|
|
||||||
|
`skill_deployed.values.abilityId` and `skill_hit.values.abilityId` identify the
|
||||||
|
same ability for mechanics that bind an effect to the next deployed Skill.
|
||||||
|
|
||||||
Each source supplies a fixed value-variant contract:
|
Each source supplies a fixed value-variant contract:
|
||||||
|
|
||||||
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
||||||
@@ -617,12 +638,24 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
|
|||||||
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
|
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
|
||||||
state, source, movement, and nearby counts travel as typed event values. The
|
state, source, movement, and nearby counts travel as typed event values. The
|
||||||
event registry publishes authoritative field metadata where branches need a
|
event registry publishes authoritative field metadata where branches need a
|
||||||
bounded contract. For those events, the mechanic editor derives its event-field
|
bounded contract. For those events, the mechanic editor derives event-field
|
||||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
conditions and manual derived-scalar controls from that metadata.
|
||||||
boolean that distinguishes headshots from body shots. `enemy_killed` and
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff` is an
|
||||||
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
|
optional non-negative integer. It contains the resolved count of distinct allied
|
||||||
boolean to restrict a kill or assist trigger to its assigned ability's active
|
Agents at a distance less than or equal to 30 meters who have at least one buff
|
||||||
period. `skill_deployed` and `signature_ability_deployed` may carry
|
granted by the source Agent. Several qualifying buffs on one ally count once.
|
||||||
|
`proximity_snapshot` remains non-strict, so saved mechanic data and producers
|
||||||
|
that omit the field remain compatible.
|
||||||
|
|
||||||
|
`src/lib/builder/loadout-combat.ts` bridges the calculation scenario input
|
||||||
|
`allies-within-30m-with-agent-granted-buff` into that field on each generated
|
||||||
|
`proximity_snapshot`. For example, a derived-scalar reducer can select the
|
||||||
|
registered numeric field without requiring a hand-entered path. Other registered
|
||||||
|
metadata includes `weapon_hit.values.headshot`, a boolean that distinguishes
|
||||||
|
headshots from body shots. `enemy_killed` and `assist` may carry
|
||||||
|
`values.assignedAbilityActive`; a Skill Chip Talent uses that boolean to
|
||||||
|
restrict a kill or assist trigger to its assigned ability's active period.
|
||||||
|
`skill_deployed` and `signature_ability_deployed` may carry
|
||||||
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
||||||
runtime expires that state before it evaluates another event at the same
|
runtime expires that state before it evaluates another event at the same
|
||||||
timestamp.
|
timestamp.
|
||||||
@@ -631,11 +664,11 @@ timestamp.
|
|||||||
the hit resolved. A producer omits the field when it lacks authoritative aiming
|
the hit resolved. A producer omits the field when it lacks authoritative aiming
|
||||||
state; the runtime does not replace missing evidence with a scenario assumption.
|
state; the runtime does not replace missing evidence with a scenario assumption.
|
||||||
Predicates compare this field explicitly with `true` or `false`; negation and
|
Predicates compare this field explicitly with `true` or `false`; negation and
|
||||||
inequality cannot turn missing evidence into a match. A weapon-hit event may
|
inequality cannot turn missing evidence into a match. A `weapon_hit` or
|
||||||
also include an event-target-centered area sample. The bounded sample lists up
|
`skill_hit` event may include an event-target-centered area sample. The bounded
|
||||||
to 256 valid ally and enemy entity IDs with their distances from the hit target
|
sample lists up to 256 valid ally and enemy entity IDs with their distances from
|
||||||
and includes that target as an enemy at zero meters. Duplicate IDs, negative
|
the hit target and includes that target as an enemy at zero meters. Duplicate
|
||||||
distances, or a missing center target invalidate the evaluation.
|
IDs, negative distances, or a missing center target invalidate the evaluation.
|
||||||
|
|
||||||
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
|
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
|
||||||
when the Agent receives an Extra Health grant, when the remaining amount
|
when the Agent receives an Extra Health grant, when the remaining amount
|
||||||
@@ -686,11 +719,20 @@ The program graph uses three child tables:
|
|||||||
rolls separately. Restoration outcomes may deliver their total immediately or
|
rolls separately. Restoration outcomes may deliver their total immediately or
|
||||||
continuously over an explicit duration.
|
continuously over an explicit duration.
|
||||||
|
|
||||||
Target-scoped direct damage may use `event_target_area` targeting for an
|
Draft outcome expressions may use `{"ref":{"source":"variant_value"}}`. This
|
||||||
explosion around its event target. This targeting selects valid enemies and
|
reference belongs to the conditional draft grammar and does not enter the
|
||||||
stores a positive radius in meters. It is valid only for `on_event` or
|
runtime expression grammar. Each persisted outcome-variant row keeps its
|
||||||
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
human-facing numeric `value`, while `value_expression_json` preserves the
|
||||||
targeting metadata and does not select runtime entities.
|
reference and its semantic link. The adapters in
|
||||||
|
`src/lib/calculations/mechanic-editor.ts` and
|
||||||
|
`src/lib/calculations/mechanic-persistence.ts` resolve the reference from the
|
||||||
|
current row's `value` when they construct a runtime program.
|
||||||
|
|
||||||
|
Target-scoped direct damage may use `event_target_area` targeting for an
|
||||||
|
explosion around its event target. This targeting selects valid enemies and
|
||||||
|
stores a positive radius in meters. It is valid only for `on_event` or
|
||||||
|
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
||||||
|
targeting metadata and does not select runtime entities.
|
||||||
|
|
||||||
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||||
@@ -730,11 +772,13 @@ substitute zero.
|
|||||||
The read-only `combat.currentHealthPercent` reference divides resolved current
|
The read-only `combat.currentHealthPercent` reference divides resolved current
|
||||||
Health by resolved maximum Health and returns a value from 0 through 100. The
|
Health by resolved maximum Health and returns a value from 0 through 100. The
|
||||||
runtime derives it when a predicate is evaluated instead of storing a second
|
runtime derives it when a predicate is evaluated instead of storing a second
|
||||||
Health value that could become stale. Current timing observes deterministic
|
Health value that could become stale. Pre-event timing reads the combat snapshot
|
||||||
resource changes from earlier branches at the same event. Health-band mechanics
|
before mechanic branches process the event. Current timing observes
|
||||||
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
|
deterministic resource changes from earlier branches at the same event.
|
||||||
an exact threshold enters the next weaker band. Maximum-potential evaluation
|
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
|
||||||
applies the same predicates before emitting passive outcomes.
|
bands do not overlap and an exact threshold enters the next weaker band.
|
||||||
|
Maximum-potential evaluation applies the same predicates before emitting passive
|
||||||
|
outcomes.
|
||||||
|
|
||||||
Continuous restoration resolves the outcome's total when the branch activates,
|
Continuous restoration resolves the outcome's total when the branch activates,
|
||||||
then delivers that total at a constant rate over the configured duration.
|
then delivers that total at a constant rate over the configured duration.
|
||||||
@@ -777,6 +821,13 @@ descriptions, and calculation metadata. The client cannot replace the system
|
|||||||
instructions, choose arbitrary models, attach files, add tools, or add external
|
instructions, choose arbitrary models, attach files, add tools, or add external
|
||||||
URLs.
|
URLs.
|
||||||
|
|
||||||
|
When source wording contains an unresolved numbered numeric placeholder, the
|
||||||
|
assistant must return `clarification_required` and ask for the value of every
|
||||||
|
required source variant. It must not infer a percentage or other numeric value.
|
||||||
|
After clarification, the draft places each answer in the corresponding
|
||||||
|
outcome-variant `value`; any scaling expression uses the draft-only
|
||||||
|
`variant_value` reference instead of repeating that number.
|
||||||
|
|
||||||
The deterministic draft parser owns the canonical outcome-capability manifest.
|
The deterministic draft parser owns the canonical outcome-capability manifest.
|
||||||
The AI context serializes that manifest instead of maintaining a second outcome
|
The AI context serializes that manifest instead of maintaining a second outcome
|
||||||
grammar. The parser rejects outcome fields absent from the manifest. Contract
|
grammar. The parser rejects outcome fields absent from the manifest. Contract
|
||||||
@@ -873,20 +924,23 @@ events remain the evidence for revision saves and activation.
|
|||||||
Source authors define planned player scenario controls as typed toggle, slider,
|
Source authors define planned player scenario controls as typed toggle, slider,
|
||||||
or numeric inputs. Each input records its label, guidance, bounds when
|
or numeric inputs. Each input records its label, guidance, bounds when
|
||||||
applicable, default value, and best-case value. ISAC retains these definitions
|
applicable, default value, and best-case value. ISAC retains these definitions
|
||||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
for continued mechanic authoring, and the calculation timeline builder can
|
||||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
consume scenario values supplied by calculation orchestration. Current Builder
|
||||||
|
and shared Build pages do not expose those controls or invoke the simulation.
|
||||||
|
Their calculation panels display `COMING SOON` instead.
|
||||||
|
|
||||||
Loadout schema version 5 stores one focus, selected tree ranks, nine Skill
|
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
||||||
Chips, a unified conditional-effect state map, encounter assumptions,
|
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
||||||
per-ability activation controls, and `calculationMode`. Runtime readers accept
|
unified conditional-effect state map, encounter assumptions, per-ability
|
||||||
the exact version 5 structure. The schema retains its simulation fields, but the
|
activation controls, and `calculationMode`. Runtime readers accept the exact
|
||||||
current player UI ignores them. Version 4 is obsolete prototype data. A
|
version 6 structure. A backed-up migration translated version 5 equipment
|
||||||
backed-up prototype migration removes version 4 drafts, saved builds, and
|
Talents from their previous equipment-tier-derived behavior. The schema retains
|
||||||
calibration snapshots instead of translating them.
|
its simulation fields, but the current player UI ignores them. Version 4 remains
|
||||||
|
obsolete prototype data.
|
||||||
|
|
||||||
## Planned player calculation modes
|
## Planned player calculation modes
|
||||||
|
|
||||||
Loadout v5 defines `maximum` and `custom` modes for the planned simulation UI:
|
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||||
|
|
||||||
- Maximum mode supplies each scenario input's best-case value, selects peak
|
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||||
conditional-effect state, and runs selected abilities on cooldown against
|
conditional-effect state, and runs selected abilities on cooldown against
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"worktree": {
|
||||||
|
"setup": "npm install"
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
-5
@@ -45,7 +45,8 @@ const isRoll = (value) =>
|
|||||||
isFiniteNumber(value.value);
|
isFiniteNumber(value.value);
|
||||||
const isRollPair = (value) =>
|
const isRollPair = (value) =>
|
||||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||||
const isGearSelection = (value) =>
|
const isTalentTier = (value) => value === 1 || value === 2;
|
||||||
|
const isLegacyGearSelection = (value) =>
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'itemId',
|
'itemId',
|
||||||
'level',
|
'level',
|
||||||
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
|
|||||||
isRollPair(value.rolls) &&
|
isRollPair(value.rolls) &&
|
||||||
Array.isArray(value.augmentSlots) &&
|
Array.isArray(value.augmentSlots) &&
|
||||||
value.augmentSlots.every(isFiniteNumber);
|
value.augmentSlots.every(isFiniteNumber);
|
||||||
const isWeaponSelection = (value) =>
|
const isLegacyWeaponSelection = (value) =>
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'itemId',
|
'itemId',
|
||||||
'level',
|
'level',
|
||||||
@@ -88,6 +89,57 @@ const isWeaponSelection = (value) =>
|
|||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
isRollPair(value.rolls);
|
isRollPair(value.rolls);
|
||||||
|
const isGearSelection = (value) =>
|
||||||
|
hasExactKeys(value, [
|
||||||
|
'itemId',
|
||||||
|
'level',
|
||||||
|
'brandId',
|
||||||
|
'talentId',
|
||||||
|
'talentTier',
|
||||||
|
'health',
|
||||||
|
'armor',
|
||||||
|
'firepower',
|
||||||
|
'toughness',
|
||||||
|
'engineering',
|
||||||
|
'rolls',
|
||||||
|
'augmentSlots',
|
||||||
|
]) &&
|
||||||
|
['itemId', 'brandId', 'talentId'].every(
|
||||||
|
(key) => typeof value[key] === 'string',
|
||||||
|
) &&
|
||||||
|
isTalentTier(value.talentTier) &&
|
||||||
|
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||||
|
(key) => isFiniteNumber(value[key]),
|
||||||
|
) &&
|
||||||
|
isRollPair(value.rolls) &&
|
||||||
|
Array.isArray(value.augmentSlots) &&
|
||||||
|
value.augmentSlots.every(isFiniteNumber);
|
||||||
|
const isWeaponSelection = (value) =>
|
||||||
|
hasExactKeys(value, [
|
||||||
|
'itemId',
|
||||||
|
'level',
|
||||||
|
'tier',
|
||||||
|
'damage',
|
||||||
|
'firepower',
|
||||||
|
'toughness',
|
||||||
|
'engineering',
|
||||||
|
'talent1Id',
|
||||||
|
'talent1Tier',
|
||||||
|
'talent2Id',
|
||||||
|
'talent2Tier',
|
||||||
|
'exoticTalentTier',
|
||||||
|
'rolls',
|
||||||
|
]) &&
|
||||||
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||||
|
(key) => typeof value[key] === 'string',
|
||||||
|
) &&
|
||||||
|
isTalentTier(value.talent1Tier) &&
|
||||||
|
isTalentTier(value.talent2Tier) &&
|
||||||
|
isTalentTier(value.exoticTalentTier) &&
|
||||||
|
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||||
|
(key) => isFiniteNumber(value[key]),
|
||||||
|
) &&
|
||||||
|
isRollPair(value.rolls);
|
||||||
const isChipSelection = (value) =>
|
const isChipSelection = (value) =>
|
||||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
typeof value.setId === 'string' &&
|
typeof value.setId === 'string' &&
|
||||||
@@ -190,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
|||||||
'skillActivations',
|
'skillActivations',
|
||||||
'specialization',
|
'specialization',
|
||||||
]) &&
|
]) &&
|
||||||
value.version === 5 &&
|
value.version === 6 &&
|
||||||
typeof value.buildName === 'string' &&
|
typeof value.buildName === 'string' &&
|
||||||
['maximum', 'custom'].includes(value.calculationMode) &&
|
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||||
hasExactKeys(value.gear, gearSlots) &&
|
hasExactKeys(value.gear, gearSlots) &&
|
||||||
@@ -212,6 +264,49 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLegacyLoadoutSnapshot = (raw) => {
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
hasExactKeys(value, [
|
||||||
|
'version',
|
||||||
|
'buildName',
|
||||||
|
'gear',
|
||||||
|
'weapons',
|
||||||
|
'chips',
|
||||||
|
'protocol',
|
||||||
|
'conditionalEffects',
|
||||||
|
'combatProfile',
|
||||||
|
'calculationMode',
|
||||||
|
'skillActivations',
|
||||||
|
'specialization',
|
||||||
|
]) &&
|
||||||
|
value.version === 5 &&
|
||||||
|
typeof value.buildName === 'string' &&
|
||||||
|
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||||
|
hasExactKeys(value.gear, gearSlots) &&
|
||||||
|
gearSlots.every((slot) => isLegacyGearSelection(value.gear[slot])) &&
|
||||||
|
Array.isArray(value.weapons) &&
|
||||||
|
value.weapons.length === 2 &&
|
||||||
|
value.weapons.every(isLegacyWeaponSelection) &&
|
||||||
|
Array.isArray(value.chips) &&
|
||||||
|
value.chips.length === 9 &&
|
||||||
|
value.chips.every(isChipSelection) &&
|
||||||
|
isProtocolSelection(value.protocol) &&
|
||||||
|
isEffectStates(value.conditionalEffects) &&
|
||||||
|
isCombatProfile(value.combatProfile) &&
|
||||||
|
isSkillActivations(value.skillActivations) &&
|
||||||
|
hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) &&
|
||||||
|
typeof value.specialization.focusId === 'string' &&
|
||||||
|
isRecord(value.specialization.nodeRanks) &&
|
||||||
|
Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const databasePath = resolve(
|
const databasePath = resolve(
|
||||||
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
|
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
|
||||||
);
|
);
|
||||||
@@ -674,6 +769,44 @@ for (const statement of statements) {
|
|||||||
await database.exec(statement);
|
await database.exec(statement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
|
||||||
|
BEFORE INSERT ON specialization_ability_skill_chip_links
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM specialization_ability_skill_chip_links link
|
||||||
|
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
|
||||||
|
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
|
||||||
|
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
|
||||||
|
SELECT CASE WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM specialization_abilities ability
|
||||||
|
JOIN specialization_abilities compatible
|
||||||
|
ON compatible.id=NEW.compatible_ability_id
|
||||||
|
WHERE ability.id=NEW.ability_id
|
||||||
|
AND ability.active=1
|
||||||
|
AND compatible.active=1
|
||||||
|
AND ability.specialization_id=compatible.specialization_id
|
||||||
|
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
|
||||||
|
END`);
|
||||||
|
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
|
||||||
|
BEFORE UPDATE ON specialization_ability_skill_chip_links
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM specialization_ability_skill_chip_links link
|
||||||
|
WHERE link.rowid<>OLD.rowid
|
||||||
|
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
|
||||||
|
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
|
||||||
|
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
|
||||||
|
SELECT CASE WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM specialization_abilities ability
|
||||||
|
JOIN specialization_abilities compatible
|
||||||
|
ON compatible.id=NEW.compatible_ability_id
|
||||||
|
WHERE ability.id=NEW.ability_id
|
||||||
|
AND ability.active=1
|
||||||
|
AND compatible.active=1
|
||||||
|
AND ability.specialization_id=compatible.specialization_id
|
||||||
|
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
|
||||||
|
END`);
|
||||||
|
|
||||||
const articleRevisionColumns = await (
|
const articleRevisionColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('article_revisions')")
|
await database.prepare("PRAGMA table_info('article_revisions')")
|
||||||
).all();
|
).all();
|
||||||
@@ -3223,6 +3356,41 @@ if (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
|
||||||
|
await Promise.all([
|
||||||
|
(await database.prepare('SELECT id,tier FROM gear')).all(),
|
||||||
|
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
|
||||||
|
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
|
||||||
|
]);
|
||||||
|
const migrateLegacyLoadoutSnapshot = (raw) => {
|
||||||
|
if (!isLegacyLoadoutSnapshot(raw)) return null;
|
||||||
|
const value =
|
||||||
|
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
|
||||||
|
for (const slot of gearSlots) {
|
||||||
|
const item = loadoutGearRows.find(
|
||||||
|
(entry) => String(entry.id) === String(value.gear[slot].itemId),
|
||||||
|
);
|
||||||
|
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
for (const selection of value.weapons) {
|
||||||
|
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
|
||||||
|
selection.talent1Tier = inheritedTier;
|
||||||
|
selection.talent2Tier = inheritedTier;
|
||||||
|
const weapon = loadoutWeaponRows.find(
|
||||||
|
(entry) => String(entry.id) === String(selection.itemId),
|
||||||
|
);
|
||||||
|
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
|
||||||
|
const hasExoticTierTwo = loadoutTalentTierRows.some(
|
||||||
|
(entry) =>
|
||||||
|
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
|
||||||
|
);
|
||||||
|
selection.exoticTalentTier =
|
||||||
|
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
|
||||||
|
}
|
||||||
|
value.version = 6;
|
||||||
|
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
|
||||||
|
};
|
||||||
|
|
||||||
const buildColumns = await (
|
const buildColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('builds')")
|
await database.prepare("PRAGMA table_info('builds')")
|
||||||
).all();
|
).all();
|
||||||
@@ -3237,9 +3405,34 @@ if (buildColumns.length) {
|
|||||||
await database.exec(
|
await database.exec(
|
||||||
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
||||||
);
|
);
|
||||||
const builds = await (
|
let builds = await (
|
||||||
await database.prepare('SELECT id,loadout_json FROM builds')
|
await database.prepare('SELECT id,loadout_json FROM builds')
|
||||||
).all();
|
).all();
|
||||||
|
const migratedBuilds = builds
|
||||||
|
.map((build) => ({
|
||||||
|
id: String(build.id),
|
||||||
|
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
|
||||||
|
}))
|
||||||
|
.filter((build) => build.loadout);
|
||||||
|
if (migratedBuilds.length) {
|
||||||
|
console.log(
|
||||||
|
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
|
||||||
|
);
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const build of migratedBuilds)
|
||||||
|
await (
|
||||||
|
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
|
||||||
|
).run(build.loadout, build.id);
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
builds = await (
|
||||||
|
await database.prepare('SELECT id,loadout_json FROM builds')
|
||||||
|
).all();
|
||||||
|
}
|
||||||
const obsoleteBuildIds = builds
|
const obsoleteBuildIds = builds
|
||||||
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
||||||
.map((build) => String(build.id));
|
.map((build) => String(build.id));
|
||||||
@@ -3629,11 +3822,42 @@ if (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const calibrationCases = await (
|
let calibrationCases = await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||||
)
|
)
|
||||||
).all();
|
).all();
|
||||||
|
const migratedCalibrationCases = calibrationCases
|
||||||
|
.map((calibrationCase) => ({
|
||||||
|
id: String(calibrationCase.id),
|
||||||
|
loadout: migrateLegacyLoadoutSnapshot(
|
||||||
|
calibrationCase.loadout_snapshot_json,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((calibrationCase) => calibrationCase.loadout);
|
||||||
|
if (migratedCalibrationCases.length) {
|
||||||
|
console.log(
|
||||||
|
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
|
||||||
|
);
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const calibrationCase of migratedCalibrationCases)
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(calibrationCase.loadout, calibrationCase.id);
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
calibrationCases = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||||
|
)
|
||||||
|
).all();
|
||||||
|
}
|
||||||
const obsoleteCalibrationIds = calibrationCases
|
const obsoleteCalibrationIds = calibrationCases
|
||||||
.filter(
|
.filter(
|
||||||
(calibrationCase) =>
|
(calibrationCase) =>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
import {
|
||||||
|
ISAC_DATA_FORMAT_VERSION,
|
||||||
|
ISAC_DATA_TABLES,
|
||||||
|
quoteIdentifier,
|
||||||
|
} from './isac-data-tables.mjs';
|
||||||
|
|
||||||
const databasePath = resolve(
|
const databasePath = resolve(
|
||||||
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
@@ -67,7 +71,7 @@ for (const row of [
|
|||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
format: 'shd-build-isac-data',
|
format: 'shd-build-isac-data',
|
||||||
version: 4,
|
version: ISAC_DATA_FORMAT_VERSION,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join, resolve } from 'node:path';
|
import { basename, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
import {
|
||||||
|
ISAC_DATA_FORMAT_VERSION,
|
||||||
|
ISAC_DATA_TABLES,
|
||||||
|
quoteIdentifier,
|
||||||
|
} from './isac-data-tables.mjs';
|
||||||
|
|
||||||
const snapshotArgument = process.argv
|
const snapshotArgument = process.argv
|
||||||
.slice(2)
|
.slice(2)
|
||||||
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
|
|||||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||||
if (
|
if (
|
||||||
snapshot?.format !== 'shd-build-isac-data' ||
|
snapshot?.format !== 'shd-build-isac-data' ||
|
||||||
snapshot?.version !== 4 ||
|
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
|
||||||
!Array.isArray(snapshot.tables) ||
|
!Array.isArray(snapshot.tables) ||
|
||||||
!Array.isArray(snapshot.icons)
|
!Array.isArray(snapshot.icons)
|
||||||
) {
|
) {
|
||||||
@@ -39,6 +43,87 @@ if (
|
|||||||
'The ISAC snapshot table manifest is incomplete or out of order.',
|
'The ISAC snapshot table manifest is incomplete or out of order.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const abilityRows = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'specialization_abilities',
|
||||||
|
).rows;
|
||||||
|
const abilitiesById = new Map(
|
||||||
|
abilityRows.map((row) => [String(row.id ?? ''), row]),
|
||||||
|
);
|
||||||
|
const skillChipRows = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'skill_chip_sets',
|
||||||
|
).rows;
|
||||||
|
const activeSkillChipCounts = new Map();
|
||||||
|
for (const row of skillChipRows) {
|
||||||
|
const abilityId = String(row.specialization_ability_id ?? '');
|
||||||
|
if (!abilityId || !abilitiesById.has(abilityId)) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(row.active) !== 1) continue;
|
||||||
|
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
|
||||||
|
if (count > 3) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
activeSkillChipCounts.set(abilityId, count);
|
||||||
|
}
|
||||||
|
const skillChipLinks = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'specialization_ability_skill_chip_links',
|
||||||
|
).rows;
|
||||||
|
const linkedAbilityIds = new Set();
|
||||||
|
for (const row of skillChipLinks) {
|
||||||
|
const abilityId = row.ability_id;
|
||||||
|
const compatibleAbilityId = row.compatible_ability_id;
|
||||||
|
if (
|
||||||
|
typeof abilityId !== 'string' ||
|
||||||
|
!abilityId ||
|
||||||
|
typeof compatibleAbilityId !== 'string' ||
|
||||||
|
!compatibleAbilityId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityId === compatibleAbilityId) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityId >= compatibleAbilityId) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ability = abilitiesById.get(abilityId);
|
||||||
|
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
|
||||||
|
if (!ability || !compatibleAbility) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ability.specialization_id !== compatibleAbility.specialization_id) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
linkedAbilityIds.has(abilityId) ||
|
||||||
|
linkedAbilityIds.has(compatibleAbilityId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
linkedAbilityIds.add(abilityId);
|
||||||
|
linkedAbilityIds.add(compatibleAbilityId);
|
||||||
|
}
|
||||||
for (const kind of ['weapon', 'gear']) {
|
for (const kind of ['weapon', 'gear']) {
|
||||||
const options = snapshot.tables.find(
|
const options = snapshot.tables.find(
|
||||||
(table) => table.name === `${kind}_mod_secondary_options`,
|
(table) => table.name === `${kind}_mod_secondary_options`,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'specialization_combat_effects',
|
'specialization_combat_effects',
|
||||||
'specialization_focuses',
|
'specialization_focuses',
|
||||||
'specialization_abilities',
|
'specialization_abilities',
|
||||||
|
'specialization_ability_skill_chip_links',
|
||||||
'specialization_ability_attacks',
|
'specialization_ability_attacks',
|
||||||
'specialization_ability_effects',
|
'specialization_ability_effects',
|
||||||
'specialization_focus_abilities',
|
'specialization_focus_abilities',
|
||||||
@@ -75,5 +76,7 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'managed_images',
|
'managed_images',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ISAC_DATA_FORMAT_VERSION = 5;
|
||||||
|
|
||||||
export const quoteIdentifier = (identifier) =>
|
export const quoteIdentifier = (identifier) =>
|
||||||
`"${String(identifier).replaceAll('"', '""')}"`;
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const APP_SECRET_NAMES = new Set([
|
|||||||
'OPENAI_MECHANIC_MODEL',
|
'OPENAI_MECHANIC_MODEL',
|
||||||
'OPENAI_MECHANIC_REASONING_EFFORT',
|
'OPENAI_MECHANIC_REASONING_EFFORT',
|
||||||
'OPENAI_MODERATION_API_KEY',
|
'OPENAI_MODERATION_API_KEY',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
'ORIGIN',
|
'ORIGIN',
|
||||||
'PASSKEY_RP_ID',
|
'PASSKEY_RP_ID',
|
||||||
'REMOVE_BG_API_KEY',
|
'REMOVE_BG_API_KEY',
|
||||||
|
|||||||
+296
-30
@@ -9006,6 +9006,47 @@ a.secondary:active {
|
|||||||
padding: 0 9px !important;
|
padding: 0 9px !important;
|
||||||
font-size: var(--shd-type-telemetry) !important;
|
font-size: var(--shd-type-telemetry) !important;
|
||||||
}
|
}
|
||||||
|
.builder-radio-toggle {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--shd-border);
|
||||||
|
background: var(--shd-builder-canvas);
|
||||||
|
color: var(--shd-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.builder-radio-toggle > img,
|
||||||
|
.builder-radio-toggle > i {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.builder-radio-toggle > span {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.builder-radio-toggle b,
|
||||||
|
.builder-radio-toggle small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.builder-radio-toggle em {
|
||||||
|
color: var(--shd-orange);
|
||||||
|
font-size: var(--shd-type-telemetry);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.builder-radio-toggle:hover,
|
||||||
|
.builder-radio-toggle:focus-visible {
|
||||||
|
border-color: var(--shd-orange);
|
||||||
|
}
|
||||||
.builder-radio-grid > div {
|
.builder-radio-grid > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr));
|
||||||
@@ -9131,6 +9172,40 @@ a.secondary:active {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
.builder-talent-slot {
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--shd-border-soft);
|
||||||
|
}
|
||||||
|
.builder-talent-slot:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.builder-exotic-talent > div:first-child {
|
||||||
|
min-height: 48px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--shd-border);
|
||||||
|
background: var(--shd-builder-canvas);
|
||||||
|
}
|
||||||
|
.builder-exotic-talent > div:first-child > .item-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.builder-exotic-talent > div:first-child > span {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.builder-exotic-talent small {
|
||||||
|
color: var(--shd-orange);
|
||||||
|
font-size: var(--shd-type-telemetry);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
}
|
||||||
.builder-rolls.required > div {
|
.builder-rolls.required > div {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr);
|
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr);
|
||||||
}
|
}
|
||||||
@@ -9178,7 +9253,7 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.builder-core-grid {
|
.builder-core-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
.builder-range-grid.weapon-damage {
|
.builder-range-grid.weapon-damage {
|
||||||
@@ -9278,6 +9353,8 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.loadout-slots > header,
|
.loadout-slots > header,
|
||||||
.summary-section > header {
|
.summary-section > header {
|
||||||
|
height: auto;
|
||||||
|
min-height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -9356,6 +9433,136 @@ a.secondary:active {
|
|||||||
min-height: 680px;
|
min-height: 680px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 2px solid var(--shd-orange);
|
border-top: 2px solid var(--shd-orange);
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
.builder-editor-columns,
|
||||||
|
.builder-specialization-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.builder-editor-lane {
|
||||||
|
min-width: 0;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
.builder-editor-lane + .builder-editor-lane {
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 1px solid var(--shd-border);
|
||||||
|
}
|
||||||
|
.builder-editor-lane > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.builder-editor-lane .builder-weapon-controls {
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(120px, 0.65fr);
|
||||||
|
}
|
||||||
|
.builder-editor-lane .builder-equipment-controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.builder-editor-lane
|
||||||
|
.builder-equipment-controls
|
||||||
|
> .builder-radio-grid:last-child:nth-child(3) {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
.builder-editor-lane .builder-weapon-controls .builder-fixed {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.builder-editor-lane .builder-range-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
}
|
||||||
|
.builder-editor-lane .range-value-inputs {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 72px;
|
||||||
|
}
|
||||||
|
.builder-editor-lane .builder-rolls.required > div,
|
||||||
|
.builder-editor-lane .builder-chip-rolls > div {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.72fr);
|
||||||
|
}
|
||||||
|
.builder-specialization-layout .builder-ability-chip-panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--shd-gap-xs);
|
||||||
|
}
|
||||||
|
.builder-focus-abilities > button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--shd-gap-sm);
|
||||||
|
padding: var(--shd-gap-sm);
|
||||||
|
border: 1px solid var(--shd-border);
|
||||||
|
background: var(--shd-builder-canvas);
|
||||||
|
color: var(--shd-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities > button:hover,
|
||||||
|
.builder-focus-abilities > button:focus-visible {
|
||||||
|
border-color: var(--shd-orange);
|
||||||
|
}
|
||||||
|
.builder-focus-abilities img {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities .builder-ability-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities span {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities small,
|
||||||
|
.builder-focus-abilities b {
|
||||||
|
color: var(--shd-text-muted);
|
||||||
|
font-size: var(--shd-type-telemetry);
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.builder-focus-abilities b {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.builder-specialization-layout .builder-specialization-identity {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.builder-specialization-layout .builder-specialization-identity > b {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.builder-specialization-identity .focus-composite-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 40px;
|
||||||
|
}
|
||||||
|
.builder-specialization-identity .focus-composite-icon > .item-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.builder-specialization-identity .focus-composite-icon > .game-icon {
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
@container (max-width: 700px) {
|
||||||
|
.builder-editor-columns,
|
||||||
|
.builder-specialization-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.builder-editor-lane + .builder-editor-lane {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid var(--shd-border);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.loadout-editor-head {
|
.loadout-editor-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -9688,11 +9895,12 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.summary-core {
|
.summary-core {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
border-top: 1px solid #303536;
|
border-top: 1px solid #303536;
|
||||||
border-bottom: 1px solid #303536;
|
border-bottom: 1px solid #303536;
|
||||||
}
|
}
|
||||||
.summary-core > div {
|
.summary-core > div {
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -9707,13 +9915,18 @@ a.secondary:active {
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
.summary-core span {
|
.summary-core span {
|
||||||
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
color: #687072;
|
color: #687072;
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.summary-core b {
|
.summary-core b {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
color: #d6d9da;
|
color: #d6d9da;
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.summary-scroll {
|
.summary-scroll {
|
||||||
max-height: calc(100vh - 340px);
|
max-height: calc(100vh - 340px);
|
||||||
@@ -9726,14 +9939,15 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.summary-stat,
|
.summary-stat,
|
||||||
.summary-bonus {
|
.summary-bonus {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(0, 1fr) minmax(70px, auto);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-top: 1px solid #252a2b;
|
border-top: 1px solid #252a2b;
|
||||||
}
|
}
|
||||||
.summary-stat > span,
|
.summary-stat > span,
|
||||||
.summary-bonus > span {
|
.summary-bonus > span {
|
||||||
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
@@ -9744,10 +9958,14 @@ a.secondary:active {
|
|||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
}
|
}
|
||||||
.summary-stat > b {
|
.summary-stat > b {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
color: #d9dbdc;
|
color: #d9dbdc;
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
}
|
}
|
||||||
.summary-bonus em {
|
.summary-bonus em {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
color: var(--shd-success);
|
color: var(--shd-success);
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -12003,10 +12221,7 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.loadout-workspace {
|
.loadout-workspace {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: minmax(300px, 350px) minmax(480px, 1fr) minmax(
|
grid-template-columns: minmax(300px, 350px) minmax(0, 1fr);
|
||||||
290px,
|
|
||||||
340px
|
|
||||||
);
|
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.loadout-workspace > *,
|
.loadout-workspace > *,
|
||||||
@@ -12040,8 +12255,20 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.loadout-summary {
|
.loadout-summary {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
position: static;
|
||||||
|
grid-column: 1/3;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
background: var(--shd-surface);
|
background: var(--shd-surface);
|
||||||
}
|
}
|
||||||
|
.loadout-summary .summary-scroll {
|
||||||
|
max-height: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||||||
|
}
|
||||||
|
.builder-editor-lane .builder-radio-grid:not(.icon-only) > div {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.builder-picker-current > * {
|
.builder-picker-current > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -12070,11 +12297,11 @@ a.secondary:active {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.builder-specialization-identity.ability-identity {
|
.builder-specialization-identity.ability-identity {
|
||||||
grid-template-columns: 54px minmax(0, 1fr) auto;
|
grid-template-columns: 46px minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
.builder-specialization-identity.ability-identity > .item-icon {
|
.builder-specialization-identity.ability-identity > .item-icon {
|
||||||
width: 50px;
|
width: 42px;
|
||||||
height: 50px;
|
height: 42px;
|
||||||
}
|
}
|
||||||
.shared-build-guide {
|
.shared-build-guide {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -12118,11 +12345,13 @@ a.secondary:active {
|
|||||||
.loadout-summary {
|
.loadout-summary {
|
||||||
position: static;
|
position: static;
|
||||||
grid-column: 1/3;
|
grid-column: 1/3;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.summary-scroll {
|
.summary-scroll {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 1050px) {
|
@media (max-width: 1050px) {
|
||||||
@@ -12699,10 +12928,9 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
.mod-secondary-pool-option {
|
.mod-secondary-pool-option {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 30px minmax(220px, 0.9fr) repeat(
|
grid-template-columns:
|
||||||
3,
|
30px minmax(220px, 0.9fr) repeat(3, minmax(285px, 1fr))
|
||||||
minmax(285px, 1fr)
|
30px;
|
||||||
) 30px;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
@@ -12814,10 +13042,9 @@ a.secondary:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-chip-profile-option.selectable {
|
.skill-chip-profile-option.selectable {
|
||||||
grid-template-columns: 32px minmax(210px, 1.2fr) repeat(
|
grid-template-columns:
|
||||||
3,
|
32px minmax(210px, 1.2fr) repeat(3, minmax(130px, 1fr))
|
||||||
minmax(130px, 1fr)
|
34px;
|
||||||
) 34px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-chip-profile-option > div:first-child,
|
.skill-chip-profile-option > div:first-child,
|
||||||
@@ -15345,6 +15572,17 @@ a.secondary:active {
|
|||||||
border-left: 2px solid var(--shd-info);
|
border-left: 2px solid var(--shd-info);
|
||||||
background: var(--shd-model-surface);
|
background: var(--shd-model-surface);
|
||||||
}
|
}
|
||||||
|
.specialization-skill-chip-sharing {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--shd-gap-sm);
|
||||||
|
margin-top: var(--shd-gap-md);
|
||||||
|
padding: var(--shd-gap-md);
|
||||||
|
border: 1px solid var(--shd-border);
|
||||||
|
background: var(--shd-surface);
|
||||||
|
}
|
||||||
|
.specialization-skill-chip-sharing .assignment-picker {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.specialization-attack-profile > .segmented-choice {
|
.specialization-attack-profile > .segmented-choice {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -15494,18 +15732,23 @@ a.secondary:active {
|
|||||||
.builder-ability-chip-panels > section {
|
.builder-ability-chip-panels > section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(48px, auto) auto;
|
grid-template-rows: auto auto;
|
||||||
gap: var(--shd-gap-sm);
|
gap: var(--shd-gap-sm);
|
||||||
padding: var(--shd-gap-sm);
|
padding: var(--shd-gap-sm);
|
||||||
border: 1px solid var(--shd-border);
|
border: 1px solid var(--shd-border);
|
||||||
background: var(--shd-surface);
|
background: var(--shd-surface);
|
||||||
}
|
}
|
||||||
.builder-ability-chip-panels header {
|
.builder-ability-chip-panels header {
|
||||||
|
height: auto;
|
||||||
|
position: static;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
gap: var(--shd-gap-sm);
|
gap: var(--shd-gap-sm);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
.builder-ability-chip-panels header img {
|
.builder-ability-chip-panels header img {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
@@ -15528,13 +15771,6 @@ a.secondary:active {
|
|||||||
.builder-ability-chip-panels header b {
|
.builder-ability-chip-panels header b {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.builder-ability-chip-panels > section > p {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--shd-text-muted);
|
|
||||||
font-size: var(--shd-type-supporting);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.builder-ability-chip-panels > section > div {
|
.builder-ability-chip-panels > section > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -15544,7 +15780,7 @@ a.secondary:active {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 70px minmax(0, 1fr);
|
grid-template-columns: 36px minmax(0, 1fr);
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
gap: 1px 8px;
|
gap: 1px 8px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
@@ -15558,8 +15794,13 @@ a.secondary:active {
|
|||||||
.builder-ability-chip-panels > section > div > button:focus-visible {
|
.builder-ability-chip-panels > section > div > button:focus-visible {
|
||||||
border-color: var(--shd-orange);
|
border-color: var(--shd-orange);
|
||||||
}
|
}
|
||||||
.builder-ability-chip-panels button small {
|
.builder-ability-chip-panels button > .builder-chip-icon {
|
||||||
grid-row: 1/3;
|
grid-row: 1/3;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.builder-ability-chip-panels button small {
|
||||||
color: var(--shd-orange);
|
color: var(--shd-orange);
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -15575,6 +15816,28 @@ a.secondary:active {
|
|||||||
color: var(--shd-text-muted);
|
color: var(--shd-text-muted);
|
||||||
font-size: var(--shd-type-telemetry);
|
font-size: var(--shd-type-telemetry);
|
||||||
}
|
}
|
||||||
|
.builder-ability-chip-panels button > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.builder-ability-chip-panels button > span > strong,
|
||||||
|
.builder-ability-chip-panels button > span > em {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.builder-ability-chip-panels button em {
|
||||||
|
display: block;
|
||||||
|
color: var(--shd-text-muted);
|
||||||
|
font-size: var(--shd-type-telemetry);
|
||||||
|
font-style: normal;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.builder-specialization-telemetry {
|
.builder-specialization-telemetry {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -15600,6 +15863,9 @@ a.secondary:active {
|
|||||||
background: var(--shd-model-surface);
|
background: var(--shd-model-surface);
|
||||||
}
|
}
|
||||||
.builder-focus-tree > section > header {
|
.builder-focus-tree > section > header {
|
||||||
|
height: auto;
|
||||||
|
min-height: 32px;
|
||||||
|
position: static;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|||||||
@@ -176,6 +176,38 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits the Agent-granted-buff ally count from its exact scenario input and defaults it to zero', () => {
|
||||||
|
const profile = createDraft().combatProfile;
|
||||||
|
profile.encounterSeconds = 1;
|
||||||
|
const configured = buildWeaponMechanicTimeline(
|
||||||
|
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||||
|
profile,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
scenarioInputs: {
|
||||||
|
'allies-within-30m-with-agent-granted-buff': 4,
|
||||||
|
'allies-within-radius': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).filter((event) => event.kind === 'proximity_snapshot');
|
||||||
|
const defaults = buildWeaponMechanicTimeline(
|
||||||
|
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||||
|
profile,
|
||||||
|
).filter((event) => event.kind === 'proximity_snapshot');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
configured.map(
|
||||||
|
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||||
|
),
|
||||||
|
).toEqual([4, 4]);
|
||||||
|
expect(configured.map((event) => event.values?.allyCount)).toEqual([2, 2]);
|
||||||
|
expect(
|
||||||
|
defaults.map(
|
||||||
|
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||||
|
),
|
||||||
|
).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
it('seeds Extra Health state before periodic stack evaluation', () => {
|
it('seeds Extra Health state before periodic stack evaluation', () => {
|
||||||
const profile = createDraft().combatProfile;
|
const profile = createDraft().combatProfile;
|
||||||
profile.encounterSeconds = 2;
|
profile.encounterSeconds = 2;
|
||||||
@@ -259,7 +291,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toEqual([1, 2]);
|
).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
|
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
|
||||||
const persistedRule = {
|
const persistedRule = {
|
||||||
rule: {
|
rule: {
|
||||||
id: 'tier-i-only',
|
id: 'tier-i-only',
|
||||||
@@ -327,6 +359,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
const adapted = equippedConditionalProgram({
|
const adapted = equippedConditionalProgram({
|
||||||
key: 'weapon:0:exotic-talent',
|
key: 'weapon:0:exotic-talent',
|
||||||
tier: 2,
|
tier: 2,
|
||||||
|
isExotic: true,
|
||||||
rule: persistedRule,
|
rule: persistedRule,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
} from '$lib/calculations/combat';
|
} from '$lib/calculations/combat';
|
||||||
import {
|
import {
|
||||||
evaluateMechanicProgram,
|
evaluateMechanicProgram,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT,
|
||||||
type MechanicCombatContext,
|
type MechanicCombatContext,
|
||||||
type MechanicEvaluation,
|
type MechanicEvaluation,
|
||||||
type MechanicEvent,
|
type MechanicEvent,
|
||||||
@@ -283,7 +285,7 @@ export function equippedConditionalProgram(
|
|||||||
: 'base';
|
: 'base';
|
||||||
return (
|
return (
|
||||||
conditionalProgram(equipped.rule, variant) ??
|
conditionalProgram(equipped.rule, variant) ??
|
||||||
(equipmentTalent && variant === 'II'
|
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||||
? conditionalProgram(equipped.rule, 'I')
|
? conditionalProgram(equipped.rule, 'I')
|
||||||
: null)
|
: null)
|
||||||
);
|
);
|
||||||
@@ -803,6 +805,11 @@ export function buildWeaponMechanicTimeline(
|
|||||||
allyCount: Number(
|
allyCount: Number(
|
||||||
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
||||||
),
|
),
|
||||||
|
[PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY]: Number(
|
||||||
|
options.scenarioInputs?.[
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT
|
||||||
|
] ?? 0,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ describe('summarizeLoadout', () => {
|
|||||||
});
|
});
|
||||||
expect(summary.equippedTalents).toEqual([
|
expect(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: 'chips:chip-set',
|
key: 'chips:chip-set:ability',
|
||||||
tier: 1,
|
tier: 1,
|
||||||
abilityId: 'ability',
|
abilityId: 'ability',
|
||||||
description: 'Gain 25% Skill Damage.',
|
description: 'Gain 25% Skill Damage.',
|
||||||
@@ -217,6 +217,7 @@ describe('summarizeLoadout', () => {
|
|||||||
|
|
||||||
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
|
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
|
draft.specialization.focusId = 'focus-1';
|
||||||
draft.chips[0] = {
|
draft.chips[0] = {
|
||||||
setId: 'chip-set',
|
setId: 'chip-set',
|
||||||
quality: 'Superior',
|
quality: 'Superior',
|
||||||
@@ -239,11 +240,24 @@ describe('summarizeLoadout', () => {
|
|||||||
{
|
{
|
||||||
id: 'chip-set',
|
id: 'chip-set',
|
||||||
name: 'Signal',
|
name: 'Signal',
|
||||||
specialization_ability_id: 'ability-1',
|
specialization_ability_id: 'ability-2',
|
||||||
|
compatibleAbilityIds: ['ability-2', 'ability-1'],
|
||||||
twoPiece: null,
|
twoPiece: null,
|
||||||
talent: null,
|
talent: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
specializations: [
|
||||||
|
{
|
||||||
|
id: 'specialization-1',
|
||||||
|
focuses: [
|
||||||
|
{
|
||||||
|
id: 'focus-1',
|
||||||
|
abilities: [{ id: 'ability-1' }],
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -272,6 +286,50 @@ describe('summarizeLoadout', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores a shared Skill Chip after its ability link is removed', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.specialization.focusId = 'focus-1';
|
||||||
|
draft.chips[0] = {
|
||||||
|
setId: 'chip-set',
|
||||||
|
quality: 'Standard',
|
||||||
|
primary: { statId: 'skill-duration', value: 12 },
|
||||||
|
secondary: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
|
||||||
|
],
|
||||||
|
chipSets: [
|
||||||
|
{
|
||||||
|
id: 'chip-set',
|
||||||
|
name: 'Signal',
|
||||||
|
specialization_ability_id: 'ability-2',
|
||||||
|
compatibleAbilityIds: ['ability-2'],
|
||||||
|
twoPiece: null,
|
||||||
|
talent: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
specializations: [
|
||||||
|
{
|
||||||
|
id: 'specialization-1',
|
||||||
|
focuses: [
|
||||||
|
{
|
||||||
|
id: 'focus-1',
|
||||||
|
abilities: [{ id: 'ability-1' }],
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.abilityStats).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[0, 10],
|
[0, 10],
|
||||||
[1, 20],
|
[1, 20],
|
||||||
@@ -425,11 +483,58 @@ describe('summarizeLoadout', () => {
|
|||||||
expect(stat(summary, 'mechanic')).toBeUndefined();
|
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
|
it('uses a gear Talent tier independently from the gear tier', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.gear.Holster.itemId = 'gear';
|
||||||
|
draft.gear.Holster.talentId = 'talent';
|
||||||
|
Object.assign(draft.gear.Holster, { talentTier: 2 });
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||||
|
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
|
||||||
|
talents: [
|
||||||
|
{
|
||||||
|
id: 'talent',
|
||||||
|
name: 'Reinforced',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.equippedTalents).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'talent',
|
||||||
|
tier: 2,
|
||||||
|
tierLabel: 'TIER II',
|
||||||
|
description: 'Gain 20%.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
draft.weapons[0].itemId = 'weapon';
|
draft.weapons[0].itemId = 'weapon';
|
||||||
draft.weapons[0].tier = 2;
|
draft.weapons[0].tier = 2;
|
||||||
draft.weapons[0].talent1Id = 'talent';
|
draft.weapons[0].talent1Id = 'talent-one';
|
||||||
|
draft.weapons[0].talent2Id = 'talent-two';
|
||||||
|
Object.assign(draft.weapons[0], {
|
||||||
|
talent1Tier: 1,
|
||||||
|
talent2Tier: 2,
|
||||||
|
exoticTalentTier: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const rule = {
|
const rule = {
|
||||||
clauses: [
|
clauses: [
|
||||||
@@ -455,13 +560,13 @@ describe('summarizeLoadout', () => {
|
|||||||
{
|
{
|
||||||
id: 'weapon',
|
id: 'weapon',
|
||||||
name: 'Test Weapon',
|
name: 'Test Weapon',
|
||||||
exotic_talent_id: null,
|
exotic_talent_id: 'exotic-talent',
|
||||||
baseBonuses: [],
|
baseBonuses: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
talents: [
|
talents: [
|
||||||
{
|
{
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
name: 'Measured',
|
name: 'Measured',
|
||||||
template: 'Gain {1} for {2}.',
|
template: 'Gain {1} for {2}.',
|
||||||
tiers: [
|
tiers: [
|
||||||
@@ -482,17 +587,60 @@ describe('summarizeLoadout', () => {
|
|||||||
],
|
],
|
||||||
rule,
|
rule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'talent-two',
|
||||||
|
name: 'Calibrated',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exotic-talent',
|
||||||
|
name: 'Exotic Talent',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(summary.equippedTalents).toEqual([
|
expect(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
tier: 2,
|
tier: 1,
|
||||||
description: 'Gain 20% for 3 Shots.',
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 10% for 2 Shots.',
|
||||||
rule,
|
rule,
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'talent-two',
|
||||||
|
tier: 2,
|
||||||
|
tierLabel: 'TIER II',
|
||||||
|
description: 'Gain 40%.',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'exotic-talent',
|
||||||
|
tier: 1,
|
||||||
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 50%.',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
import {
|
||||||
|
gearSlots,
|
||||||
|
scaled,
|
||||||
|
type LoadoutDraft,
|
||||||
|
type TalentTier,
|
||||||
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
export type LoadoutCatalog = {
|
export type LoadoutCatalog = {
|
||||||
attributes: any[];
|
attributes: any[];
|
||||||
@@ -100,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
|||||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
|
function talentTier(
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
talentId: string,
|
||||||
|
tier: TalentTier,
|
||||||
|
) {
|
||||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
return (
|
return item?.tiers.find(
|
||||||
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
|
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
|
||||||
item?.tiers[0]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentDescription(
|
function talentDescription(
|
||||||
catalog: LoadoutCatalog,
|
catalog: LoadoutCatalog,
|
||||||
talentId: string,
|
talentId: string,
|
||||||
tier: number,
|
tier: TalentTier,
|
||||||
) {
|
) {
|
||||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
const configured = talentTier(catalog, talentId, tier);
|
const configured = talentTier(catalog, talentId, tier);
|
||||||
@@ -255,12 +263,14 @@ export function summarizeLoadout(
|
|||||||
rule:
|
rule:
|
||||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||||
?.rule ?? null,
|
?.rule ?? null,
|
||||||
tier: Number(item.tier),
|
tier: selection.talentTier,
|
||||||
|
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: false,
|
||||||
source: slot,
|
source: slot,
|
||||||
description: talentDescription(
|
description: talentDescription(
|
||||||
catalog,
|
catalog,
|
||||||
selection.talentId,
|
selection.talentId,
|
||||||
Number(item.tier),
|
selection.talentTier,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -305,27 +315,48 @@ export function summarizeLoadout(
|
|||||||
roll.value,
|
roll.value,
|
||||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||||
);
|
);
|
||||||
for (const talentId of [
|
const equippedWeaponTalents = [
|
||||||
selection.talent1Id,
|
{
|
||||||
selection.talent2Id,
|
id: selection.talent1Id,
|
||||||
item.exotic_talent_id,
|
tier: selection.talent1Tier,
|
||||||
].filter(Boolean)) {
|
isExotic: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: selection.talent2Id,
|
||||||
|
tier: selection.talent2Tier,
|
||||||
|
isExotic: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: item.exotic_talent_id,
|
||||||
|
tier: selection.exoticTalentTier,
|
||||||
|
isExotic: true,
|
||||||
|
},
|
||||||
|
].filter((entry) => entry.id);
|
||||||
|
for (const equippedTalent of equippedWeaponTalents) {
|
||||||
equippedTalents.push({
|
equippedTalents.push({
|
||||||
key: `weapon:${index}:${talentId}`,
|
key: `weapon:${index}:${equippedTalent.id}`,
|
||||||
id: talentId,
|
id: equippedTalent.id,
|
||||||
rule:
|
rule:
|
||||||
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||||
tier: selection.tier,
|
?.rule ?? null,
|
||||||
|
tier: equippedTalent.tier,
|
||||||
|
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: equippedTalent.isExotic,
|
||||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||||
description: talentDescription(catalog, talentId, selection.tier),
|
description: talentDescription(
|
||||||
|
catalog,
|
||||||
|
equippedTalent.id,
|
||||||
|
equippedTalent.tier,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chipGroups = new Map<
|
const chipGroups = new Map<
|
||||||
string,
|
string,
|
||||||
{ count: number; highEnd: number; item: any }
|
{ count: number; highEnd: number; item: any; abilityId: string }
|
||||||
>();
|
>();
|
||||||
|
const focus = selectedFocus(draft, catalog);
|
||||||
const validChipRoll = (
|
const validChipRoll = (
|
||||||
kind: 'primaryOptions' | 'secondaryOptions',
|
kind: 'primaryOptions' | 'secondaryOptions',
|
||||||
quality: string,
|
quality: string,
|
||||||
@@ -345,9 +376,19 @@ export function summarizeLoadout(
|
|||||||
const selection = draft.chips[index];
|
const selection = draft.chips[index];
|
||||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
const slotAbilityId = String(
|
||||||
|
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
|
||||||
|
);
|
||||||
|
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
|
||||||
|
String(item.specialization_ability_id ?? ''),
|
||||||
|
];
|
||||||
|
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
|
||||||
|
continue;
|
||||||
|
const abilityId =
|
||||||
|
slotAbilityId || String(item.specialization_ability_id ?? '');
|
||||||
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
||||||
addAbilityStat(
|
addAbilityStat(
|
||||||
String(item.specialization_ability_id ?? ''),
|
abilityId,
|
||||||
selection.primary.statId,
|
selection.primary.statId,
|
||||||
selection.primary.value,
|
selection.primary.value,
|
||||||
index,
|
index,
|
||||||
@@ -356,10 +397,16 @@ export function summarizeLoadout(
|
|||||||
for (const roll of selection.secondary)
|
for (const roll of selection.secondary)
|
||||||
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
const groupKey = `${item.id}:${abilityId}`;
|
||||||
|
const group = chipGroups.get(groupKey) ?? {
|
||||||
|
count: 0,
|
||||||
|
highEnd: 0,
|
||||||
|
item,
|
||||||
|
abilityId,
|
||||||
|
};
|
||||||
group.count += 1;
|
group.count += 1;
|
||||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||||
chipGroups.set(item.id, group);
|
chipGroups.set(groupKey, group);
|
||||||
}
|
}
|
||||||
for (const group of chipGroups.values()) {
|
for (const group of chipGroups.values()) {
|
||||||
if (group.count >= 2 && group.item.twoPiece) {
|
if (group.count >= 2 && group.item.twoPiece) {
|
||||||
@@ -384,12 +431,12 @@ export function summarizeLoadout(
|
|||||||
}
|
}
|
||||||
if (group.count === 3 && group.item.talent) {
|
if (group.count === 3 && group.item.talent) {
|
||||||
equippedTalents.push({
|
equippedTalents.push({
|
||||||
key: `chips:${group.item.id}`,
|
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||||
id: '',
|
id: '',
|
||||||
custom: group.item.talent,
|
custom: group.item.talent,
|
||||||
rule: group.item.talent.rule ?? null,
|
rule: group.item.talent.rule ?? null,
|
||||||
tier: group.highEnd,
|
tier: group.highEnd,
|
||||||
abilityId: group.item.specialization_ability_id ?? null,
|
abilityId: group.abilityId || null,
|
||||||
source: '3 Skill Chips',
|
source: '3 Skill Chips',
|
||||||
description: group.item.talent.description_template.replace(
|
description: group.item.talent.description_template.replace(
|
||||||
/\{(\d+)\}/g,
|
/\{(\d+)\}/g,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createDraft,
|
createDraft,
|
||||||
|
migrateLoadoutDraftV5,
|
||||||
parseLoadoutDraft,
|
parseLoadoutDraft,
|
||||||
skillChipSecondaryCapacity,
|
skillChipSecondaryCapacity,
|
||||||
type SkillChipQuality,
|
type SkillChipQuality,
|
||||||
@@ -8,13 +9,40 @@ import {
|
|||||||
|
|
||||||
const copyDraft = () => structuredClone(createDraft());
|
const copyDraft = () => structuredClone(createDraft());
|
||||||
|
|
||||||
|
const talentTierFields = [
|
||||||
|
[
|
||||||
|
'gear talentTier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.gear.Holster as unknown as Record<string, unknown>,
|
||||||
|
'talentTier',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'weapon talent1Tier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||||
|
'talent1Tier',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'weapon talent2Tier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||||
|
'talent2Tier',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'weapon exoticTalentTier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||||
|
'exoticTalentTier',
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
describe('parseLoadoutDraft', () => {
|
describe('parseLoadoutDraft', () => {
|
||||||
it('accepts a current draft and returns an independent clone', () => {
|
it('accepts a current draft and returns an independent clone', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
const parsed = parseLoadoutDraft(draft);
|
const parsed = parseLoadoutDraft(draft);
|
||||||
|
|
||||||
expect(parsed).toEqual(draft);
|
expect(parsed).toEqual(draft);
|
||||||
expect(parsed?.version).toBe(5);
|
expect(parsed?.version).toBe(6);
|
||||||
expect(parsed?.chips[0]).toEqual({
|
expect(parsed?.chips[0]).toEqual({
|
||||||
setId: '',
|
setId: '',
|
||||||
quality: 'Standard',
|
quality: 'Standard',
|
||||||
@@ -27,6 +55,37 @@ describe('parseLoadoutDraft', () => {
|
|||||||
expect(draft.gear.Holster.level).toBe(50);
|
expect(draft.gear.Holster.level).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults every equipment Talent tier to Tier I', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Object.values(draft.gear).map(
|
||||||
|
(selection) =>
|
||||||
|
(selection as unknown as Record<string, unknown>).talentTier,
|
||||||
|
),
|
||||||
|
).toEqual(Array(6).fill(1));
|
||||||
|
expect(
|
||||||
|
draft.weapons.map((selection) => {
|
||||||
|
const weapon = selection as unknown as Record<string, unknown>;
|
||||||
|
return [
|
||||||
|
weapon.talent1Tier,
|
||||||
|
weapon.talent2Tier,
|
||||||
|
weapon.exoticTalentTier,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
[1, 1, 1],
|
||||||
|
[1, 1, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts Tier II for every equipment Talent selection', () => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects the retired version 1 three-chip shape', () => {
|
it('rejects the retired version 1 three-chip shape', () => {
|
||||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
retired.version = 1;
|
retired.version = 1;
|
||||||
@@ -42,13 +101,77 @@ describe('parseLoadoutDraft', () => {
|
|||||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a version 5 draft that does not contain exactly nine chips', () => {
|
it('rejects the retired version 5 schema', () => {
|
||||||
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
|
retired.version = 5;
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates version 5 equipment-derived Talent tiers once', () => {
|
||||||
|
const legacy = copyDraft() as unknown as Record<string, any>;
|
||||||
|
legacy.version = 5;
|
||||||
|
for (const selection of Object.values(legacy.gear) as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>[]) {
|
||||||
|
delete selection.talentTier;
|
||||||
|
}
|
||||||
|
for (const selection of legacy.weapons as Record<string, any>[]) {
|
||||||
|
delete selection.talent1Tier;
|
||||||
|
delete selection.talent2Tier;
|
||||||
|
delete selection.exoticTalentTier;
|
||||||
|
}
|
||||||
|
legacy.gear.Holster.itemId = 'tier-two-gear';
|
||||||
|
legacy.weapons[0].itemId = 'exotic-weapon';
|
||||||
|
legacy.weapons[0].tier = 2;
|
||||||
|
|
||||||
|
const migrated = migrateLoadoutDraftV5(legacy, {
|
||||||
|
gear: [{ id: 'tier-two-gear', tier: 2 }],
|
||||||
|
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
|
||||||
|
talents: [
|
||||||
|
{
|
||||||
|
id: 'exotic-talent',
|
||||||
|
tiers: [{ tier: 'I' }, { tier: 'II' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(migrated?.version).toBe(6);
|
||||||
|
expect(migrated?.gear.Holster.talentTier).toBe(2);
|
||||||
|
expect(migrated?.gear.Gloves.talentTier).toBe(1);
|
||||||
|
expect(migrated?.weapons[0]).toMatchObject({
|
||||||
|
talent1Tier: 2,
|
||||||
|
talent2Tier: 2,
|
||||||
|
exoticTalentTier: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a current draft that does not contain exactly nine chips', () => {
|
||||||
const draft = copyDraft();
|
const draft = copyDraft();
|
||||||
draft.chips.pop();
|
draft.chips.pop();
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
delete select(draft)[key];
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
talentTierFields.flatMap(([name, select, key]) =>
|
||||||
|
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
|
||||||
|
),
|
||||||
|
)('rejects %s value %s', (_, value, select, key) => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
select(draft)[key] = value;
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['Standard', 0],
|
['Standard', 0],
|
||||||
['Specialized', 1],
|
['Specialized', 1],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type Roll = { statId: string; value: number };
|
export type Roll = { statId: string; value: number };
|
||||||
|
export type TalentTier = 1 | 2;
|
||||||
export const skillChipQualities = [
|
export const skillChipQualities = [
|
||||||
'Standard',
|
'Standard',
|
||||||
'Specialized',
|
'Specialized',
|
||||||
@@ -17,6 +18,7 @@ export type GearSelection = {
|
|||||||
level: number;
|
level: number;
|
||||||
brandId: string;
|
brandId: string;
|
||||||
talentId: string;
|
talentId: string;
|
||||||
|
talentTier: TalentTier;
|
||||||
health: number;
|
health: number;
|
||||||
armor: number;
|
armor: number;
|
||||||
firepower: number;
|
firepower: number;
|
||||||
@@ -34,7 +36,10 @@ export type WeaponSelection = {
|
|||||||
toughness: number;
|
toughness: number;
|
||||||
engineering: number;
|
engineering: number;
|
||||||
talent1Id: string;
|
talent1Id: string;
|
||||||
|
talent1Tier: TalentTier;
|
||||||
talent2Id: string;
|
talent2Id: string;
|
||||||
|
talent2Tier: TalentTier;
|
||||||
|
exoticTalentTier: TalentTier;
|
||||||
rolls: [Roll, Roll];
|
rolls: [Roll, Roll];
|
||||||
};
|
};
|
||||||
export type ChipSelection = {
|
export type ChipSelection = {
|
||||||
@@ -81,7 +86,7 @@ export type SpecializationSelection = {
|
|||||||
nodeRanks: Record<string, number>;
|
nodeRanks: Record<string, number>;
|
||||||
};
|
};
|
||||||
export type LoadoutDraft = {
|
export type LoadoutDraft = {
|
||||||
version: 5;
|
version: 6;
|
||||||
buildName: string;
|
buildName: string;
|
||||||
calculationMode: CalculationMode;
|
calculationMode: CalculationMode;
|
||||||
gear: Record<string, GearSelection>;
|
gear: Record<string, GearSelection>;
|
||||||
@@ -118,6 +123,7 @@ export const blankGear = (): GearSelection => ({
|
|||||||
level: 50,
|
level: 50,
|
||||||
brandId: '',
|
brandId: '',
|
||||||
talentId: '',
|
talentId: '',
|
||||||
|
talentTier: 1,
|
||||||
health: 0,
|
health: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
firepower: 0,
|
firepower: 0,
|
||||||
@@ -135,7 +141,10 @@ export const blankWeapon = (): WeaponSelection => ({
|
|||||||
toughness: 0,
|
toughness: 0,
|
||||||
engineering: 0,
|
engineering: 0,
|
||||||
talent1Id: '',
|
talent1Id: '',
|
||||||
|
talent1Tier: 1,
|
||||||
talent2Id: '',
|
talent2Id: '',
|
||||||
|
talent2Tier: 1,
|
||||||
|
exoticTalentTier: 1,
|
||||||
rolls: [blankRoll(), blankRoll()],
|
rolls: [blankRoll(), blankRoll()],
|
||||||
});
|
});
|
||||||
export const blankChip = (): ChipSelection => ({
|
export const blankChip = (): ChipSelection => ({
|
||||||
@@ -161,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
|||||||
extraHealthActive: false,
|
extraHealthActive: false,
|
||||||
});
|
});
|
||||||
export const createDraft = (): LoadoutDraft => ({
|
export const createDraft = (): LoadoutDraft => ({
|
||||||
version: 5,
|
version: 6,
|
||||||
buildName: 'Untitled loadout',
|
buildName: 'Untitled loadout',
|
||||||
calculationMode: 'maximum',
|
calculationMode: 'maximum',
|
||||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||||
@@ -187,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function migrateLoadoutDraftV5(
|
||||||
|
value: unknown,
|
||||||
|
catalog: { gear: any[]; weapons: any[]; talents: any[] },
|
||||||
|
): LoadoutDraft | null {
|
||||||
|
if (!isRecord(value) || value.version !== 5) return null;
|
||||||
|
const migrated = structuredClone(value);
|
||||||
|
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
|
||||||
|
|
||||||
|
for (const slot of gearSlots) {
|
||||||
|
const selection = migrated.gear[slot];
|
||||||
|
if (!isRecord(selection)) return null;
|
||||||
|
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||||
|
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
for (const selection of migrated.weapons) {
|
||||||
|
if (!isRecord(selection)) return null;
|
||||||
|
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
|
||||||
|
selection.talent1Tier = inheritedTier;
|
||||||
|
selection.talent2Tier = inheritedTier;
|
||||||
|
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||||
|
const exoticTalent = catalog.talents.find(
|
||||||
|
(entry) => entry.id === item?.exotic_talent_id,
|
||||||
|
);
|
||||||
|
selection.exoticTalentTier =
|
||||||
|
inheritedTier === 2 &&
|
||||||
|
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
|
||||||
|
? 2
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
migrated.version = 6;
|
||||||
|
return parseLoadoutDraft(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
||||||
@@ -194,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
|||||||
keys.every((key) => key in value);
|
keys.every((key) => key in value);
|
||||||
const isFiniteNumber = (value: unknown): value is number =>
|
const isFiniteNumber = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isFinite(value);
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
const isTalentTier = (value: unknown): value is TalentTier =>
|
||||||
|
value === 1 || value === 2;
|
||||||
const isNumberRecord = (value: unknown) =>
|
const isNumberRecord = (value: unknown) =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
Object.values(value).every(
|
Object.values(value).every(
|
||||||
@@ -213,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
|||||||
'level',
|
'level',
|
||||||
'brandId',
|
'brandId',
|
||||||
'talentId',
|
'talentId',
|
||||||
|
'talentTier',
|
||||||
'health',
|
'health',
|
||||||
'armor',
|
'armor',
|
||||||
'firepower',
|
'firepower',
|
||||||
@@ -224,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
|||||||
['itemId', 'brandId', 'talentId'].every(
|
['itemId', 'brandId', 'talentId'].every(
|
||||||
(key) => typeof value[key] === 'string',
|
(key) => typeof value[key] === 'string',
|
||||||
) &&
|
) &&
|
||||||
|
isTalentTier(value.talentTier) &&
|
||||||
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
@@ -241,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
|||||||
'toughness',
|
'toughness',
|
||||||
'engineering',
|
'engineering',
|
||||||
'talent1Id',
|
'talent1Id',
|
||||||
|
'talent1Tier',
|
||||||
'talent2Id',
|
'talent2Id',
|
||||||
|
'talent2Tier',
|
||||||
|
'exoticTalentTier',
|
||||||
'rolls',
|
'rolls',
|
||||||
]) &&
|
]) &&
|
||||||
['itemId', 'talent1Id', 'talent2Id'].every(
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||||
(key) => typeof value[key] === 'string',
|
(key) => typeof value[key] === 'string',
|
||||||
) &&
|
) &&
|
||||||
|
isTalentTier(value.talent1Tier) &&
|
||||||
|
isTalentTier(value.talent2Tier) &&
|
||||||
|
isTalentTier(value.exoticTalentTier) &&
|
||||||
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
@@ -369,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
|||||||
'skillActivations',
|
'skillActivations',
|
||||||
'specialization',
|
'specialization',
|
||||||
]) ||
|
]) ||
|
||||||
value.version !== 5 ||
|
value.version !== 6 ||
|
||||||
typeof value.buildName !== 'string' ||
|
typeof value.buildName !== 'string' ||
|
||||||
!['maximum', 'custom'].includes(String(value.calculationMode))
|
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
eventFieldNumericExpression,
|
||||||
mechanicTargetingForMode,
|
mechanicTargetingForMode,
|
||||||
mechanicTargetingMode,
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
|
readEventFieldNumericExpression,
|
||||||
registeredEventFields,
|
registeredEventFields,
|
||||||
readStatePoolThreshold,
|
readStatePoolThreshold,
|
||||||
statePoolThresholdPredicate,
|
statePoolThresholdPredicate,
|
||||||
@@ -202,6 +204,108 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips the registered proximity count as a derived scalar and resolves each variant display value', () => {
|
||||||
|
const path = 'values.alliesWithin30MetersWithAgentGrantedBuff';
|
||||||
|
expect(registeredEventFields('proximity_snapshot')).toContainEqual({
|
||||||
|
path,
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expression = eventFieldNumericExpression(path);
|
||||||
|
expect(readEventFieldNumericExpression(expression)).toBe(path);
|
||||||
|
expect(
|
||||||
|
readEventFieldNumericExpression({
|
||||||
|
op: 'multiply',
|
||||||
|
left: expression,
|
||||||
|
right: 2,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const value = draft();
|
||||||
|
value.clauses[0].event = 'proximity_snapshot';
|
||||||
|
value.clauses[0].recipeKey = undefined;
|
||||||
|
value.clauses[0].reducer = {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: expression,
|
||||||
|
};
|
||||||
|
value.clauses[0].mutations = [
|
||||||
|
{
|
||||||
|
kind: 'derive',
|
||||||
|
pool: 'effect',
|
||||||
|
value: { ref: { source: 'reducer' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const upgradeScale = {
|
||||||
|
op: 'add' as const,
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide' as const,
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment' as const,
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.clauses[0].outcomes[0].variants = {
|
||||||
|
I: {
|
||||||
|
value: 7.25,
|
||||||
|
expression: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: upgradeScale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
II: {
|
||||||
|
value: 9.5,
|
||||||
|
expression: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: upgradeScale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseConditionalMechanicDraft(value, {
|
||||||
|
variantKeys: ['I', 'II'],
|
||||||
|
allowedAttributeIds: new Set(['weapon-damage']),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value.draft.clauses[0]).toMatchObject({
|
||||||
|
event: 'proximity_snapshot',
|
||||||
|
reducer: { kind: 'derived_scalar', value: expression },
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
I: {
|
||||||
|
value: 7.25,
|
||||||
|
expression: {
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.value.programs.I.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 7.25,
|
||||||
|
right: upgradeScale,
|
||||||
|
});
|
||||||
|
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 9.5,
|
||||||
|
right: upgradeScale,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@@ -791,9 +895,9 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid pre-event timing for current Health percentage references', () => {
|
it('preserves pre-event timing for current Health percentage references', () => {
|
||||||
const invalid = draft();
|
const value = draft();
|
||||||
invalid.clauses[0].if = {
|
value.clauses[0].if = {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
source: 'combat',
|
source: 'combat',
|
||||||
@@ -803,11 +907,13 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 50,
|
right: 50,
|
||||||
};
|
};
|
||||||
const result = parseConditionalMechanicDraft(invalid, {
|
const result = parseConditionalMechanicDraft(value, {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowedAttributeIds: new Set(['weapon-damage']),
|
allowedAttributeIds: new Set(['weapon-damage']),
|
||||||
});
|
});
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
|
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
|
||||||
|
|||||||
@@ -39,9 +39,28 @@ export type ConditionalMechanicPoolDraft = PoolDefinition & {
|
|||||||
expiresAfterSecondsVariants?: Record<string, number>;
|
expiresAfterSecondsVariants?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConditionalMechanicValueReference =
|
||||||
|
ValueReference | { source: 'variant_value' };
|
||||||
|
|
||||||
|
export type ConditionalNumericExpression =
|
||||||
|
| number
|
||||||
|
| { ref: ConditionalMechanicValueReference }
|
||||||
|
| { op: 'floor'; value: ConditionalNumericExpression }
|
||||||
|
| {
|
||||||
|
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
|
||||||
|
left: ConditionalNumericExpression;
|
||||||
|
right: ConditionalNumericExpression;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: 'clamp';
|
||||||
|
value: ConditionalNumericExpression;
|
||||||
|
min: ConditionalNumericExpression;
|
||||||
|
max: ConditionalNumericExpression;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConditionalMechanicVariantValue = {
|
export type ConditionalMechanicVariantValue = {
|
||||||
value: number;
|
value: number;
|
||||||
expression?: NumericExpression;
|
expression?: ConditionalNumericExpression;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionalMechanicOutcomeDraft = Omit<
|
export type ConditionalMechanicOutcomeDraft = Omit<
|
||||||
@@ -200,6 +219,21 @@ export function registeredEventFields(event: MechanicEventKind) {
|
|||||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function eventFieldNumericExpression(path: string): NumericExpression {
|
||||||
|
return { ref: { source: 'event', path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readEventFieldNumericExpression(
|
||||||
|
expression: NumericExpression,
|
||||||
|
): string | null {
|
||||||
|
return typeof expression === 'object' &&
|
||||||
|
'ref' in expression &&
|
||||||
|
expression.ref.source === 'event' &&
|
||||||
|
expression.ref.path.trim()
|
||||||
|
? expression.ref.path
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export type MechanicTargetingMode =
|
export type MechanicTargetingMode =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'allies_within_radius'
|
| 'allies_within_radius'
|
||||||
@@ -426,8 +460,7 @@ function isReference(value: unknown): value is ValueReference {
|
|||||||
Boolean(value.path.trim()) &&
|
Boolean(value.path.trim()) &&
|
||||||
(value.timing === undefined ||
|
(value.timing === undefined ||
|
||||||
value.timing === 'pre_event' ||
|
value.timing === 'pre_event' ||
|
||||||
value.timing === 'current') &&
|
value.timing === 'current')
|
||||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
|
||||||
);
|
);
|
||||||
case 'equipment':
|
case 'equipment':
|
||||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||||
@@ -453,6 +486,14 @@ function isReference(value: unknown): value is ValueReference {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConditionalReference(
|
||||||
|
value: unknown,
|
||||||
|
): value is ConditionalMechanicValueReference {
|
||||||
|
return (
|
||||||
|
(object(value) && value.source === 'variant_value') || isReference(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateAttributeReferences(
|
function validateAttributeReferences(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
allowedAttributeIds: ReadonlySet<string> | undefined,
|
allowedAttributeIds: ReadonlySet<string> | undefined,
|
||||||
@@ -512,6 +553,29 @@ function isExpression(value: unknown): value is NumericExpression {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConditionalExpression(
|
||||||
|
value: unknown,
|
||||||
|
): value is ConditionalNumericExpression {
|
||||||
|
if (finite(value)) return true;
|
||||||
|
if (!object(value)) return false;
|
||||||
|
if ('ref' in value) return isConditionalReference(value.ref);
|
||||||
|
if (value.op === 'floor') return isConditionalExpression(value.value);
|
||||||
|
if (value.op === 'clamp') {
|
||||||
|
return (
|
||||||
|
isConditionalExpression(value.value) &&
|
||||||
|
isConditionalExpression(value.min) &&
|
||||||
|
isConditionalExpression(value.max)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||||
|
String(value.op),
|
||||||
|
) &&
|
||||||
|
isConditionalExpression(value.left) &&
|
||||||
|
isConditionalExpression(value.right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isOperand(value: unknown) {
|
function isOperand(value: unknown) {
|
||||||
return scalar(value) || isReference(value);
|
return scalar(value) || isReference(value);
|
||||||
}
|
}
|
||||||
@@ -839,7 +903,7 @@ function parseOutcome(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
configured.expression !== undefined &&
|
configured.expression !== undefined &&
|
||||||
!isExpression(configured.expression)
|
!isConditionalExpression(configured.expression)
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path} variant "${variant}" has an invalid value expression.`,
|
`${path} variant "${variant}" has an invalid value expression.`,
|
||||||
@@ -955,6 +1019,37 @@ function parseClauses(
|
|||||||
return clauses;
|
return clauses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveVariantExpression(
|
||||||
|
expression: ConditionalNumericExpression,
|
||||||
|
variantValue: number,
|
||||||
|
): NumericExpression {
|
||||||
|
if (typeof expression === 'number') return expression;
|
||||||
|
if ('ref' in expression) {
|
||||||
|
return expression.ref.source === 'variant_value'
|
||||||
|
? variantValue
|
||||||
|
: { ref: expression.ref };
|
||||||
|
}
|
||||||
|
if (expression.op === 'floor') {
|
||||||
|
return {
|
||||||
|
op: 'floor',
|
||||||
|
value: resolveVariantExpression(expression.value, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (expression.op === 'clamp') {
|
||||||
|
return {
|
||||||
|
op: 'clamp',
|
||||||
|
value: resolveVariantExpression(expression.value, variantValue),
|
||||||
|
min: resolveVariantExpression(expression.min, variantValue),
|
||||||
|
max: resolveVariantExpression(expression.max, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
op: expression.op,
|
||||||
|
left: resolveVariantExpression(expression.left, variantValue),
|
||||||
|
right: resolveVariantExpression(expression.right, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function programsForDraft(
|
function programsForDraft(
|
||||||
draft: ConditionalMechanicDraftV1,
|
draft: ConditionalMechanicDraftV1,
|
||||||
options: ParseConditionalMechanicDraftOptions,
|
options: ParseConditionalMechanicDraftOptions,
|
||||||
@@ -1003,7 +1098,13 @@ function programsForDraft(
|
|||||||
}) => ({
|
}) => ({
|
||||||
...outcome,
|
...outcome,
|
||||||
id: outcomeKey,
|
id: outcomeKey,
|
||||||
value: variants[variant].expression ?? variants[variant].value,
|
value:
|
||||||
|
variants[variant].expression === undefined
|
||||||
|
? variants[variant].value
|
||||||
|
: resolveVariantExpression(
|
||||||
|
variants[variant].expression,
|
||||||
|
variants[variant].value,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { evaluateMechanicProgram } from './mechanic.js';
|
||||||
import {
|
import {
|
||||||
adaptConditionalEffectProgram,
|
adaptConditionalEffectProgram,
|
||||||
type ConditionalEffectClauseRow,
|
type ConditionalEffectClauseRow,
|
||||||
@@ -850,7 +851,7 @@ describe('conditional-effect persistence adapter', () => {
|
|||||||
left: {
|
left: {
|
||||||
source: 'combat',
|
source: 'combat',
|
||||||
path: 'currentHealthPercent',
|
path: 'currentHealthPercent',
|
||||||
timing: 'current',
|
timing: 'pre_event',
|
||||||
},
|
},
|
||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 25,
|
right: 25,
|
||||||
@@ -912,4 +913,77 @@ describe('conditional-effect persistence adapter', () => {
|
|||||||
if: condition,
|
if: condition,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('preserves a variant-value expression in persisted JSON while adapting it to the selected row value', () => {
|
||||||
|
const sourceExpression = {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: {
|
||||||
|
op: 'add',
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide',
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment',
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const serializedExpression = JSON.stringify(sourceExpression);
|
||||||
|
const persistedOutcome = outcome({
|
||||||
|
state_pool_id: null,
|
||||||
|
value: 7.25,
|
||||||
|
value_expression_json: serializedExpression,
|
||||||
|
value_scaling: 'flat',
|
||||||
|
application_phase: 'on_event',
|
||||||
|
});
|
||||||
|
const result = adaptConditionalEffectProgram(
|
||||||
|
{
|
||||||
|
rule: rule(),
|
||||||
|
statePools: [],
|
||||||
|
clauses: [
|
||||||
|
clause({
|
||||||
|
trigger_id: null,
|
||||||
|
event_kind: 'tick',
|
||||||
|
parameters_json: '{}',
|
||||||
|
state_pool_id: null,
|
||||||
|
mutations_json: '[]',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outcomes: [persistedOutcome],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
{ variantKey: 'I' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||||
|
expect(persistedOutcome.value).toBe(7.25);
|
||||||
|
expect(persistedOutcome.value_expression_json).toBe(serializedExpression);
|
||||||
|
expect(JSON.parse(String(persistedOutcome.value_expression_json))).toEqual(
|
||||||
|
sourceExpression,
|
||||||
|
);
|
||||||
|
expect(result.program?.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 7.25,
|
||||||
|
right: sourceExpression.right,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.program) return;
|
||||||
|
const runtime = evaluateMechanicProgram(
|
||||||
|
result.program,
|
||||||
|
[{ kind: 'tick', at: 0 }],
|
||||||
|
{ mode: 'custom', encounterSeconds: 1 },
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: 5 },
|
||||||
|
);
|
||||||
|
expect(runtime.status).toBe('OK');
|
||||||
|
expect(runtime.outcomes[0]).toMatchObject({
|
||||||
|
outcomeId: 'outcome-1',
|
||||||
|
value: 14.5,
|
||||||
|
resolvedValue: 14.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type Predicate,
|
type Predicate,
|
||||||
type Reducer,
|
type Reducer,
|
||||||
type ScenarioInputDefinition,
|
type ScenarioInputDefinition,
|
||||||
|
type ValueReference,
|
||||||
} from './mechanic.js';
|
} from './mechanic.js';
|
||||||
|
|
||||||
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
||||||
@@ -216,8 +217,7 @@ function isValueReference(value: unknown) {
|
|||||||
Boolean(value.path.trim()) &&
|
Boolean(value.path.trim()) &&
|
||||||
(value.timing === undefined ||
|
(value.timing === undefined ||
|
||||||
value.timing === 'pre_event' ||
|
value.timing === 'pre_event' ||
|
||||||
value.timing === 'current') &&
|
value.timing === 'current')
|
||||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (value.source === 'attribute') {
|
if (value.source === 'attribute') {
|
||||||
@@ -258,6 +258,55 @@ function isNumericExpression(value: unknown): value is NumericExpression {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveVariantExpression(
|
||||||
|
value: unknown,
|
||||||
|
variantValue: number,
|
||||||
|
): NumericExpression | null {
|
||||||
|
if (finite(value)) return value;
|
||||||
|
if (!object(value)) return null;
|
||||||
|
if ('ref' in value) {
|
||||||
|
if (
|
||||||
|
object(value.ref) &&
|
||||||
|
value.ref.source === 'variant_value' &&
|
||||||
|
Object.keys(value.ref).length === 1
|
||||||
|
) {
|
||||||
|
return variantValue;
|
||||||
|
}
|
||||||
|
return isValueReference(value.ref)
|
||||||
|
? { ref: value.ref as ValueReference }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (value.op === 'floor') {
|
||||||
|
const nested = resolveVariantExpression(value.value, variantValue);
|
||||||
|
return nested === null ? null : { op: 'floor', value: nested };
|
||||||
|
}
|
||||||
|
if (value.op === 'clamp') {
|
||||||
|
const nested = resolveVariantExpression(value.value, variantValue);
|
||||||
|
const min = resolveVariantExpression(value.min, variantValue);
|
||||||
|
const max = resolveVariantExpression(value.max, variantValue);
|
||||||
|
return nested === null || min === null || max === null
|
||||||
|
? null
|
||||||
|
: { op: 'clamp', value: nested, min, max };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||||
|
String(value.op),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const left = resolveVariantExpression(value.left, variantValue);
|
||||||
|
const right = resolveVariantExpression(value.right, variantValue);
|
||||||
|
return left === null || right === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
op: value.op as
|
||||||
|
'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max',
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isPredicate(value: unknown): value is Predicate {
|
function isPredicate(value: unknown): value is Predicate {
|
||||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||||
if (value.kind === 'true') return true;
|
if (value.kind === 'true') return true;
|
||||||
@@ -674,11 +723,8 @@ function parseOutcome(
|
|||||||
) {
|
) {
|
||||||
errors.push(`${path} has an unsupported application phase.`);
|
errors.push(`${path} has an unsupported application phase.`);
|
||||||
}
|
}
|
||||||
let value: NumericExpression = requiredNumber(
|
const variantValue = requiredNumber(row.value, `${path} value`, errors);
|
||||||
row.value,
|
let value: NumericExpression = variantValue;
|
||||||
`${path} value`,
|
|
||||||
errors,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
row.value_expression_json !== null &&
|
row.value_expression_json !== null &&
|
||||||
row.value_expression_json !== undefined &&
|
row.value_expression_json !== undefined &&
|
||||||
@@ -690,7 +736,8 @@ function parseOutcome(
|
|||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
if (parsed.ok) {
|
if (parsed.ok) {
|
||||||
if (isNumericExpression(parsed.value)) value = parsed.value;
|
const resolved = resolveVariantExpression(parsed.value, variantValue);
|
||||||
|
if (resolved !== null) value = resolved;
|
||||||
else
|
else
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path} value expression is not a valid numeric expression.`,
|
`${path} value expression is not a valid numeric expression.`,
|
||||||
|
|||||||
@@ -19,6 +19,84 @@ const custom = (
|
|||||||
inputs,
|
inputs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeAndPersonalProgram = (
|
||||||
|
resolvedVariantValue: number,
|
||||||
|
): MechanicProgram => ({
|
||||||
|
id: 'close-and-personal-engineering',
|
||||||
|
name: 'Close and Personal Engineering',
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
key: 'eligible-allies',
|
||||||
|
label: 'Eligible Allies',
|
||||||
|
scope: 'agent',
|
||||||
|
initial: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clauses: [
|
||||||
|
{
|
||||||
|
id: 'sample-eligible-allies',
|
||||||
|
when: 'proximity_snapshot',
|
||||||
|
reducer: {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: {
|
||||||
|
ref: {
|
||||||
|
source: 'event',
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: [
|
||||||
|
{
|
||||||
|
kind: 'derive',
|
||||||
|
pool: 'eligible-allies',
|
||||||
|
value: { ref: { source: 'reducer' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resolve-engineering',
|
||||||
|
when: 'tick',
|
||||||
|
reducer: { kind: 'none' },
|
||||||
|
mutations: [{ kind: 'none' }],
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
id: 'engineering',
|
||||||
|
label: 'Engineering',
|
||||||
|
phase: 'on_event',
|
||||||
|
kind: 'attribute_modifier',
|
||||||
|
scope: 'self',
|
||||||
|
basis: 'flat',
|
||||||
|
attributeId: 'engineering',
|
||||||
|
value: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'pool', pool: 'eligible-allies' } },
|
||||||
|
right: resolvedVariantValue,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
op: 'add',
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide',
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment',
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||||
const program: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
@@ -321,6 +399,23 @@ describe('structured mechanic runtime', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('advertises the optional non-negative integer proximity count without making snapshots strict', () => {
|
||||||
|
const definition = MECHANIC_EVENT_REGISTRY.proximity_snapshot;
|
||||||
|
const countField = definition.fields?.find(
|
||||||
|
({ path }) => path === 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definition.strictFields).not.toBe(true);
|
||||||
|
expect(countField).toEqual({
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
expect(countField?.required).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('advertises imported support and equipment-use trigger events', () => {
|
it('advertises imported support and equipment-use trigger events', () => {
|
||||||
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
||||||
key: 'ally_downed',
|
key: 'ally_downed',
|
||||||
@@ -1306,6 +1401,7 @@ describe('structured mechanic runtime', () => {
|
|||||||
],
|
],
|
||||||
custom(),
|
custom(),
|
||||||
);
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
expect(result.finalState[0].value).toBe(2);
|
expect(result.finalState[0].value).toBe(2);
|
||||||
const maximum = evaluateMechanicProgram(program, [], {
|
const maximum = evaluateMechanicProgram(program, [], {
|
||||||
mode: 'maximum',
|
mode: 'maximum',
|
||||||
@@ -1314,6 +1410,81 @@ describe('structured mechanic runtime', () => {
|
|||||||
expect(maximum.finalState[0].value).toBe(3);
|
expect(maximum.finalState[0].value).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ count: 0, upgrades: 0, multiplier: 0 },
|
||||||
|
{ count: 3, upgrades: 0, multiplier: 3 },
|
||||||
|
{ count: 3, upgrades: 5, multiplier: 6 },
|
||||||
|
])(
|
||||||
|
'resolves Close-and-Personal-style Engineering for count $count and $upgrades OS upgrades',
|
||||||
|
({ count, upgrades, multiplier }) => {
|
||||||
|
const resolvedVariantValue = 7.25;
|
||||||
|
const result = evaluateMechanicProgram(
|
||||||
|
closeAndPersonalProgram(resolvedVariantValue),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'proximity_snapshot',
|
||||||
|
at: 0,
|
||||||
|
values: {
|
||||||
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'tick', at: 1 },
|
||||||
|
],
|
||||||
|
custom(),
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: upgrades },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
expect(result.finalState).toContainEqual(
|
||||||
|
expect.objectContaining({ pool: 'eligible-allies', value: count }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.outcomes.find(({ outcomeId }) => outcomeId === 'engineering'),
|
||||||
|
).toMatchObject({
|
||||||
|
value: resolvedVariantValue * multiplier,
|
||||||
|
resolvedValue: resolvedVariantValue * multiplier,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
count: '2',
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be a number.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: -1,
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be 0 or greater.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: 1.5,
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be an integer.',
|
||||||
|
},
|
||||||
|
])('rejects invalid proximity ally count $count', ({ count, error }) => {
|
||||||
|
const result = evaluateMechanicProgram(
|
||||||
|
closeAndPersonalProgram(7.25),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'proximity_snapshot',
|
||||||
|
at: 0,
|
||||||
|
values: {
|
||||||
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom(),
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('INVALID');
|
||||||
|
expect(result.errors).toContain(`Event 1 (Proximity snapshot) ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('shares one movement charge pool across generation and consumption clauses', () => {
|
it('shares one movement charge pool across generation and consumption clauses', () => {
|
||||||
const program: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
id: 'mobile-charge',
|
id: 'mobile-charge',
|
||||||
@@ -2834,13 +3005,13 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects pre-event timing for the derived current Health percentage', () => {
|
it('evaluates pre-event timing for the derived current Health percentage', () => {
|
||||||
const invalid: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
id: 'invalid-health-percent',
|
id: 'pre-event-health-percent',
|
||||||
name: 'Invalid Health Percent',
|
name: 'Pre-event Health Percent',
|
||||||
clauses: [
|
clauses: [
|
||||||
{
|
{
|
||||||
id: 'invalid',
|
id: 'full-health-before-event',
|
||||||
when: 'tick',
|
when: 'tick',
|
||||||
if: {
|
if: {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
@@ -2849,25 +3020,32 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
path: 'currentHealthPercent',
|
path: 'currentHealthPercent',
|
||||||
timing: 'pre_event',
|
timing: 'pre_event',
|
||||||
},
|
},
|
||||||
operator: 'lt',
|
operator: 'gte',
|
||||||
right: 50,
|
right: 100,
|
||||||
},
|
},
|
||||||
reducer: { kind: 'none' },
|
reducer: { kind: 'none' },
|
||||||
mutations: [],
|
mutations: [],
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
id: 'matched',
|
||||||
|
label: 'Matched',
|
||||||
|
phase: 'on_event',
|
||||||
|
kind: 'marker',
|
||||||
|
scope: 'self',
|
||||||
|
basis: 'flat',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = validateMechanicProgram(invalid);
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||||
expect(result.ok).toBe(false);
|
expect(
|
||||||
if (!result.ok) {
|
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
|
||||||
expect(result.errors).toEqual(
|
maxHealth: 100,
|
||||||
expect.arrayContaining([
|
currentHealth: 100,
|
||||||
expect.stringContaining(
|
}).outcomes.map((outcome) => outcome.outcomeId),
|
||||||
'current Health percentage must use current timing',
|
).toEqual(['matched']);
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats nested conditions and state operations deterministically', () => {
|
it('formats nested conditions and state operations deterministically', () => {
|
||||||
@@ -3104,6 +3282,42 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
expect(
|
expect(
|
||||||
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
|
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
|
const skillProgram: MechanicProgram = {
|
||||||
|
...program,
|
||||||
|
id: 'skill-hit-explosion',
|
||||||
|
clauses: program.clauses.map((clause) => ({
|
||||||
|
...clause,
|
||||||
|
when: 'skill_hit',
|
||||||
|
if: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
evaluateMechanicProgram(
|
||||||
|
skillProgram,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'skill_hit',
|
||||||
|
at: 1,
|
||||||
|
targetId: 'enemy-b',
|
||||||
|
areaTargetSample: {
|
||||||
|
center: 'event_target',
|
||||||
|
validTargets: [
|
||||||
|
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||||
|
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom(5),
|
||||||
|
{
|
||||||
|
maxHealth: 1000,
|
||||||
|
currentHealth: 1000,
|
||||||
|
attributes: { firepower: 100 },
|
||||||
|
},
|
||||||
|
).outcomes.map((outcome) => outcome.targetId),
|
||||||
|
).toEqual(['enemy-a', 'enemy-b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires authoritative area evidence only when an area outcome activates', () => {
|
it('requires authoritative area evidence only when an area outcome activates', () => {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export const MECHANIC_EVENT_KINDS = [
|
|||||||
'tick',
|
'tick',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY =
|
||||||
|
'alliesWithin30MetersWithAgentGrantedBuff' as const;
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD =
|
||||||
|
`values.${PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY}` as const;
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT =
|
||||||
|
'allies-within-30m-with-agent-granted-buff' as const;
|
||||||
|
|
||||||
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
||||||
export type MechanicEventDefinition = {
|
export type MechanicEventDefinition = {
|
||||||
key: MechanicEventKind;
|
key: MechanicEventKind;
|
||||||
@@ -39,6 +46,7 @@ export type MechanicEventDefinition = {
|
|||||||
type: 'boolean' | 'number' | 'string';
|
type: 'boolean' | 'number' | 'string';
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
minimum?: number;
|
minimum?: number;
|
||||||
|
integer?: boolean;
|
||||||
}[];
|
}[];
|
||||||
strictFields?: boolean;
|
strictFields?: boolean;
|
||||||
};
|
};
|
||||||
@@ -97,6 +105,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
|
|||||||
description:
|
description:
|
||||||
'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.',
|
'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
path: 'values.abilityId',
|
||||||
|
label: 'Deployed ability ID',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'values.assignedAbilityDurationSeconds',
|
path: 'values.assignedAbilityDurationSeconds',
|
||||||
label: 'Assigned ability duration',
|
label: 'Assigned ability duration',
|
||||||
@@ -123,8 +136,9 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
|
|||||||
key: 'skill_hit',
|
key: 'skill_hit',
|
||||||
label: 'Skill hit',
|
label: 'Skill hit',
|
||||||
description:
|
description:
|
||||||
'A deployed skill connects with a target. Critical and distance details belong in event values.',
|
'A deployed skill connects with a target. Critical and distance details belong in event values. An optional area-target sample lists valid entities by distance from the hit target.',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ path: 'values.abilityId', label: 'Source ability ID', type: 'string' },
|
||||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||||
{
|
{
|
||||||
path: 'values.assignedAbilityActive',
|
path: 'values.assignedAbilityActive',
|
||||||
@@ -302,6 +316,15 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
|
|||||||
label: 'Proximity snapshot',
|
label: 'Proximity snapshot',
|
||||||
description:
|
description:
|
||||||
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
firing_started: {
|
firing_started: {
|
||||||
key: 'firing_started',
|
key: 'firing_started',
|
||||||
@@ -1892,15 +1915,8 @@ function validateReference(
|
|||||||
}
|
}
|
||||||
if (reference.source === 'event' && !reference.path.trim())
|
if (reference.source === 'event' && !reference.path.trim())
|
||||||
errors.push(`${path} requires an event path.`);
|
errors.push(`${path} requires an event path.`);
|
||||||
if (reference.source === 'combat') {
|
if (reference.source === 'combat' && !reference.path.trim())
|
||||||
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`);
|
errors.push(`${path} requires a combat path.`);
|
||||||
if (
|
|
||||||
reference.path === 'currentHealthPercent' &&
|
|
||||||
reference.timing === 'pre_event'
|
|
||||||
) {
|
|
||||||
errors.push(`${path} current Health percentage must use current timing.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (reference.source === 'attribute' && !reference.attributeId.trim())
|
if (reference.source === 'attribute' && !reference.attributeId.trim())
|
||||||
errors.push(`${path} requires an Attribute ID.`);
|
errors.push(`${path} requires an Attribute ID.`);
|
||||||
if (reference.source === 'equipment' && !reference.path.trim())
|
if (reference.source === 'equipment' && !reference.path.trim())
|
||||||
@@ -2050,6 +2066,13 @@ function validateMechanicEvents(events: MechanicEvent[]) {
|
|||||||
) {
|
) {
|
||||||
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
field.type === 'number' &&
|
||||||
|
field.integer &&
|
||||||
|
!Number.isInteger(value)
|
||||||
|
) {
|
||||||
|
errors.push(`${fieldPath} must be an integer.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
||||||
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
||||||
@@ -2067,7 +2090,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
|
|||||||
if (sample === undefined) continue;
|
if (sample === undefined) continue;
|
||||||
const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`;
|
const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`;
|
||||||
if (
|
if (
|
||||||
event.kind !== 'weapon_hit' ||
|
!['weapon_hit', 'skill_hit'].includes(event.kind) ||
|
||||||
!objectValue(sample) ||
|
!objectValue(sample) ||
|
||||||
sample.center !== 'event_target' ||
|
sample.center !== 'event_target' ||
|
||||||
!Array.isArray(sample.validTargets) ||
|
!Array.isArray(sample.validTargets) ||
|
||||||
@@ -2075,7 +2098,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
|
|||||||
!event.targetId.trim()
|
!event.targetId.trim()
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${samplePath} requires a weapon-hit event target and event-target-centered valid targets.`,
|
`${samplePath} requires a weapon-hit or skill-hit event target and event-target-centered valid targets.`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2769,10 +2792,10 @@ export function validateMechanicProgram(
|
|||||||
if (targetingError) errors.push(`${outcomePath} ${targetingError}`);
|
if (targetingError) errors.push(`${outcomePath} ${targetingError}`);
|
||||||
if (
|
if (
|
||||||
outcome.targeting?.kind === 'event_target_area' &&
|
outcome.targeting?.kind === 'event_target_area' &&
|
||||||
clause.when !== 'weapon_hit'
|
!['weapon_hit', 'skill_hit'].includes(clause.when)
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${outcomePath} event-target area damage requires a weapon-hit clause.`,
|
`${outcomePath} event-target area damage requires a weapon-hit or skill-hit clause.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
let coverUploading = $state(false);
|
let coverUploading = $state(false);
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
let lastContent = '';
|
let lastContent = '';
|
||||||
let saveSequence = 0;
|
let saveTimer: number | undefined;
|
||||||
|
let saveInFlight = false;
|
||||||
|
let saveQueued = false;
|
||||||
const groupedTags = $derived.by(() => {
|
const groupedTags = $derived.by(() => {
|
||||||
const groups: Record<string, ComposerTag[]> = {};
|
const groups: Record<string, ComposerTag[]> = {};
|
||||||
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
||||||
@@ -35,6 +37,40 @@
|
|||||||
coverImageId, coverAlt, coverCaption, version };
|
coverImageId, coverAlt, coverCaption, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
||||||
|
saveTimer = window.setTimeout(() => {
|
||||||
|
saveTimer = undefined;
|
||||||
|
void saveDraft();
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (saveInFlight || !saveQueued) return;
|
||||||
|
saveInFlight = true;
|
||||||
|
saveQueued = false;
|
||||||
|
const draft = { ...payload(), tagIds: [...selectedTags] };
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/articles/draft', {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.status === 409) {
|
||||||
|
saveState = 'conflict';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
|
||||||
|
version = result.version;
|
||||||
|
saveState = saveQueued ? 'saving' : 'saved';
|
||||||
|
} catch (error) {
|
||||||
|
saveState = 'error';
|
||||||
|
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
|
||||||
|
} finally {
|
||||||
|
saveInFlight = false;
|
||||||
|
if (saveQueued && saveState !== 'conflict') scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
@@ -44,29 +80,13 @@
|
|||||||
}
|
}
|
||||||
if (content === lastContent) return;
|
if (content === lastContent) return;
|
||||||
lastContent = content;
|
lastContent = content;
|
||||||
const sequence = ++saveSequence;
|
saveQueued = true;
|
||||||
saveState = 'saving';
|
saveState = 'saving';
|
||||||
const timer = window.setTimeout(async () => {
|
if (!saveInFlight) scheduleSave();
|
||||||
try {
|
return () => {
|
||||||
const response = await fetch('/api/articles/draft', {
|
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
|
saveTimer = undefined;
|
||||||
});
|
};
|
||||||
const result = await response.json();
|
|
||||||
if (sequence !== saveSequence) return;
|
|
||||||
if (response.status === 409) {
|
|
||||||
saveState = 'conflict';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
|
|
||||||
version = result.version;
|
|
||||||
saveState = 'saved';
|
|
||||||
} catch (error) {
|
|
||||||
if (sequence !== saveSequence) return;
|
|
||||||
saveState = 'error';
|
|
||||||
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
|
|
||||||
}
|
|
||||||
}, 900);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleTag(id: string) {
|
function toggleTag(id: string) {
|
||||||
@@ -115,7 +135,7 @@
|
|||||||
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
|
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
|
||||||
<PendingForm action="?/publish" class="article-publish-form">
|
<PendingForm action="?/publish" class="article-publish-form">
|
||||||
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
|
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
|
||||||
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
|
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
items,
|
items,
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
label,
|
label,
|
||||||
placeholder = 'Search available items…',
|
placeholder = 'Search…',
|
||||||
disabledIds = [],
|
disabledIds = [],
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
@@ -27,19 +27,19 @@
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||||
<ItemIcon src={selected?.iconUrl} alt=""/>
|
<ItemIcon src={selected?.iconUrl} alt=""/>
|
||||||
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="builder-picker-panel">
|
<div class="builder-picker-panel">
|
||||||
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
||||||
<div>
|
<div>
|
||||||
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
|
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
|
||||||
{#each results as item}
|
{#each results as item}
|
||||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||||
<ItemIcon src={item.iconUrl} alt=""/>
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||||
</button>
|
</button>
|
||||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
{:else}<p>NO MATCHES</p>{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
|
let open = $state(false);
|
||||||
|
const selected = $derived(items.find((item) => item.id === value));
|
||||||
const visible = $derived(items.filter((item) =>
|
const visible = $derived(items.filter((item) =>
|
||||||
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
||||||
));
|
));
|
||||||
@@ -31,26 +33,33 @@
|
|||||||
function choose(id: string) {
|
function choose(id: string) {
|
||||||
value = id;
|
value = id;
|
||||||
onchange?.(id);
|
onchange?.(id);
|
||||||
|
if (searchable) {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
||||||
<legend>{label}</legend>
|
<legend>{label}</legend>
|
||||||
{#if searchable}
|
{#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
|
||||||
|
{#if searchable && open}
|
||||||
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
{#if !searchable || open}
|
||||||
{#each visible as item}
|
<div>
|
||||||
<label title={item.name}>
|
{#each visible as item}
|
||||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
<label title={item.name}>
|
||||||
<span>
|
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||||
<ItemIcon src={item.iconUrl} alt=""/>
|
<span>
|
||||||
<b>{item.name}</b>
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
{#if item.category}<small>{item.category}</small>{/if}
|
<b>{item.name}</b>
|
||||||
</span>
|
{#if item.category}<small>{item.category}</small>{/if}
|
||||||
</label>
|
</span>
|
||||||
{:else}
|
</label>
|
||||||
<p>NO MATCHING OPTIONS</p>
|
{:else}
|
||||||
{/each}
|
<p>NO MATCHING OPTIONS</p>
|
||||||
</div>
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
|
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||||||
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
||||||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
||||||
|
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
|
||||||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||||||
]
|
]
|
||||||
: action === 'saveCombatEffect'
|
: action === 'saveCombatEffect'
|
||||||
|
|||||||
@@ -37,20 +37,20 @@
|
|||||||
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
|
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
|
||||||
|
|
||||||
const issueLabel = (issue: string) => {
|
const issueLabel = (issue: string) => {
|
||||||
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
|
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
|
||||||
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
|
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
|
||||||
return 'CHECK CONFIGURATION';
|
return 'CHECK';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="loadout-matrix" aria-label="Loadout matrix">
|
<section class="loadout-matrix" aria-label="Loadout matrix">
|
||||||
<header>
|
<header>
|
||||||
<span>LOADOUT MATRIX</span>
|
<span>LOADOUT</span>
|
||||||
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
|
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="matrix-section doctrine-section">
|
<div class="matrix-section doctrine-section">
|
||||||
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
|
<small class="section-label">SPECIALIZATION FOCUS</small>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="doctrine-cell"
|
class="doctrine-cell"
|
||||||
@@ -60,27 +60,15 @@
|
|||||||
onclick={() => onselect(doctrine.id)}
|
onclick={() => onselect(doctrine.id)}
|
||||||
>
|
>
|
||||||
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
|
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
|
||||||
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
|
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
|
||||||
<i aria-hidden="true"></i>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
|
<div class="supply-line"><span>ABILITIES</span></div>
|
||||||
<div class="ability-cells">
|
<div class="ability-cells">
|
||||||
{#each abilities as cell}
|
{#each abilities as cell}
|
||||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="matrix-section">
|
|
||||||
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
|
|
||||||
<div class="equipment-cells">
|
|
||||||
{#each weapons as cell}
|
|
||||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
|
||||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
|
||||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
|
||||||
<i aria-hidden="true"></i>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -88,12 +76,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-section">
|
<div class="matrix-section">
|
||||||
<small class="section-label">GEAR · 6 POSITIONS</small>
|
<small class="section-label">WEAPONS</small>
|
||||||
|
<div class="equipment-cells">
|
||||||
|
{#each weapons as cell}
|
||||||
|
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||||
|
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||||
|
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||||
|
<i aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="matrix-section">
|
||||||
|
<small class="section-label">GEAR</small>
|
||||||
<div class="equipment-cells gear-cells">
|
<div class="equipment-cells gear-cells">
|
||||||
{#each gear as cell}
|
{#each gear as cell}
|
||||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||||
<i aria-hidden="true"></i>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -101,10 +102,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-section protocol-section">
|
<div class="matrix-section protocol-section">
|
||||||
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
|
<small class="section-label">OS PROTOCOL</small>
|
||||||
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
|
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
|
||||||
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
|
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
|
||||||
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
|
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
|
||||||
<i aria-hidden="true"></i>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,21 +113,20 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
|
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||||
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
|
.loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
|
||||||
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
|
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
|
||||||
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
|
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
|
||||||
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
|
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
|
||||||
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
|
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
|
||||||
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
|
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
|
||||||
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
|
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
|
||||||
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
|
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
|
||||||
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
|
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
|
||||||
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
|
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
|
||||||
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
|
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
|
||||||
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
|
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
|
||||||
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
|
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
|
||||||
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
||||||
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
|
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
|
||||||
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
|
||||||
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,10 +37,9 @@
|
|||||||
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
||||||
<div class="summary-head">
|
<div class="summary-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
|
<p class="eyebrow"><span></span> KNOWN STATS</p>
|
||||||
<h2 id={headingId}>Agent stats</h2>
|
<h2 id={headingId}>Agent stats</h2>
|
||||||
</div>
|
</div>
|
||||||
<b>{summary.stats.length}</b>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-core">
|
<div class="summary-core">
|
||||||
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
||||||
@@ -53,49 +52,57 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-scroll">
|
<div class="summary-scroll">
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
||||||
{#each summary.stats as stat}
|
{#each summary.stats as stat}
|
||||||
<div class="summary-stat">
|
<div class="summary-stat">
|
||||||
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
|
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
|
||||||
<b>{unitValue(stat.value, stat.id)}</b>
|
<b>{unitValue(stat.value, stat.id)}</b>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Equip and configure items to calculate their known Attributes.</p>
|
<p>Equip items to see known Attributes.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
||||||
{#each summary.activeBonuses as bonus}
|
{#each summary.activeBonuses as bonus}
|
||||||
<div class="summary-bonus">
|
<div class="summary-bonus">
|
||||||
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
|
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
|
||||||
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
|
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
|
<p>No active bonuses.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
||||||
{#each summary.equippedTalents as equipped}
|
{#each summary.equippedTalents as equipped}
|
||||||
<div class="summary-talent">
|
<div class="summary-talent">
|
||||||
<span>
|
<details open={layout === 'page'}>
|
||||||
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
|
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
|
||||||
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
|
<p>{equipped.description}</p>
|
||||||
<em>{equipped.description}</em>
|
|
||||||
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
||||||
</span>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Selected equipment Talents appear here.</p>
|
<p>No Talents equipped.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
|
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loadout-known-stats{background:var(--shd-surface)}
|
.loadout-known-stats{background:var(--shd-surface)}
|
||||||
.summary-talent{grid-template-columns:1fr;cursor:default}
|
.summary-talent{grid-template-columns:1fr;cursor:default}
|
||||||
|
.summary-talent details,.summary-talent summary{min-width:0}
|
||||||
|
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
|
||||||
|
.summary-talent summary::-webkit-details-marker{display:none}
|
||||||
|
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
|
||||||
|
.summary-talent details[open] summary:after{content:'-'}
|
||||||
|
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
|
||||||
|
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
|
||||||
|
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
|
||||||
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
|
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
|
||||||
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
|
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
|
||||||
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
|||||||
@@ -10,20 +10,20 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
|
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
|
||||||
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
|
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
|
||||||
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
|
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
|
||||||
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
|
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
||||||
<header>
|
<header>
|
||||||
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
||||||
<b>COMING SOON</b>
|
<b>PLANNED</b>
|
||||||
</header>
|
</header>
|
||||||
{#if variant === 'full'}
|
{#if variant === 'full'}
|
||||||
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
|
<p>Known stats are live. Damage projections are planned.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
||||||
{#each capabilities as capability}
|
{#each capabilities as capability}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
|
import type { TalentTier } from '$lib/builder/loadout';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label = 'TALENT TIER',
|
||||||
|
name,
|
||||||
|
talent,
|
||||||
|
value = $bindable(1),
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
name: string;
|
||||||
|
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
|
||||||
|
value?: TalentTier;
|
||||||
|
onchange?: (tier: TalentTier) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
|
||||||
|
const choose = (tier: TalentTier) => {
|
||||||
|
value = tier;
|
||||||
|
onchange?.(tier);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="talent-tier-choice">
|
||||||
|
<legend>{label}</legend>
|
||||||
|
<div>
|
||||||
|
{#each [1, 2] as tier}
|
||||||
|
<label class:tier-two={tier === 2}>
|
||||||
|
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
|
||||||
|
<span>
|
||||||
|
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
|
||||||
|
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
|
||||||
|
<b>T{tier}</b>
|
||||||
|
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
|
||||||
|
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
|
||||||
|
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
|
||||||
|
.talent-tier-choice label{min-width:0;cursor:pointer}
|
||||||
|
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
|
||||||
|
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
|
||||||
|
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
|
||||||
|
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
|
||||||
|
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
|
||||||
|
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
|
||||||
|
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
|
||||||
|
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
|
||||||
|
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
|
||||||
|
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
|
||||||
|
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
||||||
|
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
|
||||||
|
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
|
||||||
|
</style>
|
||||||
@@ -9,15 +9,18 @@
|
|||||||
blankConditionalMechanicDraft,
|
blankConditionalMechanicDraft,
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
eventFieldNumericExpression,
|
||||||
mechanicTargetingForMode,
|
mechanicTargetingForMode,
|
||||||
mechanicTargetingMode,
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
|
readEventFieldNumericExpression,
|
||||||
registeredEventFields,
|
registeredEventFields,
|
||||||
readStatePoolThreshold,
|
readStatePoolThreshold,
|
||||||
statePoolThresholdPredicate,
|
statePoolThresholdPredicate,
|
||||||
type ConditionalMechanicClauseDraft,
|
type ConditionalMechanicClauseDraft,
|
||||||
|
type ConditionalNumericExpression,
|
||||||
type ConditionalMechanicDraftV1,
|
type ConditionalMechanicDraftV1,
|
||||||
type ConditionalMechanicOutcomeDraft,
|
type ConditionalMechanicOutcomeDraft,
|
||||||
type ConditionalMechanicPoolDraft,
|
type ConditionalMechanicPoolDraft,
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
formatMechanicProgram,
|
formatMechanicProgram,
|
||||||
MECHANIC_EVENT_KINDS,
|
MECHANIC_EVENT_KINDS,
|
||||||
MECHANIC_EVENT_REGISTRY,
|
MECHANIC_EVENT_REGISTRY,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
type ClauseMutation,
|
type ClauseMutation,
|
||||||
type MechanicOutcomeScope,
|
type MechanicOutcomeScope,
|
||||||
type NumericExpression,
|
type NumericExpression,
|
||||||
@@ -408,6 +412,40 @@
|
|||||||
updateClause(index, { reducer });
|
updateClause(index, { reducer });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function derivedScalarModel(clause: ConditionalMechanicClauseDraft) {
|
||||||
|
if (clause.reducer.kind !== 'derived_scalar') return 'static';
|
||||||
|
if (typeof clause.reducer.value === 'number') return 'static';
|
||||||
|
return readEventFieldNumericExpression(clause.reducer.value) ? 'event' : 'advanced';
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericEventFields(clause: ConditionalMechanicClauseDraft) {
|
||||||
|
return registeredEventFields(clause.event).filter((field) => field.type === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDerivedScalarModel(clauseIndex: number, model: string) {
|
||||||
|
const clause = rule.clauses[clauseIndex];
|
||||||
|
if (clause.reducer.kind !== 'derived_scalar') return;
|
||||||
|
if (model === 'static') {
|
||||||
|
updateClause(clauseIndex, { reducer: { kind: 'derived_scalar', value: 1 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const field = numericEventFields(clause)[0];
|
||||||
|
if (model === 'event' && field) {
|
||||||
|
updateClause(clauseIndex, {
|
||||||
|
reducer: {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: eventFieldNumericExpression(field.path)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDerivedScalarEventField(clauseIndex: number, path: string) {
|
||||||
|
updateClause(clauseIndex, {
|
||||||
|
reducer: { kind: 'derived_scalar', value: eventFieldNumericExpression(path) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||||
@@ -517,6 +555,10 @@
|
|||||||
const eventValueOptions = [
|
const eventValueOptions = [
|
||||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||||
|
{
|
||||||
|
value: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
|
label: 'AGENT-BUFFED ALLIES ≤30M'
|
||||||
|
},
|
||||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||||
@@ -534,10 +576,10 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
function protocolUpgradeExpression(): ConditionalNumericExpression {
|
||||||
return {
|
return {
|
||||||
op: 'multiply',
|
op: 'multiply',
|
||||||
left: value,
|
left: { ref: { source: 'variant_value' } },
|
||||||
right: {
|
right: {
|
||||||
op: 'add',
|
op: 'add',
|
||||||
left: 1,
|
left: 1,
|
||||||
@@ -578,17 +620,17 @@
|
|||||||
typeof expression === 'object'
|
typeof expression === 'object'
|
||||||
&& 'op' in expression
|
&& 'op' in expression
|
||||||
&& expression.op === 'multiply'
|
&& expression.op === 'multiply'
|
||||||
&& typeof expression.left === 'number'
|
|
||||||
&& typeof expression.right === 'object'
|
&& typeof expression.right === 'object'
|
||||||
) {
|
) {
|
||||||
if ('ref' in expression.right) {
|
if (typeof expression.left === 'number' && 'ref' in expression.right) {
|
||||||
if (expression.right.ref.source === 'event') return 'event';
|
if (expression.right.ref.source === 'event') return 'event';
|
||||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||||
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
typeof expression.left === 'number'
|
||||||
|
&& 'op' in expression.right
|
||||||
&& expression.right.op === 'divide'
|
&& expression.right.op === 'divide'
|
||||||
&& typeof expression.right.left === 'object'
|
&& typeof expression.right.left === 'object'
|
||||||
&& 'ref' in expression.right.left
|
&& 'ref' in expression.right.left
|
||||||
@@ -596,7 +638,13 @@
|
|||||||
&& expression.right.right === 100
|
&& expression.right.right === 100
|
||||||
) return 'attribute';
|
) return 'attribute';
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
(typeof expression.left === 'number'
|
||||||
|
|| (
|
||||||
|
typeof expression.left === 'object'
|
||||||
|
&& 'ref' in expression.left
|
||||||
|
&& expression.left.ref.source === 'variant_value'
|
||||||
|
))
|
||||||
|
&& 'op' in expression.right
|
||||||
&& expression.right.op === 'add'
|
&& expression.right.op === 'add'
|
||||||
&& typeof expression.right.right === 'object'
|
&& typeof expression.right.right === 'object'
|
||||||
&& 'op' in expression.right.right
|
&& 'op' in expression.right.right
|
||||||
@@ -657,7 +705,7 @@
|
|||||||
model: OutcomeValueModel,
|
model: OutcomeValueModel,
|
||||||
value: number,
|
value: number,
|
||||||
reference = ''
|
reference = ''
|
||||||
): NumericExpression | undefined {
|
): ConditionalNumericExpression | undefined {
|
||||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||||
if (model === 'attribute') return reference
|
if (model === 'attribute') return reference
|
||||||
? attributePercentExpression(value, reference)
|
? attributePercentExpression(value, reference)
|
||||||
@@ -671,7 +719,7 @@
|
|||||||
if (model === 'reducer') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +746,7 @@
|
|||||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
function replaceExpressionBase(expression: ConditionalNumericExpression, value: number): ConditionalNumericExpression {
|
||||||
const model = outcomeValueModel({
|
const model = outcomeValueModel({
|
||||||
key: 'temporary',
|
key: 'temporary',
|
||||||
label: 'Temporary',
|
label: 'Temporary',
|
||||||
@@ -743,7 +791,7 @@
|
|||||||
if (model === 'reducer') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||||
return expression;
|
return expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +850,7 @@
|
|||||||
updateClause(clauseIndex, { mutations });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | 'clear', pool?: string, value?: number) {
|
||||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||||
const current = mutations[mutationIndex];
|
const current = mutations[mutationIndex];
|
||||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||||
@@ -812,7 +860,9 @@
|
|||||||
: undefined;
|
: undefined;
|
||||||
mutations[mutationIndex] = kind === 'clear'
|
mutations[mutationIndex] = kind === 'clear'
|
||||||
? { kind, pool: nextPool, entity }
|
? { kind, pool: nextPool, entity }
|
||||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
: kind === 'derive'
|
||||||
|
? { kind, pool: nextPool, value: { ref: { source: 'reducer' } }, entity }
|
||||||
|
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
||||||
updateClause(clauseIndex, { mutations });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,7 +1184,29 @@
|
|||||||
{:else if clause.reducer.kind === 'periodic'}
|
{:else if clause.reducer.kind === 'periodic'}
|
||||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
<SegmentedChoice
|
||||||
|
legend="DERIVED VALUE SOURCE"
|
||||||
|
name={`${idPrefix}-derived-source-${clauseIndex}`}
|
||||||
|
options={[
|
||||||
|
{value:'static',label:'STATIC'},
|
||||||
|
{value:'event',label:'EVENT FIELD',disabled:numericEventFields(clause).length === 0},
|
||||||
|
...(derivedScalarModel(clause) === 'advanced' ? [{value:'advanced',label:'ADVANCED',disabled:true}] : [])
|
||||||
|
]}
|
||||||
|
value={derivedScalarModel(clause)}
|
||||||
|
onchange={(values) => setDerivedScalarModel(clauseIndex,values[0])}
|
||||||
|
/>
|
||||||
|
{#if derivedScalarModel(clause) === 'event'}
|
||||||
|
<SegmentedChoice
|
||||||
|
legend="NUMERIC EVENT FIELD"
|
||||||
|
name={`${idPrefix}-derived-event-${clauseIndex}`}
|
||||||
|
options={numericEventFields(clause).map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
|
||||||
|
value={readEventFieldNumericExpression(clause.reducer.value) ?? ''}
|
||||||
|
columns={Math.min(3,numericEventFields(clause).length)}
|
||||||
|
onchange={(values) => setDerivedScalarEventField(clauseIndex,values[0])}
|
||||||
|
/>
|
||||||
|
{:else if derivedScalarModel(clause) === 'static'}
|
||||||
|
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1159,9 +1231,9 @@
|
|||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="OPERATION"
|
legend="OPERATION"
|
||||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'derive',label:'DERIVED VALUE'},{value:'clear',label:'CLEAR'}]}
|
||||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
value={mutation.kind === 'none' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'derive'|'clear')}
|
||||||
/>
|
/>
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="STATE POOL"
|
legend="STATE POOL"
|
||||||
@@ -1169,10 +1241,12 @@
|
|||||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'derive' ? 'derive' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||||
/>
|
/>
|
||||||
{#if mutation.kind !== 'clear'}
|
{#if mutation.kind !== 'clear' && mutation.kind !== 'derive'}
|
||||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||||
|
{:else if mutation.kind === 'derive'}
|
||||||
|
<div class="branch-readout"><span>VALUE</span><strong>DERIVED SCALAR</strong></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||||
);
|
);
|
||||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 10',
|
'Prompt contract version: 11',
|
||||||
);
|
);
|
||||||
|
|
||||||
const advertised = structuredClone(
|
const advertised = structuredClone(
|
||||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
it('advertises the canonical Extra Health change event contract in version 11', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
request,
|
||||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 10',
|
'Prompt contract version: 11',
|
||||||
);
|
);
|
||||||
expect(input.canonicalCatalog.events).toContainEqual({
|
expect(input.canonicalCatalog.events).toContainEqual({
|
||||||
key: 'extra_health_changed',
|
key: 'extra_health_changed',
|
||||||
@@ -325,7 +325,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
it('advertises reproducible outcome and clause proc probabilities in version 11', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
request,
|
||||||
@@ -336,7 +336,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'procChancePercent',
|
'procChancePercent',
|
||||||
@@ -489,4 +489,62 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
});
|
});
|
||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
it('advertises draft variant-value scaling and the typed proximity input in version 11', () => {
|
||||||
|
const input = JSON.parse(
|
||||||
|
buildAiMechanicAuthoringInput({
|
||||||
|
request: {
|
||||||
|
...request,
|
||||||
|
context: {
|
||||||
|
...request.context,
|
||||||
|
sourceName: 'Close and Personal',
|
||||||
|
sourceDescription:
|
||||||
|
'Increase Weapon Damage by (1)% plus (2)% for each nearby ally with a buff granted by you.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attributes: [],
|
||||||
|
events: [MECHANIC_EVENT_REGISTRY.proximity_snapshot],
|
||||||
|
actions: [],
|
||||||
|
recipeKeys: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const grammar = input.canonicalCatalog.grammar;
|
||||||
|
const proximityEvent = input.canonicalCatalog.events[0];
|
||||||
|
|
||||||
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
|
'Prompt contract version: 11',
|
||||||
|
);
|
||||||
|
expect(grammar.valueReferences).toContain('variant_value');
|
||||||
|
expect(grammar.draftOutcomeExpressionReferences).toEqual(['variant_value']);
|
||||||
|
expect(grammar.referenceShapes).toContainEqual({
|
||||||
|
ref: { source: 'variant_value' },
|
||||||
|
});
|
||||||
|
expect(proximityEvent.fields).toContainEqual({
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
expect(proximityEvent.strictFields).toBeUndefined();
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||||
|
expect.stringContaining('unresolved numbered source placeholder'),
|
||||||
|
);
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||||
|
expect.stringContaining('{ref:{source:"variant_value"}}'),
|
||||||
|
);
|
||||||
|
expect(input.examplesAndRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('only inside an outcome variant expression'),
|
||||||
|
expect.stringContaining('outcome variants[variantKey].value'),
|
||||||
|
expect.stringContaining(
|
||||||
|
'{ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}',
|
||||||
|
),
|
||||||
|
expect.stringContaining(
|
||||||
|
'{key:"allies-within-30m-with-agent-granted-buff"',
|
||||||
|
),
|
||||||
|
expect.stringContaining('readiness:"ready",min:0'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
|||||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||||
|
|
||||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
export const AI_MECHANIC_PROMPT_VERSION = 11;
|
||||||
|
|
||||||
const mechanicsGrammar = {
|
const mechanicsGrammar = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -83,6 +83,7 @@ const mechanicsGrammar = {
|
|||||||
'attribute',
|
'attribute',
|
||||||
'reducer',
|
'reducer',
|
||||||
'pool',
|
'pool',
|
||||||
|
'variant_value',
|
||||||
],
|
],
|
||||||
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
||||||
numericExpressionOperators: [
|
numericExpressionOperators: [
|
||||||
@@ -95,7 +96,9 @@ const mechanicsGrammar = {
|
|||||||
'clamp',
|
'clamp',
|
||||||
'floor',
|
'floor',
|
||||||
],
|
],
|
||||||
|
draftOutcomeExpressionReferences: ['variant_value'],
|
||||||
referenceShapes: [
|
referenceShapes: [
|
||||||
|
{ ref: { source: 'variant_value' } },
|
||||||
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
||||||
{ ref: { source: 'scenario', key: 'input-key' } },
|
{ ref: { source: 'scenario', key: 'input-key' } },
|
||||||
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
||||||
@@ -498,6 +501,7 @@ Security and scope:
|
|||||||
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
||||||
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
||||||
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
||||||
|
- An unresolved numbered source placeholder is a material missing value. Ask the human for the exact numeric value for every affected source variant; never infer or guess it.
|
||||||
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
||||||
|
|
||||||
Response rules:
|
Response rules:
|
||||||
@@ -507,6 +511,7 @@ Response rules:
|
|||||||
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
||||||
- A draft must define exactly the supplied value variants for every outcome.
|
- A draft must define exactly the supplied value variants for every outcome.
|
||||||
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
||||||
|
- Put each clarified placeholder answer in the corresponding outcome variant value as its human-facing value. When that value participates in a runtime expression, reference it exactly as {ref:{source:"variant_value"}}; never duplicate the numeric literal in the expression. variant_value is draft-only and valid only inside an outcome variant expression.
|
||||||
- Use attribute_modifier only with an exact supplied Attribute ID.
|
- Use attribute_modifier only with an exact supplied Attribute ID.
|
||||||
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
||||||
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
||||||
@@ -551,9 +556,14 @@ export function buildAiMechanicAuthoringInput(input: {
|
|||||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||||
|
'variant_value is draft-only and may appear only inside an outcome variant expression, with the exact shape {ref:{source:"variant_value"}}. It resolves to that same variant object’s human-facing numeric value at runtime.',
|
||||||
|
'If source wording contains an unresolved numbered placeholder such as {1} or {2}, return clarification_required and ask for the exact numeric answer for every affected source variant. Never guess a placeholder value.',
|
||||||
|
'After clarification, put each answer in the corresponding outcome variants[variantKey].value. If a runtime-scaled expression uses that answer, reference {ref:{source:"variant_value"}} rather than duplicating the numeric literal.',
|
||||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||||
|
'For proximity_snapshot, the optional values.alliesWithin30MetersWithAgentGrantedBuff field is a non-negative integer count of distinct allied Agents at or within 30 meters who possess at least one buff granted by the source Agent. Multiple qualifying buffs on one ally still count that ally once. Read it with {ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}.',
|
||||||
|
'When loadout timelines must supply values.alliesWithin30MetersWithAgentGrantedBuff, define the ready numeric scenario input exactly as {key:"allies-within-30m-with-agent-granted-buff",label:"Allies within 30 meters with an Agent-granted buff",kind:"number",readiness:"ready",min:0}. The loadout timeline copies that resolved count into each proximity_snapshot event, and runtime event validation rejects non-integers.',
|
||||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
|
|||||||
`specialization-${item.id}`,
|
`specialization-${item.id}`,
|
||||||
'specialization',
|
'specialization',
|
||||||
String(item.name),
|
String(item.name),
|
||||||
`specialization-${slugify(String(item.name))}`,
|
`specialization-${slugify(String(item.name))}-${item.id}`,
|
||||||
'specialization',
|
'specialization',
|
||||||
item.id,
|
item.id,
|
||||||
Number(item.active) ? 1 : 0,
|
Number(item.active) ? 1 : 0,
|
||||||
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
|
|||||||
`focus-${item.id}`,
|
`focus-${item.id}`,
|
||||||
'focus',
|
'focus',
|
||||||
name,
|
name,
|
||||||
`focus-${slugify(name)}`,
|
`focus-${slugify(name)}-${item.id}`,
|
||||||
'focus',
|
'focus',
|
||||||
item.id,
|
item.id,
|
||||||
Number(item.active) ? 1 : 0,
|
Number(item.active) ? 1 : 0,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
talentEffects,
|
talentEffects,
|
||||||
talentTemplates,
|
talentTemplates,
|
||||||
chipSets,
|
chipSets,
|
||||||
|
abilitySkillChipLinks,
|
||||||
chipTwoPiece,
|
chipTwoPiece,
|
||||||
chipPrimaryOptions,
|
chipPrimaryOptions,
|
||||||
chipPrimaryQualityValues,
|
chipPrimaryQualityValues,
|
||||||
@@ -31,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
protocolTalentEffects,
|
protocolTalentEffects,
|
||||||
specializations,
|
specializations,
|
||||||
specializationFocuses,
|
specializationFocuses,
|
||||||
|
specializationAbilities,
|
||||||
|
focusAbilities,
|
||||||
specializationNodes,
|
specializationNodes,
|
||||||
specializationRanks,
|
specializationRanks,
|
||||||
specializationRankEffects,
|
specializationRankEffects,
|
||||||
@@ -85,6 +88,14 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT link.ability_id,link.compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links link
|
||||||
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
|
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).all(),
|
||||||
@@ -136,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM specialization_abilities ORDER BY specialization_id,name COLLATE NOCASE',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
||||||
@@ -194,6 +215,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...set,
|
...set,
|
||||||
|
compatibleAbilityIds: [
|
||||||
|
String(set.specialization_ability_id),
|
||||||
|
...abilitySkillChipLinks.flatMap((link) =>
|
||||||
|
link.ability_id === set.specialization_ability_id
|
||||||
|
? [String(link.compatible_ability_id)]
|
||||||
|
: link.compatible_ability_id === set.specialization_ability_id
|
||||||
|
? [String(link.ability_id)]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
twoPiece:
|
twoPiece:
|
||||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||||
null,
|
null,
|
||||||
@@ -242,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
.filter((focus) => focus.specialization_id === specialization.id)
|
.filter((focus) => focus.specialization_id === specialization.id)
|
||||||
.map((focus) => ({
|
.map((focus) => ({
|
||||||
...focus,
|
...focus,
|
||||||
|
abilities: focusAbilities
|
||||||
|
.filter((link) => link.focus_id === focus.id)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(left.ability_slot) - Number(right.ability_slot),
|
||||||
|
)
|
||||||
|
.map((link) =>
|
||||||
|
specializationAbilities.find(
|
||||||
|
(ability) => ability.id === link.ability_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(Boolean),
|
||||||
nodes: specializationNodes
|
nodes: specializationNodes
|
||||||
.filter((node) => node.focus_id === focus.id)
|
.filter((node) => node.focus_id === focus.id)
|
||||||
.map((node) => ({
|
.map((node) => ({
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
|
|||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async function validateSkillChipSelections(
|
|||||||
if (!loadout.chips.some((chip) => chip.setId)) return null;
|
if (!loadout.chips.some((chip) => chip.setId)) return null;
|
||||||
const [
|
const [
|
||||||
sets,
|
sets,
|
||||||
|
abilityLinks,
|
||||||
focusAbilities,
|
focusAbilities,
|
||||||
primaryOptions,
|
primaryOptions,
|
||||||
primaryValues,
|
primaryValues,
|
||||||
@@ -44,12 +45,28 @@ async function validateSkillChipSelections(
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1',
|
`SELECT chip.id,chip.specialization_ability_id
|
||||||
|
FROM skill_chip_sets chip
|
||||||
|
JOIN specialization_abilities ability
|
||||||
|
ON ability.id=chip.specialization_ability_id AND ability.active=1
|
||||||
|
WHERE chip.active=1`,
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
|
`SELECT link.ability_id,link.compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links link
|
||||||
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
|
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT link.ability_id,link.ability_slot
|
||||||
|
FROM specialization_focus_abilities link
|
||||||
|
JOIN specialization_focuses focus ON focus.id=link.focus_id AND focus.active=1
|
||||||
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
|
WHERE link.focus_id=? ORDER BY link.ability_slot`,
|
||||||
)
|
)
|
||||||
).all(loadout.specialization.focusId),
|
).all(loadout.specialization.focusId),
|
||||||
(
|
(
|
||||||
@@ -94,11 +111,20 @@ async function validateSkillChipSelections(
|
|||||||
const expectedAbility = focusAbilities.find(
|
const expectedAbility = focusAbilities.find(
|
||||||
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
|
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
|
||||||
);
|
);
|
||||||
|
const ownerAbilityId = String(definition?.specialization_ability_id ?? '');
|
||||||
|
const expectedAbilityId = String(expectedAbility?.ability_id ?? '');
|
||||||
|
const sharedWithExpectedAbility = abilityLinks.some((link) => {
|
||||||
|
const left = String(link.ability_id);
|
||||||
|
const right = String(link.compatible_ability_id);
|
||||||
|
return (
|
||||||
|
(left === ownerAbilityId && right === expectedAbilityId) ||
|
||||||
|
(left === expectedAbilityId && right === ownerAbilityId)
|
||||||
|
);
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
!definition ||
|
!definition ||
|
||||||
!expectedAbility ||
|
!expectedAbility ||
|
||||||
String(definition.specialization_ability_id) !==
|
(ownerAbilityId !== expectedAbilityId && !sharedWithExpectedAbility)
|
||||||
String(expectedAbility.ability_id)
|
|
||||||
)
|
)
|
||||||
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
|
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
|
||||||
const primaryRange = rangeFor(
|
const primaryRange = rangeFor(
|
||||||
@@ -125,6 +151,74 @@ async function validateSkillChipSelections(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateEquipmentTalentSelections(
|
||||||
|
database: Awaited<ReturnType<typeof getDatabase>>,
|
||||||
|
loadout: LoadoutDraft,
|
||||||
|
) {
|
||||||
|
const [gearTalents, weaponTalents, weapons, talentTiers] = await Promise.all([
|
||||||
|
(
|
||||||
|
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
|
||||||
|
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
|
||||||
|
]);
|
||||||
|
const hasTier = (talentId: string, tier: 1 | 2) =>
|
||||||
|
talentTiers.some(
|
||||||
|
(entry) =>
|
||||||
|
String(entry.talent_id) === talentId &&
|
||||||
|
String(entry.tier) === (tier === 2 ? 'II' : 'I'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const slot of Object.keys(loadout.gear)) {
|
||||||
|
const selection = loadout.gear[slot];
|
||||||
|
if (!selection.talentId) continue;
|
||||||
|
if (
|
||||||
|
!gearTalents.some(
|
||||||
|
(entry) =>
|
||||||
|
String(entry.gear_id) === selection.itemId &&
|
||||||
|
String(entry.talent_id) === selection.talentId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return `${slot} has a Talent that is not available for the equipped item.`;
|
||||||
|
if (!hasTier(selection.talentId, selection.talentTier))
|
||||||
|
return `${slot} Talent T${selection.talentTier} is not configured.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < loadout.weapons.length; index += 1) {
|
||||||
|
const selection = loadout.weapons[index];
|
||||||
|
const label = index ? 'Secondary weapon' : 'Primary weapon';
|
||||||
|
for (const configured of [
|
||||||
|
{ id: selection.talent1Id, tier: selection.talent1Tier, slot: 1 },
|
||||||
|
{ id: selection.talent2Id, tier: selection.talent2Tier, slot: 2 },
|
||||||
|
]) {
|
||||||
|
if (!configured.id) continue;
|
||||||
|
if (
|
||||||
|
!weaponTalents.some(
|
||||||
|
(entry) =>
|
||||||
|
String(entry.weapon_id) === selection.itemId &&
|
||||||
|
Number(entry.talent_slot) === configured.slot &&
|
||||||
|
String(entry.talent_id) === configured.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return `${label} has a Talent that is not available in slot ${configured.slot}.`;
|
||||||
|
if (!hasTier(configured.id, configured.tier))
|
||||||
|
return `${label} Talent ${configured.slot} T${configured.tier} is not configured.`;
|
||||||
|
}
|
||||||
|
const weapon = weapons.find(
|
||||||
|
(entry) => String(entry.id) === selection.itemId,
|
||||||
|
);
|
||||||
|
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
|
||||||
|
if (exoticTalentId && !hasTier(exoticTalentId, selection.exoticTalentTier))
|
||||||
|
return `${label} exotic Talent T${selection.exoticTalentTier} is not configured.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const viewer = await getSessionUser(cookies);
|
const viewer = await getSessionUser(cookies);
|
||||||
@@ -166,6 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
talentEffects,
|
talentEffects,
|
||||||
talentTemplates,
|
talentTemplates,
|
||||||
chipSets,
|
chipSets,
|
||||||
|
abilitySkillChipLinks,
|
||||||
chipTwoPiece,
|
chipTwoPiece,
|
||||||
chipPrimaryOptions,
|
chipPrimaryOptions,
|
||||||
chipPrimaryQualityValues,
|
chipPrimaryQualityValues,
|
||||||
@@ -258,6 +353,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT link.ability_id,link.compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links link
|
||||||
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
|
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).all(),
|
||||||
@@ -460,6 +563,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...set,
|
...set,
|
||||||
|
category:
|
||||||
|
specializationAbilities.find(
|
||||||
|
(ability) => ability.id === set.specialization_ability_id,
|
||||||
|
)?.name ?? 'SKILL CHIP SET',
|
||||||
|
compatibleAbilityIds: [
|
||||||
|
String(set.specialization_ability_id),
|
||||||
|
...abilitySkillChipLinks.flatMap((link) =>
|
||||||
|
link.ability_id === set.specialization_ability_id
|
||||||
|
? [String(link.compatible_ability_id)]
|
||||||
|
: link.compatible_ability_id === set.specialization_ability_id
|
||||||
|
? [String(link.ability_id)]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
iconUrl: iconUrl('skill_chip', set.id),
|
iconUrl: iconUrl('skill_chip', set.id),
|
||||||
twoPiece:
|
twoPiece:
|
||||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||||
@@ -592,6 +709,12 @@ export const actions: Actions = {
|
|||||||
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
||||||
|
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
|
const equipmentTalentError = await validateEquipmentTalentSelections(
|
||||||
|
database,
|
||||||
|
loadout,
|
||||||
|
);
|
||||||
|
if (equipmentTalentError)
|
||||||
|
return fail(400, { buildError: equipmentTalentError });
|
||||||
const skillChipError = await validateSkillChipSelections(database, loadout);
|
const skillChipError = await validateSkillChipSelections(database, loadout);
|
||||||
if (skillChipError) return fail(400, { buildError: skillChipError });
|
if (skillChipError) return fail(400, { buildError: skillChipError });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|||||||
+156
-126
@@ -12,11 +12,12 @@
|
|||||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||||
|
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
|
||||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import {
|
import {
|
||||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
||||||
type LoadoutDraft, type Roll, type SkillChipQuality
|
type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
|
||||||
} from '$lib/builder/loadout';
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -55,6 +56,9 @@
|
|||||||
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
|
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
|
||||||
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
|
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
|
||||||
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
|
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
|
||||||
|
const chipSupportsAbility = (item: any, abilityId: string) =>
|
||||||
|
item?.compatibleAbilityIds?.includes(abilityId)
|
||||||
|
?? item?.specialization_ability_id === abilityId;
|
||||||
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
|
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
|
||||||
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
|
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
|
||||||
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
|
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
|
||||||
@@ -71,6 +75,8 @@
|
|||||||
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
|
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
|
||||||
}) ?? []);
|
}) ?? []);
|
||||||
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
||||||
|
const supportedTalentTier = (id: string, tier: TalentTier): TalentTier =>
|
||||||
|
talent(id)?.tiers.some((entry: any) => entry.tier === (tier === 2 ? 'II' : 'I')) ? tier : 1;
|
||||||
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||||
const unitValue = (value: unknown, statId: string) => {
|
const unitValue = (value: unknown, statId: string) => {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -115,10 +121,11 @@
|
|||||||
&& value >= Number(range.min)
|
&& value >= Number(range.min)
|
||||||
&& value <= Number(range.max);
|
&& value <= Number(range.max);
|
||||||
};
|
};
|
||||||
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
|
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number], abilityId: string) => {
|
||||||
const capacity = skillChipSecondaryCapacity[chip.quality];
|
const capacity = skillChipSecondaryCapacity[chip.quality];
|
||||||
const secondaryIds = chip.secondary.map((roll) => roll.statId);
|
const secondaryIds = chip.secondary.map((roll) => roll.statId);
|
||||||
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
|
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
||||||
|
return Boolean(definition && chipSupportsAbility(definition, abilityId))
|
||||||
&& chipProfileConfigured
|
&& chipProfileConfigured
|
||||||
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
|
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
|
||||||
&& chip.secondary.length === capacity
|
&& chip.secondary.length === capacity
|
||||||
@@ -168,14 +175,14 @@
|
|||||||
const chip = draft.chips[index];
|
const chip = draft.chips[index];
|
||||||
const ability = next.abilities[Math.floor(index / 3)];
|
const ability = next.abilities[Math.floor(index / 3)];
|
||||||
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
||||||
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
|
if (chip.setId && (!ability || !chipSupportsAbility(definition, ability.id))) {
|
||||||
displaced.push(structuredClone(chip));
|
displaced.push(structuredClone(chip));
|
||||||
draft.chips[index] = createDraft().chips[index];
|
draft.chips[index] = createDraft().chips[index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const chip of displaced) {
|
for (const chip of displaced) {
|
||||||
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
||||||
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
|
const abilityIndex = next.abilities.findIndex((ability: any) => chipSupportsAbility(definition, ability.id));
|
||||||
if (abilityIndex < 0) continue;
|
if (abilityIndex < 0) continue;
|
||||||
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
|
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
|
||||||
if (targetIndex === undefined) continue;
|
if (targetIndex === undefined) continue;
|
||||||
@@ -207,6 +214,7 @@
|
|||||||
selection.level = 50;
|
selection.level = 50;
|
||||||
selection.brandId = item?.brandIds[0] ?? '';
|
selection.brandId = item?.brandIds[0] ?? '';
|
||||||
selection.talentId = item?.talentIds[0] ?? '';
|
selection.talentId = item?.talentIds[0] ?? '';
|
||||||
|
selection.talentTier = supportedTalentTier(selection.talentId, Number(item?.tier) >= 2 ? 2 : 1);
|
||||||
selection.health = Number(item?.base_health_min ?? 0);
|
selection.health = Number(item?.base_health_min ?? 0);
|
||||||
selection.armor = Number(item?.base_armor_min ?? 0);
|
selection.armor = Number(item?.base_armor_min ?? 0);
|
||||||
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
|
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
|
||||||
@@ -255,7 +263,10 @@
|
|||||||
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
|
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
|
||||||
for (const key of coreKeys) selection[key] = core.min;
|
for (const key of coreKeys) selection[key] = core.min;
|
||||||
selection.talent1Id = item?.talent1Ids[0] ?? '';
|
selection.talent1Id = item?.talent1Ids[0] ?? '';
|
||||||
|
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
|
||||||
selection.talent2Id = item?.talent2Ids[0] ?? '';
|
selection.talent2Id = item?.talent2Ids[0] ?? '';
|
||||||
|
selection.talent2Tier = supportedTalentTier(selection.talent2Id, selection.tier >= 2 ? 2 : 1);
|
||||||
|
selection.exoticTalentTier = supportedTalentTier(item?.exotic_talent_id ?? '', selection.tier >= 2 ? 2 : 1);
|
||||||
selection.rolls = [blankRoll(), blankRoll()];
|
selection.rolls = [blankRoll(), blankRoll()];
|
||||||
};
|
};
|
||||||
const configureChip = (index: number, id: string) => {
|
const configureChip = (index: number, id: string) => {
|
||||||
@@ -345,18 +356,21 @@
|
|||||||
const value = String(category ?? '').toLowerCase();
|
const value = String(category ?? '').toLowerCase();
|
||||||
if (value.includes('shotgun')) return '/shotgun.png';
|
if (value.includes('shotgun')) return '/shotgun.png';
|
||||||
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
|
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
|
||||||
|
if (value.includes('mmr') || value.includes('marksman')) return '/mmr.png';
|
||||||
|
if (value.includes('lmg') || value.includes('light machine')) return '/lmg.png';
|
||||||
|
if (value.includes('ar') || value.includes('assault')) return '/ar.png';
|
||||||
return '/rifle.png';
|
return '/rifle.png';
|
||||||
};
|
};
|
||||||
const doctrineCell = $derived<MatrixCell>({
|
const doctrineCell = $derived<MatrixCell>({
|
||||||
id: 'specialization:focus',
|
id: 'specialization:focus',
|
||||||
label: 'SPECIALIZATION + FOCUS',
|
label: 'FOCUS',
|
||||||
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
|
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
|
||||||
meta: selectedFocus?.primary_attribute ?? '',
|
meta: selectedFocus?.primary_attribute ?? '',
|
||||||
summary: selectedFocus
|
summary: selectedFocus
|
||||||
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
|
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
|
||||||
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
|
: 'SETS 3 ABILITIES',
|
||||||
iconUrl: selectedFocus?.specialization.iconUrl,
|
iconUrl: selectedFocus?.specialization.iconUrl,
|
||||||
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
|
issue: !selectedFocus ? 'Select a focus.' : selectedFocus.abilities.length !== 3 ? 'Focus needs 3 abilities.' : undefined
|
||||||
});
|
});
|
||||||
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
|
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
|
||||||
const ability = selectedFocus?.abilities[index];
|
const ability = selectedFocus?.abilities[index];
|
||||||
@@ -365,26 +379,26 @@
|
|||||||
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
|
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
|
||||||
return {
|
return {
|
||||||
id: `ability:${index}`,
|
id: `ability:${index}`,
|
||||||
label: `ABILITY 0${index + 1}`,
|
label: `ABILITY ${index + 1}`,
|
||||||
name: ability?.name ?? 'NOT ASSIGNED',
|
name: ability?.name ?? 'UNASSIGNED',
|
||||||
meta: ability?.attack?.damage_type ?? '',
|
meta: ability?.attack?.damage_type ?? '',
|
||||||
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
|
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
|
||||||
iconUrl: ability?.iconUrl,
|
iconUrl: ability?.iconUrl,
|
||||||
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
|
issue: !ability ? 'Ability required.' : configuredChips.length < 3 ? `${3 - configuredChips.length} chip${configuredChips.length === 2 ? '' : 's'} required.` : abilityChips.some((chip) => !chipSelectionConfigured(chip, ability.id)) ? 'Chip setup required.' : undefined
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
|
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
|
||||||
const item = weaponItem(index);
|
const item = weaponItem(index);
|
||||||
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean);
|
const talents = [[selection.talent1Id, selection.talent1Tier], [selection.talent2Id, selection.talent2Tier]].map(([id,tier]) => id ? `${talent(String(id))?.name ?? 'Talent'} T${tier}` : '').filter(Boolean);
|
||||||
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
|
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
|
||||||
return {
|
return {
|
||||||
id: `weapon:${index}`,
|
id: `weapon:${index}`,
|
||||||
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
|
label: index ? 'SECONDARY' : 'PRIMARY',
|
||||||
name: item?.name ?? 'EMPTY SLOT',
|
name: item?.name ?? 'EMPTY',
|
||||||
meta: item?.category ?? '',
|
meta: item?.category ?? '',
|
||||||
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
|
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'CHOOSE WEAPON',
|
||||||
iconUrl: weaponIcon(item?.category),
|
iconUrl: weaponIcon(item?.category),
|
||||||
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
issue: item ? undefined : 'Choose a weapon.'
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
|
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
|
||||||
@@ -392,36 +406,39 @@
|
|||||||
const item = gearItem(slot);
|
const item = gearItem(slot);
|
||||||
const details = [
|
const details = [
|
||||||
brand(selection.brandId)?.name,
|
brand(selection.brandId)?.name,
|
||||||
talent(selection.talentId)?.name,
|
selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
|
||||||
rollSummary(selection.rolls)
|
rollSummary(selection.rolls)
|
||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
return {
|
return {
|
||||||
id: `gear:${slot}`,
|
id: `gear:${slot}`,
|
||||||
label: slot.toUpperCase(),
|
label: slot.toUpperCase(),
|
||||||
name: item?.name ?? 'EMPTY SLOT',
|
name: item?.name ?? 'EMPTY',
|
||||||
meta: item ? `T${item.tier}` : '',
|
meta: item ? `T${item.tier}` : '',
|
||||||
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
|
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'CHOOSE GEAR',
|
||||||
iconUrl: gearIcon(slot),
|
iconUrl: gearIcon(slot),
|
||||||
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
issue: item ? undefined : 'Choose gear.'
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const protocolCell = $derived<MatrixCell>({
|
const protocolCell = $derived<MatrixCell>({
|
||||||
id: 'protocol:0',
|
id: 'protocol:0',
|
||||||
label: 'OS PROTOCOL',
|
label: 'PROTOCOL',
|
||||||
name: protocolItem()?.name ?? 'EMPTY SLOT',
|
name: protocolItem()?.name ?? 'EMPTY',
|
||||||
meta: protocolItem()?.quality ?? '',
|
meta: protocolItem()?.quality ?? '',
|
||||||
summary: protocolItem()
|
summary: protocolItem()
|
||||||
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
|
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
|
||||||
: 'SELECT ONE PROTOCOL',
|
: 'CHOOSE PROTOCOL',
|
||||||
iconUrl: protocolItem()?.iconUrl,
|
iconUrl: protocolItem()?.iconUrl,
|
||||||
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
issue: protocolItem() ? undefined : 'Choose a protocol.'
|
||||||
});
|
});
|
||||||
const loadoutConfigured = $derived(Boolean(
|
const loadoutConfigured = $derived(Boolean(
|
||||||
selectedFocus
|
selectedFocus
|
||||||
&& selectedFocus.abilities.length === 3
|
&& selectedFocus.abilities.length === 3
|
||||||
&& draft.weapons.every((item) => item.itemId)
|
&& draft.weapons.every((item) => item.itemId)
|
||||||
&& gearSlots.every((slot) => draft.gear[slot].itemId)
|
&& gearSlots.every((slot) => draft.gear[slot].itemId)
|
||||||
&& draft.chips.every(chipSelectionConfigured)
|
&& draft.chips.every((chip, index) => chipSelectionConfigured(
|
||||||
|
chip,
|
||||||
|
selectedFocus.abilities[Math.floor(index / 3)]?.id ?? ''
|
||||||
|
))
|
||||||
&& draft.protocol.itemId
|
&& draft.protocol.itemId
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -429,17 +446,24 @@
|
|||||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
localStorage.removeItem('shd-build-loadout-draft-v3');
|
||||||
localStorage.removeItem('shd-build-loadout-draft-v4');
|
localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||||
if (data.initialBuild) return;
|
if (data.initialBuild) return;
|
||||||
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
|
const current = localStorage.getItem('shd-build-loadout-draft-v6');
|
||||||
|
const legacy = current ? null : localStorage.getItem('shd-build-loadout-draft-v5');
|
||||||
|
const stored = current ?? legacy;
|
||||||
if (!stored) return;
|
if (!stored) return;
|
||||||
try {
|
try {
|
||||||
const parsed = parseLoadoutDraft(JSON.parse(stored));
|
const raw = JSON.parse(stored);
|
||||||
|
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
draft = parsed;
|
draft = parsed;
|
||||||
|
if (legacy) {
|
||||||
|
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(parsed));
|
||||||
|
localStorage.removeItem('shd-build-loadout-draft-v5');
|
||||||
|
}
|
||||||
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
||||||
saved = true;
|
saved = true;
|
||||||
} else localStorage.removeItem('shd-build-loadout-draft-v5');
|
} else localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('shd-build-loadout-draft-v5');
|
localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -457,7 +481,7 @@
|
|||||||
: 'Build published to the SHD archive.';
|
: 'Build published to the SHD archive.';
|
||||||
});
|
});
|
||||||
function saveDraft() {
|
function saveDraft() {
|
||||||
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
|
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
|
||||||
saved = true;
|
saved = true;
|
||||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||||
}
|
}
|
||||||
@@ -467,10 +491,10 @@
|
|||||||
<main class="app-page builder-page">
|
<main class="app-page builder-page">
|
||||||
<div class="builder-command">
|
<div class="builder-command">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
|
<div><p class="eyebrow"><span></span> BUILDER / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<span class:visible={saved}>SAVED LOCALLY</span>
|
<span class:visible={saved}>LOCAL SAVED</span>
|
||||||
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
|
<button class="secondary" onclick={saveDraft}>SAVE</button>
|
||||||
{#if data.viewer}
|
{#if data.viewer}
|
||||||
<PendingForm action="?/save" class="builder-save-form">
|
<PendingForm action="?/save" class="builder-save-form">
|
||||||
{#snippet children(pending)}
|
{#snippet children(pending)}
|
||||||
@@ -486,21 +510,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if data.viewer}
|
{#if data.viewer}
|
||||||
<div class="builder-persistence">
|
<div class="builder-persistence">
|
||||||
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
|
<SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
|
||||||
{ value:'private', label:'PRIVATE' },
|
{ value:'private', label:'PRIVATE' },
|
||||||
{ value:'unlisted', label:'UNLISTED' },
|
{ value:'unlisted', label:'UNLISTED' },
|
||||||
{ value:'public', label:'PUBLIC' }
|
{ value:'public', label:'PUBLIC' }
|
||||||
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
|
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
|
||||||
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
|
<p>{persistedVisibility === 'private' ? 'Private to you.' : persistedVisibility === 'unlisted' ? 'Direct link only.' : 'Public listing and link.'}</p>
|
||||||
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
<div class="builder-status"><span><i></i> STATS ONLINE</span></div>
|
||||||
</div>
|
</div>
|
||||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||||
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
||||||
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
|
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
|
||||||
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
|
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>NO BUILDER DATA</strong><span>Add active items in ISAC.</span></div>{/if}
|
||||||
<div class="loadout-workspace">
|
<div class="loadout-workspace">
|
||||||
<LoadoutMatrix
|
<LoadoutMatrix
|
||||||
{active}
|
{active}
|
||||||
@@ -515,129 +539,135 @@
|
|||||||
|
|
||||||
<section class="loadout-editor">
|
<section class="loadout-editor">
|
||||||
{#if activeParts[0] === 'specialization'}
|
{#if activeParts[0] === 'specialization'}
|
||||||
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
|
<div class="loadout-editor-head"><div><span>SPECIALIZATION</span><h2>FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
|
||||||
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
|
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
|
||||||
{#if selectedFocus}
|
{#if selectedFocus}
|
||||||
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
|
<div class="builder-specialization-layout">
|
||||||
<div class="builder-ability-chip-panels">
|
<div class="builder-editor-lane">
|
||||||
{#each [0,1,2] as abilityIndex}
|
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt=""/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'NEEDS DATA'}</b></div>
|
||||||
{@const ability = selectedFocus.abilities[abilityIndex]}
|
<div class="builder-focus-abilities" aria-label="Assigned abilities">
|
||||||
<section>
|
{#each [0,1,2] as abilityIndex}
|
||||||
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
|
{@const ability = selectedFocus.abilities[abilityIndex]}
|
||||||
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
|
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt="" className="builder-ability-icon"/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'UNASSIGNED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></button>
|
||||||
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
|
|
||||||
<div class="builder-focus-tree">
|
|
||||||
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
|
|
||||||
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
|
|
||||||
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
|
|
||||||
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
|
|
||||||
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div></section>
|
</div>
|
||||||
{/each}
|
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
|
||||||
|
</div>
|
||||||
|
<div class="builder-focus-tree builder-editor-lane">
|
||||||
|
<div class="builder-section-title"><div><span>UPGRADE TREE</span><small>UNLOCK IN ORDER</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
|
||||||
|
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
|
||||||
|
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'START' : 'PRIOR ROWS'}</b></header><div>
|
||||||
|
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
|
||||||
|
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
|
||||||
|
{/each}
|
||||||
|
</div></section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PlannedCombatModel headingId="builder-combat-model-heading" />
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeParts[0] === 'ability'}
|
{:else if activeParts[0] === 'ability'}
|
||||||
{@const abilityIndex = Number(activeParts[1])}
|
{@const abilityIndex = Number(activeParts[1])}
|
||||||
{@const ability = selectedFocus?.abilities[abilityIndex]}
|
{@const ability = selectedFocus?.abilities[abilityIndex]}
|
||||||
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
|
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'UNASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
|
||||||
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button>
|
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
|
||||||
{#if ability}
|
{#if ability}
|
||||||
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div>
|
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt="" className="ability-icon"/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA NEEDED'}</b></div>
|
||||||
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div>
|
<div class="builder-section-title"><div><span>SKILL CHIPS</span><small>3 SETS</small></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></div>
|
||||||
<div class="builder-ability-chip-panels single">
|
<div class="builder-ability-chip-panels single">
|
||||||
<section>
|
<section>
|
||||||
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
|
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><ItemIcon src={item?.iconUrl} alt="" className="builder-chip-icon"/><span><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY'}</strong><em>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT SET'}</em></span></button>{/each}</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
|
<p class="error">Choose a focus with 3 abilities first.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeParts[0] === 'gear'}
|
{:else if activeParts[0] === 'gear'}
|
||||||
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
|
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
|
||||||
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
|
<div class="loadout-editor-head"><div><span>GEAR</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `T${item.tier} · LV ${selection.level}` : 'UNASSIGNED'}</b></div>
|
||||||
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
|
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
|
||||||
{#if item}
|
{#if item}
|
||||||
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
|
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
|
||||||
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
|
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
|
||||||
<div class="builder-equipment-controls">
|
|
||||||
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
|
|
||||||
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
|
|
||||||
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
|
|
||||||
</div>
|
|
||||||
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
|
|
||||||
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
|
|
||||||
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
|
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
|
||||||
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
|
|
||||||
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
|
|
||||||
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
|
|
||||||
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
|
|
||||||
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
|
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
|
||||||
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
|
<div class="builder-editor-columns">
|
||||||
|
<section class="builder-editor-lane" aria-label="Gear identity and base values">
|
||||||
|
<div class="builder-equipment-controls">
|
||||||
|
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
|
||||||
|
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
|
||||||
|
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} value={selection.talentId} onchange={(id) => { selection.talentId = id; selection.talentTier = supportedTalentTier(id, selection.talentTier); }} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
|
||||||
|
{#if selection.talentId && talent(selection.talentId)}<TalentTierChoice name={`gear-${slot}-talent-tier`} talent={talent(selection.talentId)} bind:value={selection.talentTier}/>{/if}
|
||||||
|
</div>
|
||||||
|
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional.' : 'Base values scale with item level and use game-matching truncated display values.'}</p>
|
||||||
|
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
|
||||||
|
</section>
|
||||||
|
<section class="builder-editor-lane" aria-label="Gear attributes and augments">
|
||||||
|
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{item.tier} POOL</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
|
||||||
|
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
|
||||||
|
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
|
||||||
|
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
|
||||||
|
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>OPTIONAL ITEM UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeParts[0] === 'weapon'}
|
{:else if activeParts[0] === 'weapon'}
|
||||||
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
|
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
|
||||||
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
|
<div class="loadout-editor-head"><div><span>WEAPON</span><h2>{index ? 'SECONDARY' : 'PRIMARY'}</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
|
||||||
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
|
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON"/>
|
||||||
{#if item}
|
{#if item}
|
||||||
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
|
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
|
||||||
<div class="builder-weapon-controls">
|
|
||||||
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
|
|
||||||
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
|
|
||||||
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
|
|
||||||
</div>
|
|
||||||
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
||||||
{#if range}
|
{#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
|
||||||
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p>
|
<div class="builder-editor-columns">
|
||||||
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
|
<section class="builder-editor-lane" aria-label="Weapon identity and base values">
|
||||||
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
|
<div class="builder-weapon-controls">
|
||||||
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
|
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
|
||||||
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
|
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((entry: any) => ({ value: String(entry.tier), label: `T${entry.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
|
||||||
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
|
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
|
||||||
|
</div>
|
||||||
|
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range below level 40.' : 'Damage scales with the selected item level.'}</p>
|
||||||
|
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
|
||||||
|
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
|
||||||
|
</section>
|
||||||
|
<section class="builder-editor-lane" aria-label="Weapon attributes and talents">
|
||||||
|
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{selection.tier} POOL</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
|
||||||
|
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
|
||||||
|
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
|
||||||
|
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
|
||||||
|
<div class="builder-talent-grids">
|
||||||
|
{#if item.talent1Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent1Id} onchange={(id) => { selection.talent1Id = id; selection.talent1Tier = supportedTalentTier(id, selection.talent1Tier); }} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{#if selection.talent1Id && talent(selection.talent1Id)}<TalentTierChoice name={`weapon-${index}-talent-1-tier`} talent={talent(selection.talent1Id)} bind:value={selection.talent1Tier}/>{/if}</section>{/if}
|
||||||
|
{#if item.talent2Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent2Id} onchange={(id) => { selection.talent2Id = id; selection.talent2Tier = supportedTalentTier(id, selection.talent2Tier); }} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{#if selection.talent2Id && talent(selection.talent2Id)}<TalentTierChoice name={`weapon-${index}-talent-2-tier`} talent={talent(selection.talent2Id)} bind:value={selection.talent2Tier}/>{/if}</section>{/if}
|
||||||
|
{#if item.exotic_talent_id && talent(item.exotic_talent_id)}<section class="builder-talent-slot builder-exotic-talent"><div><ItemIcon src={talent(item.exotic_talent_id).iconUrl} alt=""/><span><small>EXOTIC TALENT</small><b>{talent(item.exotic_talent_id).name}</b></span></div><TalentTierChoice name={`weapon-${index}-exotic-talent-tier`} talent={talent(item.exotic_talent_id)} bind:value={selection.exoticTalentTier}/></section>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
|
|
||||||
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
|
|
||||||
<div class="builder-talent-grids">
|
|
||||||
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
|
|
||||||
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
|
|
||||||
</div>
|
|
||||||
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeParts[0] === 'chip'}
|
{:else if activeParts[0] === 'chip'}
|
||||||
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
|
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
|
||||||
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
<div class="loadout-editor-head"><div><span>CHIP {(index % 3) + 1} / 3</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
||||||
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
|
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO ABILITY</button>
|
||||||
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
|
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => chipSupportsAbility(entry, ability.id))} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="CHIP SET"/>{:else}<p class="error">Choose a focus with 3 abilities first.</p>{/if}
|
||||||
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
|
{#if !chipProfileConfigured}<p class="error">CHIP PROFILE REQUIRED. Configure shared ranges in ISAC.</p>{/if}
|
||||||
{#if item}
|
{#if item}
|
||||||
<div class="builder-field-grid">
|
<div class="builder-editor-columns builder-chip-editor">
|
||||||
<SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/>
|
<section class="builder-editor-lane" aria-label="Skill Chip quality and primary Attribute">
|
||||||
|
<div class="builder-field-grid"><SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/></div>
|
||||||
|
<div class="builder-section-title"><div><span>PRIMARY</span><small>ABILITY ONLY</small></div><b>NOT IN TOTALS</b></div>
|
||||||
|
<p class="builder-scaling-note">Not included in totals.</p>
|
||||||
|
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE REQUIRED</p>{/if}</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
|
||||||
|
<div class="builder-section-title"><div><span>SECONDARIES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
|
||||||
|
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE REQUIRED</p>{/if}</div>{/each}</div>
|
||||||
|
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">NO SECONDARIES</p>{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
|
|
||||||
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
|
|
||||||
<div class="builder-chip-rolls">
|
|
||||||
<div>
|
|
||||||
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
|
|
||||||
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
|
|
||||||
</div>
|
|
||||||
{#each chip.secondary as roll, secondaryIndex}
|
|
||||||
<div>
|
|
||||||
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
|
|
||||||
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{@const protocol = protocolItem()}
|
{@const protocol = protocolItem()}
|
||||||
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
|
<div class="loadout-editor-head"><div><span>PROTOCOL</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
|
||||||
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
|
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="PROTOCOL"/>
|
||||||
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
|
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -127,6 +127,58 @@ describe('Builder Skill Chip save validation', () => {
|
|||||||
expect(mocks.insertBuild).not.toHaveBeenCalled();
|
expect(mocks.insertBuild).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a Skill Chip owned by an explicitly compatible ability', async () => {
|
||||||
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
all: vi.fn(async () => {
|
||||||
|
if (sql.includes('FROM skill_chip_sets'))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'chip-set-1',
|
||||||
|
specialization_ability_id: 'ability-2',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (sql.includes('FROM specialization_ability_skill_chip_links'))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
ability_id: 'ability-1',
|
||||||
|
compatible_ability_id: 'ability-2',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (sql.includes('FROM specialization_focus_abilities'))
|
||||||
|
return [{ ability_id: 'ability-1', ability_slot: 1 }];
|
||||||
|
if (sql.includes('FROM skill_chip_primary_options'))
|
||||||
|
return [{ stat_attribute_id: primaryStatId }];
|
||||||
|
if (sql.includes('FROM skill_chip_primary_quality_values'))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
stat_attribute_id: primaryStatId,
|
||||||
|
quality: 'Superior',
|
||||||
|
min_value: 10,
|
||||||
|
max_value: 20,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (sql.includes('FROM skill_chip_secondary_options'))
|
||||||
|
return secondaryStatIds.map((stat_attribute_id) => ({
|
||||||
|
stat_attribute_id,
|
||||||
|
}));
|
||||||
|
if (sql.includes('FROM skill_chip_secondary_quality_values'))
|
||||||
|
return secondaryStatIds.map((stat_attribute_id) => ({
|
||||||
|
stat_attribute_id,
|
||||||
|
quality: 'Superior',
|
||||||
|
min_value: 1,
|
||||||
|
max_value: 5,
|
||||||
|
}));
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
run: mocks.insertBuild,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await runSave(validLoadout());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ buildSaved: true });
|
||||||
|
expect(mocks.insertBuild).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
label: 'primary',
|
label: 'primary',
|
||||||
@@ -183,4 +235,56 @@ describe('Builder Skill Chip save validation', () => {
|
|||||||
});
|
});
|
||||||
expect(mocks.insertBuild).toHaveBeenCalledOnce();
|
expect(mocks.insertBuild).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects an equipment Talent tier without canonical tier data', async () => {
|
||||||
|
const loadout = createDraft();
|
||||||
|
loadout.buildName = 'Invalid Talent Tier';
|
||||||
|
loadout.gear.Holster.itemId = 'gear-1';
|
||||||
|
loadout.gear.Holster.talentId = 'talent-1';
|
||||||
|
loadout.gear.Holster.talentTier = 2;
|
||||||
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
all: vi.fn(async () => {
|
||||||
|
if (sql.includes('FROM gear_talents'))
|
||||||
|
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
|
||||||
|
if (sql.includes('FROM talent_tiers'))
|
||||||
|
return [{ talent_id: 'talent-1', tier: 'I' }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
run: mocks.insertBuild,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await runSave(loadout);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
data: { buildError: 'Holster Talent T2 is not configured.' },
|
||||||
|
});
|
||||||
|
expect(mocks.insertBuild).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves an equipment Talent with an authored Tier II', async () => {
|
||||||
|
const loadout = createDraft();
|
||||||
|
loadout.buildName = 'Tier Two Talent';
|
||||||
|
loadout.gear.Holster.itemId = 'gear-1';
|
||||||
|
loadout.gear.Holster.talentId = 'talent-1';
|
||||||
|
loadout.gear.Holster.talentTier = 2;
|
||||||
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
all: vi.fn(async () => {
|
||||||
|
if (sql.includes('FROM gear_talents'))
|
||||||
|
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
|
||||||
|
if (sql.includes('FROM talent_tiers'))
|
||||||
|
return [
|
||||||
|
{ talent_id: 'talent-1', tier: 'I' },
|
||||||
|
{ talent_id: 'talent-1', tier: 'II' },
|
||||||
|
];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
run: mocks.insertBuild,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await runSave(loadout);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ buildSaved: true });
|
||||||
|
expect(mocks.insertBuild).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
const chip = (id: string) => data.chips.find((item) => item.id === id);
|
const chip = (id: string) => data.chips.find((item) => item.id === id);
|
||||||
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
|
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
|
||||||
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||||
|
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
||||||
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
||||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||||
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
|
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
|
||||||
@@ -56,11 +57,11 @@
|
|||||||
<div class="shared-build-grid">
|
<div class="shared-build-grid">
|
||||||
<section>
|
<section>
|
||||||
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
|
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
|
||||||
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
|
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b>{#if selection.talent1Id}<b>{talent(selection.talent1Id)?.name ?? 'TALENT 1'} · T{selection.talent1Tier}</b>{/if}{#if selection.talent2Id}<b>{talent(selection.talent2Id)?.name ?? 'TALENT 2'} · T{selection.talent2Tier}</b>{/if}{#if item.exotic_talent_id}<b>{talent(item.exotic_talent_id)?.name ?? 'EXOTIC TALENT'} · T{selection.exoticTalentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<header><span>GEAR</span><b>06 SLOTS</b></header>
|
<header><span>GEAR</span><b>06 SLOTS</b></header>
|
||||||
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
|
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}{#if selection.talentId}<b>{talent(selection.talentId)?.name ?? 'TALENT'} · T{selection.talentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
||||||
|
|||||||
@@ -502,16 +502,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
case 'skill-chips':
|
case 'skill-chips':
|
||||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||||
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
|
talents.name AS talent_name,talents.description_template AS talent_description,
|
||||||
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
|
|
||||||
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
|
|
||||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM skill_chip_sets item
|
FROM skill_chip_sets item
|
||||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||||
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
|
|
||||||
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
|
|
||||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||||
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
||||||
WHERE item.active=1${searchClause}
|
WHERE item.active=1${searchClause}
|
||||||
@@ -1035,23 +1031,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
||||||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||||||
facts: [
|
facts: [
|
||||||
{
|
|
||||||
label: 'SPECIALIZATION ABILITY',
|
|
||||||
value: row.ability_name
|
|
||||||
? `${row.ability_name} · ${row.specialization_name}`
|
|
||||||
: 'UNASSIGNED',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: '2-PIECE BONUS',
|
label: '2-PIECE BONUS',
|
||||||
value: row.bonus_stat
|
value: row.bonus_stat
|
||||||
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
||||||
: 'NOT CONFIGURED',
|
: 'NOT CONFIGURED',
|
||||||
},
|
},
|
||||||
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
|
|
||||||
{
|
|
||||||
label: 'SECONDARY POOL',
|
|
||||||
value: `${row.secondary_count} OPTIONS`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
@@ -1059,6 +1044,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
description: row.talent_description
|
||||||
|
? String(row.talent_description)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
case 'specializations':
|
case 'specializations':
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
|
|||||||
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
|
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
|
||||||
)
|
)
|
||||||
).all(id),
|
).all(id),
|
||||||
|
skillChipLinks: await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT ability_id,compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links
|
||||||
|
WHERE ability_id=? OR compatible_ability_id=?
|
||||||
|
ORDER BY ability_id,compatible_ability_id`,
|
||||||
|
)
|
||||||
|
).all(id, id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (proposal.action === 'saveCombatEffect') {
|
if (proposal.action === 'saveCombatEffect') {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
|
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
|
||||||
<section class="skill-chip-ability-assignment">
|
<section class="skill-chip-ability-assignment">
|
||||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
|
||||||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||||
{#each data.sets as item}
|
{#each data.sets as item}
|
||||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||||
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
||||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ async function focusSnapshot(database: Database, id: string) {
|
|||||||
async function abilitySnapshot(database: Database, id: string) {
|
async function abilitySnapshot(database: Database, id: string) {
|
||||||
return {
|
return {
|
||||||
record: await auditRecord('specialization_abilities', id),
|
record: await auditRecord('specialization_abilities', id),
|
||||||
|
skillChipLinks: await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT ability_id,compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links
|
||||||
|
WHERE ability_id=? OR compatible_ability_id=?
|
||||||
|
ORDER BY ability_id,compatible_ability_id`,
|
||||||
|
)
|
||||||
|
).all(id, id),
|
||||||
attack:
|
attack:
|
||||||
(await (
|
(await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
@@ -398,6 +406,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'cooldownSeconds',
|
'cooldownSeconds',
|
||||||
'effectsJson',
|
'effectsJson',
|
||||||
'attackJson',
|
'attackJson',
|
||||||
|
'skillChipCompatibleAbilityId',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -470,6 +479,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
specializations,
|
specializations,
|
||||||
focuses,
|
focuses,
|
||||||
abilities,
|
abilities,
|
||||||
|
abilitySkillChipLinks,
|
||||||
abilityAttacks,
|
abilityAttacks,
|
||||||
abilityEffects,
|
abilityEffects,
|
||||||
focusAbilities,
|
focusAbilities,
|
||||||
@@ -506,6 +516,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
|
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM specialization_ability_skill_chip_links ORDER BY ability_id,compatible_ability_id',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
||||||
).all(),
|
).all(),
|
||||||
@@ -672,6 +687,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
})),
|
})),
|
||||||
...focuses,
|
...focuses,
|
||||||
];
|
];
|
||||||
|
const linkedAbilityById = new Map<string, string>();
|
||||||
|
for (const link of abilitySkillChipLinks) {
|
||||||
|
linkedAbilityById.set(
|
||||||
|
String(link.ability_id),
|
||||||
|
String(link.compatible_ability_id),
|
||||||
|
);
|
||||||
|
linkedAbilityById.set(
|
||||||
|
String(link.compatible_ability_id),
|
||||||
|
String(link.ability_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
const loadedAbilities = [
|
const loadedAbilities = [
|
||||||
...pendingAbilities.map((item) => ({
|
...pendingAbilities.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -680,6 +706,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
cooldown_seconds: item.cooldownSeconds || null,
|
cooldown_seconds: item.cooldownSeconds || null,
|
||||||
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
|
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
|
||||||
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
|
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
|
||||||
|
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '',
|
||||||
active: 1,
|
active: 1,
|
||||||
pending: true,
|
pending: true,
|
||||||
skillChipCount: 0,
|
skillChipCount: 0,
|
||||||
@@ -698,6 +725,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
chipCounts.find((item) => item.specialization_ability_id === ability.id)
|
chipCounts.find((item) => item.specialization_ability_id === ability.id)
|
||||||
?.count ?? 0,
|
?.count ?? 0,
|
||||||
),
|
),
|
||||||
|
skillChipCompatibleAbilityId:
|
||||||
|
linkedAbilityById.get(String(ability.id)) ?? '',
|
||||||
iconUrl: iconUrl('specialization_ability', String(ability.id)),
|
iconUrl: iconUrl('specialization_ability', String(ability.id)),
|
||||||
renderUrl: renderUrl('specialization_ability', String(ability.id)),
|
renderUrl: renderUrl('specialization_ability', String(ability.id)),
|
||||||
})),
|
})),
|
||||||
@@ -1160,7 +1189,12 @@ export const actions: Actions = {
|
|||||||
);
|
);
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
|
const abilityId =
|
||||||
|
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||||
const specializationId = String(form.get('specializationId') ?? '');
|
const specializationId = String(form.get('specializationId') ?? '');
|
||||||
|
const skillChipCompatibleAbilityId = String(
|
||||||
|
form.get('skillChipCompatibleAbilityId') ?? '',
|
||||||
|
);
|
||||||
const name = cleanName(form.get('name'));
|
const name = cleanName(form.get('name'));
|
||||||
const description = cleanName(form.get('description'));
|
const description = cleanName(form.get('description'));
|
||||||
const durationSeconds = optionalPositive(form.get('durationSeconds'));
|
const durationSeconds = optionalPositive(form.get('durationSeconds'));
|
||||||
@@ -1312,6 +1346,46 @@ export const actions: Actions = {
|
|||||||
) {
|
) {
|
||||||
return fail(400, { abilityError: 'Choose an active specialization.' });
|
return fail(400, { abilityError: 'Choose an active specialization.' });
|
||||||
}
|
}
|
||||||
|
if (skillChipCompatibleAbilityId) {
|
||||||
|
if (skillChipCompatibleAbilityId === abilityId) {
|
||||||
|
return fail(400, {
|
||||||
|
abilityError: 'An ability cannot share Skill Chips with itself.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const compatibleAbility = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1',
|
||||||
|
)
|
||||||
|
).get(skillChipCompatibleAbilityId);
|
||||||
|
if (
|
||||||
|
!compatibleAbility ||
|
||||||
|
String(compatibleAbility.specialization_id) !== specializationId
|
||||||
|
) {
|
||||||
|
return fail(400, {
|
||||||
|
abilityError:
|
||||||
|
'Choose an active Skill Chip-compatible ability from the same specialization.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const conflictingLink = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT ability_id,compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links
|
||||||
|
WHERE (ability_id=? OR compatible_ability_id=?)
|
||||||
|
AND ability_id<>? AND compatible_ability_id<>?`,
|
||||||
|
)
|
||||||
|
).get(
|
||||||
|
skillChipCompatibleAbilityId,
|
||||||
|
skillChipCompatibleAbilityId,
|
||||||
|
abilityId,
|
||||||
|
abilityId,
|
||||||
|
);
|
||||||
|
if (conflictingLink) {
|
||||||
|
return fail(409, {
|
||||||
|
abilityError:
|
||||||
|
'Each ability can share one Skill Chip pool. Clear the existing link first.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!(await validateAttributeIds(
|
!(await validateAttributeIds(
|
||||||
database,
|
database,
|
||||||
@@ -1387,8 +1461,6 @@ export const actions: Actions = {
|
|||||||
: 'The ability icon could not be processed.',
|
: 'The ability icon could not be processed.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const abilityId =
|
|
||||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
|
||||||
const before = id ? await abilitySnapshot(database, id) : null;
|
const before = id ? await abilitySnapshot(database, id) : null;
|
||||||
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
|
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
|
||||||
const previousIcon = iconChanged
|
const previousIcon = iconChanged
|
||||||
@@ -1426,6 +1498,24 @@ export const actions: Actions = {
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`DELETE FROM specialization_ability_skill_chip_links
|
||||||
|
WHERE ability_id=? OR compatible_ability_id=?`,
|
||||||
|
)
|
||||||
|
).run(abilityId, abilityId);
|
||||||
|
if (skillChipCompatibleAbilityId) {
|
||||||
|
const [leftAbilityId, rightAbilityId] = [
|
||||||
|
abilityId,
|
||||||
|
skillChipCompatibleAbilityId,
|
||||||
|
].sort();
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`INSERT INTO specialization_ability_skill_chip_links
|
||||||
|
(ability_id,compatible_ability_id) VALUES(?,?)`,
|
||||||
|
)
|
||||||
|
).run(leftAbilityId, rightAbilityId);
|
||||||
|
}
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'DELETE FROM specialization_ability_effects WHERE ability_id=?',
|
'DELETE FROM specialization_ability_effects WHERE ability_id=?',
|
||||||
@@ -2326,6 +2416,12 @@ export const actions: Actions = {
|
|||||||
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
|
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
|
||||||
)
|
)
|
||||||
).run(new Date().toISOString(), id);
|
).run(new Date().toISOString(), id);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`DELETE FROM specialization_ability_skill_chip_links
|
||||||
|
WHERE ability_id=? OR compatible_ability_id=?`,
|
||||||
|
)
|
||||||
|
).run(id, id);
|
||||||
if (ability)
|
if (ability)
|
||||||
await refreshSpecializationFocuses(
|
await refreshSpecializationFocuses(
|
||||||
database,
|
database,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
description: string;
|
description: string;
|
||||||
durationSeconds: number | string;
|
durationSeconds: number | string;
|
||||||
cooldownSeconds: number | string;
|
cooldownSeconds: number | string;
|
||||||
|
skillChipCompatibleAbilityId: string;
|
||||||
attack: {
|
attack: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
baseDamage: number | string;
|
baseDamage: number | string;
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
||||||
});
|
});
|
||||||
const blankAbility = (specializationId = ''): AbilityEditor => ({
|
const blankAbility = (specializationId = ''): AbilityEditor => ({
|
||||||
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '',
|
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', skillChipCompatibleAbilityId: '',
|
||||||
attack: {
|
attack: {
|
||||||
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
|
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
|
||||||
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
|
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
|
||||||
@@ -161,10 +162,25 @@
|
|||||||
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
|
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
|
||||||
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
|
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
|
||||||
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
|
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
|
||||||
|
const abilityEligibleChipCount = (ability: any) => abilityChipCount(ability)
|
||||||
|
+ abilityChipCount(abilityById(String(ability?.skillChipCompatibleAbilityId ?? '')));
|
||||||
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
|
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
|
||||||
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
|
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
|
||||||
ability.specialization_id === specializationId && Number(ability.active) === 1
|
ability.specialization_id === specializationId && Number(ability.active) === 1
|
||||||
);
|
);
|
||||||
|
const skillChipCompatibilityOptions = (editor: AbilityEditor) => abilityList(editor.specializationId)
|
||||||
|
.filter((ability: any) => ability.id !== editor.id)
|
||||||
|
.map((ability: any) => ({
|
||||||
|
...ability,
|
||||||
|
detail: `${abilityChipCount(ability)} OWN CHIP SETS`,
|
||||||
|
disabled: Boolean(
|
||||||
|
ability.skillChipCompatibleAbilityId
|
||||||
|
&& ability.skillChipCompatibleAbilityId !== editor.id
|
||||||
|
),
|
||||||
|
disabledReason: ability.skillChipCompatibleAbilityId && ability.skillChipCompatibleAbilityId !== editor.id
|
||||||
|
? 'ALREADY SHARES ANOTHER POOL'
|
||||||
|
: ''
|
||||||
|
}));
|
||||||
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
|
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
|
||||||
effect.specialization_id === specializationId && Number(effect.active) === 1
|
effect.specialization_id === specializationId && Number(effect.active) === 1
|
||||||
);
|
);
|
||||||
@@ -234,6 +250,7 @@
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
durationSeconds: item.duration_seconds ?? '',
|
durationSeconds: item.duration_seconds ?? '',
|
||||||
cooldownSeconds: item.cooldown_seconds ?? '',
|
cooldownSeconds: item.cooldown_seconds ?? '',
|
||||||
|
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId ?? '',
|
||||||
attack: item.attack ? {
|
attack: item.attack ? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
baseDamage: item.attack.base_damage,
|
baseDamage: item.attack.base_damage,
|
||||||
@@ -499,6 +516,20 @@
|
|||||||
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
|
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
|
||||||
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
|
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="specialization-skill-chip-sharing">
|
||||||
|
<div class="selection-head"><div><span>SHARED SKILL CHIP POOL</span><small>OPTIONAL · SAME SPECIALIZATION · 3 EQUIPPED SLOTS, UP TO 6 ELIGIBLE SETS</small></div><b>{abilityEditing.skillChipCompatibleAbilityId ? 'SHARED' : 'ISOLATED'}</b></div>
|
||||||
|
<SearchableAssignment
|
||||||
|
items={skillChipCompatibilityOptions(abilityEditing)}
|
||||||
|
mode="single"
|
||||||
|
compact
|
||||||
|
fieldName="skillChipCompatibleAbilityId"
|
||||||
|
subjectLabel="COMPATIBLE ABILITY"
|
||||||
|
searchPlaceholder="Search this specialization…"
|
||||||
|
emptySelectionLabel="NO SHARED SKILL CHIP POOL"
|
||||||
|
bind:value={abilityEditing.skillChipCompatibleAbilityId}
|
||||||
|
/>
|
||||||
|
<p class="configuration-note">Link only abilities that can equip each other's Skill Chips in game. Unlinked abilities remain isolated.</p>
|
||||||
|
</div>
|
||||||
<div class="specialization-attack-profile">
|
<div class="specialization-attack-profile">
|
||||||
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
|
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
|
||||||
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
|
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
|
||||||
@@ -728,7 +759,7 @@
|
|||||||
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
|
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
|
||||||
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
|
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
|
||||||
{#if ability}
|
{#if ability}
|
||||||
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
|
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} OWNED · {abilityEligibleChipCount(ability)} ELIGIBLE · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
|
||||||
{/if}
|
{/if}
|
||||||
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
|
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -746,7 +777,7 @@
|
|||||||
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
|
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
|
||||||
<article class:archived={Number(ability.active) === 0}>
|
<article class:archived={Number(ability.active) === 0}>
|
||||||
<ItemIcon src={ability.iconUrl} alt={ability.name}/>
|
<ItemIcon src={ability.iconUrl} alt={ability.name}/>
|
||||||
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div>
|
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} OWNED · ${abilityEligibleChipCount(ability)} ELIGIBLE` : 'ARCHIVED CLASS ABILITY'}</small></div>
|
||||||
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
|
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
|
||||||
</article>
|
</article>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const links: Array<{ ability_id: string; compatible_ability_id: string }> =
|
||||||
|
[];
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
auditRecord: vi.fn(async () => ({
|
||||||
|
id: 'ability-1',
|
||||||
|
specialization_id: 'specialization-1',
|
||||||
|
name: 'Tactical Link',
|
||||||
|
})),
|
||||||
|
recordIsacAudit: vi.fn(),
|
||||||
|
database: {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/session', () => ({
|
||||||
|
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/authorization', () => ({
|
||||||
|
requireDataView: vi.fn(async (user) => user),
|
||||||
|
requirePermission: vi.fn(async (user) => user),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: vi.fn(async () => mocks.database),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/audit', () => ({
|
||||||
|
auditRecord: mocks.auditRecord,
|
||||||
|
recordIsacAudit: mocks.recordIsacAudit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/data-moderation', () => ({
|
||||||
|
deserializeProposal: vi.fn(),
|
||||||
|
getPendingEntities: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/game-data-icons', () => ({
|
||||||
|
applyIconMutation: vi.fn(),
|
||||||
|
gameDataIconStorageDirectory: vi.fn(),
|
||||||
|
gameDataIconMetadata: vi.fn(async () => null),
|
||||||
|
gameDataIconUrl: vi.fn(() => ''),
|
||||||
|
GameDataIconValidationError: class extends Error {},
|
||||||
|
readGameDataIcon: vi.fn(async () => null),
|
||||||
|
readIconMutation: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/managed-images', () => ({
|
||||||
|
applyGameDataRender: vi.fn(),
|
||||||
|
ManagedImageValidationError: class extends Error {},
|
||||||
|
managedImageUrl: vi.fn(() => ''),
|
||||||
|
processManagedImage: vi.fn(),
|
||||||
|
readGameDataRenderMutation: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/specialization-readiness', () => ({
|
||||||
|
refreshSpecializationFocusReady: vi.fn(),
|
||||||
|
refreshSpecializationFocuses: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/conditional-effects', () => ({
|
||||||
|
conditionalRuleSnapshot: vi.fn(),
|
||||||
|
loadConditionalRuleDraft: vi.fn(),
|
||||||
|
parseConditionalRulePayload: vi.fn(),
|
||||||
|
saveConditionalRuleInTransaction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
|
const saveRequest = (compatibleAbilityId: string) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', 'ability-1');
|
||||||
|
form.set('specializationId', 'specialization-1');
|
||||||
|
form.set('skillChipCompatibleAbilityId', compatibleAbilityId);
|
||||||
|
form.set('name', 'Tactical Link');
|
||||||
|
form.set('description', 'Deploy a tactical support link.');
|
||||||
|
form.set('effectsJson', '[]');
|
||||||
|
form.set('attackJson', '{"enabled":false}');
|
||||||
|
return new Request('http://localhost/isac/specializations?/saveAbility', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSave = (compatibleAbilityId: string) =>
|
||||||
|
actions.saveAbility?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: saveRequest(compatibleAbilityId),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const runArchive = () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', 'ability-1');
|
||||||
|
return actions.archiveAbility?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: new Request(
|
||||||
|
'http://localhost/isac/specializations?/archiveAbility',
|
||||||
|
{ method: 'POST', body: form },
|
||||||
|
),
|
||||||
|
} as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Specialization ability Skill Chip sharing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.links.length = 0;
|
||||||
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
get: vi.fn(async (...args: unknown[]) => {
|
||||||
|
if (
|
||||||
|
sql.includes('SELECT specialization_id FROM specialization_abilities')
|
||||||
|
)
|
||||||
|
return { specialization_id: 'specialization-1' };
|
||||||
|
if (sql.includes('SELECT id FROM specializations'))
|
||||||
|
return { id: 'specialization-1' };
|
||||||
|
if (
|
||||||
|
sql.includes(
|
||||||
|
'SELECT id,specialization_id FROM specialization_abilities',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
id: String(args[0]),
|
||||||
|
specialization_id:
|
||||||
|
args[0] === 'cross-specialization-ability'
|
||||||
|
? 'specialization-2'
|
||||||
|
: 'specialization-1',
|
||||||
|
};
|
||||||
|
if (sql.includes('FROM specialization_ability_skill_chip_links'))
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
sql.includes('FROM specialization_abilities WHERE specialization_id=')
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (sql.includes('FROM specialization_ability_attacks')) return null;
|
||||||
|
if (sql.includes('FROM managed_images')) return null;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
all: vi.fn(async () => {
|
||||||
|
if (sql.includes('FROM specialization_ability_skill_chip_links'))
|
||||||
|
return [...mocks.links];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
run: vi.fn(async (...args: unknown[]) => {
|
||||||
|
if (sql.includes('DELETE FROM specialization_ability_skill_chip_links'))
|
||||||
|
mocks.links.length = 0;
|
||||||
|
if (sql.includes('INSERT INTO specialization_ability_skill_chip_links'))
|
||||||
|
mocks.links.push({
|
||||||
|
ability_id: String(args[0]),
|
||||||
|
compatible_ability_id: String(args[1]),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves and audits an explicit same-specialization ability pair', async () => {
|
||||||
|
const result = await runSave('ability-2');
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
abilitySaved: true,
|
||||||
|
abilityMode: 'edit',
|
||||||
|
resultToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(mocks.links).toEqual([
|
||||||
|
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
|
||||||
|
]);
|
||||||
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'specialization_ability.updated',
|
||||||
|
entityId: 'ability-1',
|
||||||
|
before: expect.objectContaining({ record: expect.any(Object) }),
|
||||||
|
after: expect.objectContaining({
|
||||||
|
skillChipLinks: [
|
||||||
|
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cross-specialization sharing without writing or auditing', async () => {
|
||||||
|
const result = await runSave('cross-specialization-ability');
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
abilityError:
|
||||||
|
'Choose an active Skill Chip-compatible ability from the same specialization.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the relationship when either ability is archived', async () => {
|
||||||
|
mocks.links.push({
|
||||||
|
ability_id: 'ability-1',
|
||||||
|
compatible_ability_id: 'ability-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runArchive();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
abilityArchived: true,
|
||||||
|
resultToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(mocks.links).toEqual([]);
|
||||||
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'specialization_ability.archived',
|
||||||
|
entityId: 'ability-1',
|
||||||
|
before: expect.objectContaining({
|
||||||
|
skillChipLinks: [
|
||||||
|
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
after: expect.objectContaining({ skillChipLinks: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.12
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Copy this file to $XDG_CONFIG_HOME/shd-capture/config.toml and adjust local limits.
|
|
||||||
# The utility reads the OpenAI key from the named environment variable only.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# Only loopback IP literals are valid: 127.0.0.1 or ::1.
|
|
||||||
host = "127.0.0.1"
|
|
||||||
port = 8765
|
|
||||||
|
|
||||||
[lifecycle]
|
|
||||||
# One local process owns this instance name and releases its runtime lock on shutdown.
|
|
||||||
instance_name = "shd-capture"
|
|
||||||
lock_timeout_seconds = 5
|
|
||||||
shutdown_timeout_seconds = 30
|
|
||||||
|
|
||||||
[limits]
|
|
||||||
max_image_bytes = 33554432
|
|
||||||
max_video_bytes = 4294967296
|
|
||||||
max_queue_items = 64
|
|
||||||
max_ocr_text_characters = 20000
|
|
||||||
|
|
||||||
[openai]
|
|
||||||
# Set this name in the process environment. Do not put the key in this file.
|
|
||||||
api_key_env = "OPENAI_API_KEY"
|
|
||||||
model = "gpt-4.1-mini"
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Game Data Capture Contracts
|
|
||||||
|
|
||||||
RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence
|
|
||||||
model, archive manifest, and ISAC handoff boundary. The approved GDC-001
|
|
||||||
mod-family change extends version 1 with `weapon_mod` and `gear_mod`.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `domain-contract-v1.json` lists every family/view pair, record type, Gear
|
|
||||||
slot, abstention reason, state, and allowed transition.
|
|
||||||
- `screenshot-classification.schema.json` validates one hierarchical screen
|
|
||||||
classification.
|
|
||||||
- `evidence-archive-manifest-v1.schema.json` validates a private evidence
|
|
||||||
archive manifest.
|
|
||||||
- `isac-handoff-v1.schema.json` validates a bounded draft handoff for
|
|
||||||
authenticated ISAC intake.
|
|
||||||
- `examples/classification/` contains one valid and one invalid classification
|
|
||||||
for each family, including Weapon Mod, Gear Mod, and explicit `unknown`
|
|
||||||
abstention.
|
|
||||||
- `examples/archive/` and `examples/handoff/` exercise each versioned document
|
|
||||||
schema.
|
|
||||||
|
|
||||||
The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard
|
|
||||||
formats, and no implementation-specific keywords. Python `jsonschema` and
|
|
||||||
TypeScript validators such as Ajv can compile the files without model
|
|
||||||
conversion. Consumers must reject a document unless `schema_version` equals the
|
|
||||||
schema's `const` value. Consumers must also run semantic reference validation
|
|
||||||
because JSON Schema cannot prove that an ID points to an item in another array.
|
|
||||||
|
|
||||||
## Mod Screen Semantics
|
|
||||||
|
|
||||||
Repository source data and editors establish `weapon_mod` and `gear_mod` as
|
|
||||||
separate canonical aggregates. Each family currently supports only the `detail`
|
|
||||||
view and its same-named record type. A Mod classification never sets the
|
|
||||||
six-slot `gear_slot` field. A Weapon Mod detail screenshot may provide evidence
|
|
||||||
for item-owned slot, primary quality ranges, compatibility, Mod Effects, exotic
|
|
||||||
Attributes, and weapon-lock data. A Gear Mod detail screenshot may provide
|
|
||||||
evidence for its item-owned primary quality ranges. These fields and
|
|
||||||
relationships are not separate screen views or record types.
|
|
||||||
|
|
||||||
Each mod family has one global canonical secondary Attribute pool configured
|
|
||||||
outside individual item details. The pool stores Level 1 and Level 20 minimum
|
|
||||||
and maximum ranges for Specialized, Superior, and High-end quality. Capacity is
|
|
||||||
zero for Standard, one for Specialized, and two each for Superior and High-end.
|
|
||||||
A secondary roll visible on an item-detail screenshot is evidence for that
|
|
||||||
observed item instance; it does not establish a pool option, canonical range
|
|
||||||
endpoint, or family-wide capacity.
|
|
||||||
|
|
||||||
The `record_types` array still supports composite screens. No repository
|
|
||||||
evidence establishes another independent canonical record type on a Weapon Mod
|
|
||||||
or Gear Mod detail screen, so these two mappings each contain one record type. A
|
|
||||||
classifier must use `unknown` with an abstention reason for another Mod view, an
|
|
||||||
ambiguous family, or unsupported content.
|
|
||||||
|
|
||||||
## Skill Chip Screen Semantics
|
|
||||||
|
|
||||||
The implemented Skill Chip model assigns each definition to one specialization
|
|
||||||
ability. Each set owns one 2-piece bonus and may own one 3-piece Talent. One
|
|
||||||
shared fixed primary profile serves every definition: Skill Duration, Skill
|
|
||||||
Radius, Skill Cooldown Recovery, and Skill Intensity each store direct minimum
|
|
||||||
and maximum values for Standard, Specialized, Superior, and High-end. These
|
|
||||||
primary values apply to the assigned ability. Current Agent and combat totals
|
|
||||||
exclude them and present them as `NOT YET DEFINED`.
|
|
||||||
|
|
||||||
One shared secondary Attribute pool also serves every definition. Its entries
|
|
||||||
store direct minimum and maximum values for Specialized, Superior, and High-end.
|
|
||||||
Secondary capacity is zero for Standard, one for Specialized, and two each for
|
|
||||||
Superior and High-end. Skill Chips have no item levels, so workers must not
|
|
||||||
infer level curves or level-based endpoints from Skill Chip evidence.
|
|
||||||
|
|
||||||
Every equipped quality counts toward the two-piece threshold. Only equipped
|
|
||||||
High-end chips increase `h` in `minimum + (maximum - minimum) * h / 3`, where
|
|
||||||
`h` ranges from zero through three. The unchanged version 1 contract vocabulary
|
|
||||||
uses `skill_chip_roll_pool` for captured roll-profile evidence. Handoff
|
|
||||||
consumers must map that evidence to the shared primary profile or shared
|
|
||||||
secondary pool and must not attach either profile to an individual definition.
|
|
||||||
The JSON schemas remain unchanged.
|
|
||||||
|
|
||||||
## Version Decision
|
|
||||||
|
|
||||||
The approved additive taxonomy change remains schema version 1. The document
|
|
||||||
shapes, trust boundary, bounds, and meanings of every existing value are
|
|
||||||
unchanged, and all existing version 1 labels and documents remain valid. This
|
|
||||||
utility and its payloads are prototype formats; no production-stable archive or
|
|
||||||
handoff version has been declared. A version increment would force a document
|
|
||||||
migration without representing a structural or semantic break.
|
|
||||||
|
|
||||||
Consumers must update their closed version 1 vocabularies before they accept Mod
|
|
||||||
labels. Older consumers reject the new values, which keeps the boundary fail
|
|
||||||
closed. Existing stored labels need validation against the revised vocabulary
|
|
||||||
but no value rewrite. The capture-label UI, storage regression coverage,
|
|
||||||
classifier, extraction dispatch, evidence export, and ISAC adapter mappings must
|
|
||||||
consume both families and their exact `detail`/record-type pairs before their
|
|
||||||
respective gates can pass. This contract change does not alter those downstream
|
|
||||||
implementations.
|
|
||||||
|
|
||||||
## Evidence Rules
|
|
||||||
|
|
||||||
Capture workers store source text, a normalized value, confidence, engine
|
|
||||||
identity, an image digest, a bounded region, and append-only correction history
|
|
||||||
for each evidence value. A null normalized value requires a `missing_reason`. A
|
|
||||||
present normalized value requires a null `missing_reason`. Each correction's
|
|
||||||
`before` state must match the preceding `after` state. Timestamps must increase,
|
|
||||||
and the final `after` state must match the current evidence value.
|
|
||||||
|
|
||||||
Workers record the configured item shown on screen under `observed_instances`,
|
|
||||||
including visible values and any applicable quality, tier, upgrade state, or
|
|
||||||
level. Skill Chip observations omit item level because Skill Chips have none.
|
|
||||||
Workers place proposed reusable definitions under `canonical_candidates` and
|
|
||||||
handoff `records`. For Weapon Mods, Gear Mods, and Skill Chips, item-detail
|
|
||||||
evidence remains separate from each family’s shared canonical profiles. A worker
|
|
||||||
must not copy an observed roll, relationship, mechanic, range endpoint, option
|
|
||||||
list, or secondary capacity into a canonical candidate unless evidence
|
|
||||||
establishes that canonical claim.
|
|
||||||
|
|
||||||
## Trust Boundary
|
|
||||||
|
|
||||||
Archive and handoff documents contain content hashes and logical artifact IDs.
|
|
||||||
They exclude host paths and credentials. The handoff declares
|
|
||||||
`mutation_authority: "none"`. Importing or inspecting it grants no permission
|
|
||||||
and performs no mutation. An authenticated ISAC action must revalidate each
|
|
||||||
draft and preserve contributor moderation and audit behavior.
|
|
||||||
|
|
||||||
Archive images and handoff images allow at most 64 MiB. Private archive videos
|
|
||||||
allow at most 16 GiB, matching the configured capture ceiling. Schema validation
|
|
||||||
enforces media-specific byte limits. Semantic validation enforces unique entity
|
|
||||||
and correction IDs, local references, evidence artifact hashes, correction
|
|
||||||
chains, and normalized image bounds.
|
|
||||||
|
|
||||||
The contracts assign explicit states or reasons to cancellation, resource
|
|
||||||
exhaustion, ambiguous classification, unsupported content, and extraction
|
|
||||||
failure. Recovery requires a listed transition. Unknown transitions and future
|
|
||||||
schema versions fail closed.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{
|
|
||||||
"contract_version": 1,
|
|
||||||
"families": {
|
|
||||||
"weapon": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["weapon", "weapon_talent"],
|
|
||||||
"blueprint": ["weapon", "weapon_talent"],
|
|
||||||
"crafting": ["weapon"],
|
|
||||||
"stat_range": ["weapon"],
|
|
||||||
"talent_grid": ["weapon_talent"],
|
|
||||||
"augmentation": ["weapon_augmentation_profile"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gear": {
|
|
||||||
"gear_slot": true,
|
|
||||||
"views": {
|
|
||||||
"detail": ["gear", "brand_set"],
|
|
||||||
"blueprint": ["gear", "brand_set"],
|
|
||||||
"talent": ["gear_talent"],
|
|
||||||
"augmentation": ["gear_augmentation_profile"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"skill_chip": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["skill_chip_set", "skill_chip_roll_pool"],
|
|
||||||
"set_bonus": ["skill_chip_set_bonus"],
|
|
||||||
"talent": ["skill_chip_talent"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"os_protocol": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["os_protocol"],
|
|
||||||
"secondary_profile": ["os_protocol_secondary_profile"],
|
|
||||||
"talent": ["os_protocol_talent"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"specialization": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"overview": ["specialization"],
|
|
||||||
"details": ["specialization"],
|
|
||||||
"ability": ["specialization_ability"],
|
|
||||||
"focus_details": ["specialization_focus"],
|
|
||||||
"focus_tree": ["specialization_focus", "specialization_tree_node"],
|
|
||||||
"rank_details": ["specialization_tree_rank"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"weapon_mod": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["weapon_mod"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gear_mod": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["gear_mod"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"unknown": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": { "unknown": [] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gear_slots": [
|
|
||||||
"Mask",
|
|
||||||
"Gloves",
|
|
||||||
"Backpack",
|
|
||||||
"Body Armor",
|
|
||||||
"Holster",
|
|
||||||
"Knee Pads"
|
|
||||||
],
|
|
||||||
"abstention_reasons": [
|
|
||||||
"low_confidence",
|
|
||||||
"ambiguous_family",
|
|
||||||
"ambiguous_view",
|
|
||||||
"unsupported_family",
|
|
||||||
"unsupported_view",
|
|
||||||
"insufficient_visual_evidence",
|
|
||||||
"occluded",
|
|
||||||
"corrupt_image",
|
|
||||||
"resource_limit",
|
|
||||||
"cancelled"
|
|
||||||
],
|
|
||||||
"missing_reasons": [
|
|
||||||
"not_visible",
|
|
||||||
"occluded",
|
|
||||||
"unreadable",
|
|
||||||
"not_present_on_screen",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous",
|
|
||||||
"extraction_failed",
|
|
||||||
"not_applicable",
|
|
||||||
"redacted"
|
|
||||||
],
|
|
||||||
"states": {
|
|
||||||
"capture": {
|
|
||||||
"received": ["spooling", "deduplicated", "failed", "cancelled"],
|
|
||||||
"spooling": ["stored", "failed", "cancelled"],
|
|
||||||
"stored": ["queued", "failed"],
|
|
||||||
"deduplicated": [],
|
|
||||||
"queued": [],
|
|
||||||
"failed": ["spooling", "cancelled"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"job": {
|
|
||||||
"queued": ["running", "cancelled"],
|
|
||||||
"running": ["succeeded", "retry_wait", "failed", "cancelled"],
|
|
||||||
"retry_wait": ["queued", "failed", "cancelled"],
|
|
||||||
"succeeded": [],
|
|
||||||
"failed": ["queued"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"extraction": {
|
|
||||||
"pending": ["local_processing", "cancelled"],
|
|
||||||
"local_processing": [
|
|
||||||
"candidate_ready",
|
|
||||||
"fallback_pending",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
],
|
|
||||||
"fallback_pending": ["fallback_processing", "cancelled"],
|
|
||||||
"fallback_processing": [
|
|
||||||
"candidate_ready",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
],
|
|
||||||
"candidate_ready": [],
|
|
||||||
"abstained": [],
|
|
||||||
"failed": ["pending"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"grouping": {
|
|
||||||
"ungrouped": ["proposed", "cancelled"],
|
|
||||||
"proposed": ["grouped", "rejected", "cancelled"],
|
|
||||||
"grouped": ["split", "superseded"],
|
|
||||||
"split": ["superseded"],
|
|
||||||
"superseded": [],
|
|
||||||
"rejected": ["ungrouped"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"pending": ["in_review", "cancelled"],
|
|
||||||
"in_review": ["changes_requested", "approved", "rejected", "cancelled"],
|
|
||||||
"changes_requested": ["in_review", "rejected", "cancelled"],
|
|
||||||
"approved": ["in_review"],
|
|
||||||
"rejected": ["in_review"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"not_requested": ["queued"],
|
|
||||||
"queued": ["building", "cancelled"],
|
|
||||||
"building": ["ready", "failed", "cancelled"],
|
|
||||||
"ready": ["expired"],
|
|
||||||
"failed": ["queued", "cancelled"],
|
|
||||||
"cancelled": [],
|
|
||||||
"expired": ["queued"]
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"open": ["retryable", "terminal", "dismissed"],
|
|
||||||
"retryable": ["retrying", "terminal", "dismissed"],
|
|
||||||
"retrying": ["recovered", "retryable", "terminal"],
|
|
||||||
"recovered": [],
|
|
||||||
"terminal": ["dismissed"],
|
|
||||||
"dismissed": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,924 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://shd.build/schemas/game-data-capture/evidence-archive-manifest-v1.json",
|
|
||||||
"title": "Game data capture evidence archive manifest v1",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schema_version",
|
|
||||||
"archive_id",
|
|
||||||
"created_at",
|
|
||||||
"generator",
|
|
||||||
"artifacts",
|
|
||||||
"captures",
|
|
||||||
"screens",
|
|
||||||
"jobs",
|
|
||||||
"extractions",
|
|
||||||
"groups",
|
|
||||||
"reviews",
|
|
||||||
"exports",
|
|
||||||
"errors",
|
|
||||||
"evidence_values",
|
|
||||||
"observed_instances",
|
|
||||||
"canonical_candidates"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schema_version": { "const": 1 },
|
|
||||||
"archive_id": { "$ref": "#/$defs/id" },
|
|
||||||
"created_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"generator": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["name", "version", "policy_version"],
|
|
||||||
"properties": {
|
|
||||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
|
||||||
"version": { "type": "string", "minLength": 1, "maxLength": 50 },
|
|
||||||
"policy_version": { "type": "string", "minLength": 1, "maxLength": 50 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artifacts": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/artifact" }
|
|
||||||
},
|
|
||||||
"captures": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/capture" }
|
|
||||||
},
|
|
||||||
"screens": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/screen" }
|
|
||||||
},
|
|
||||||
"jobs": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/job" }
|
|
||||||
},
|
|
||||||
"extractions": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/extraction" }
|
|
||||||
},
|
|
||||||
"groups": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/group" }
|
|
||||||
},
|
|
||||||
"reviews": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/review" }
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/export" }
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/error" }
|
|
||||||
},
|
|
||||||
"evidence_values": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100000,
|
|
||||||
"items": { "$ref": "#/$defs/evidenceValue" }
|
|
||||||
},
|
|
||||||
"observed_instances": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/observedInstance" }
|
|
||||||
},
|
|
||||||
"canonical_candidates": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/canonicalCandidate" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$defs": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
|
||||||
},
|
|
||||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
||||||
"timestamp": { "type": "string", "format": "date-time", "maxLength": 40 },
|
|
||||||
"family": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"gear",
|
|
||||||
"skill_chip",
|
|
||||||
"os_protocol",
|
|
||||||
"specialization",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod",
|
|
||||||
"unknown"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"recordType": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"weapon_talent",
|
|
||||||
"weapon_augmentation_profile",
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile",
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent",
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent",
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"missingReason": {
|
|
||||||
"enum": [
|
|
||||||
"not_visible",
|
|
||||||
"occluded",
|
|
||||||
"unreadable",
|
|
||||||
"not_present_on_screen",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous",
|
|
||||||
"extraction_failed",
|
|
||||||
"not_applicable",
|
|
||||||
"redacted"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"jsonValue": {
|
|
||||||
"oneOf": [
|
|
||||||
{ "type": "null" },
|
|
||||||
{ "type": "boolean" },
|
|
||||||
{ "type": "number" },
|
|
||||||
{ "type": "string", "maxLength": 8192 },
|
|
||||||
{
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 500,
|
|
||||||
"items": { "$ref": "#/$defs/jsonValue" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"maxProperties": 500,
|
|
||||||
"additionalProperties": { "$ref": "#/$defs/jsonValue" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sourceEngine": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["kind", "engine_id", "engine_version"],
|
|
||||||
"properties": {
|
|
||||||
"kind": {
|
|
||||||
"enum": [
|
|
||||||
"local_ocr",
|
|
||||||
"openai_vision",
|
|
||||||
"classifier",
|
|
||||||
"manual",
|
|
||||||
"video_decoder"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"engine_id": { "type": "string", "minLength": 1, "maxLength": 100 },
|
|
||||||
"engine_version": { "type": "string", "minLength": 1, "maxLength": 100 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
|
||||||
"properties": {
|
|
||||||
"coordinate_space": { "const": "normalized" },
|
|
||||||
"x": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"y": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
|
||||||
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
|
||||||
"properties": {
|
|
||||||
"coordinate_space": { "const": "pixels" },
|
|
||||||
"x": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
|
||||||
"y": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
|
||||||
"width": { "type": "integer", "minimum": 1, "maximum": 100000 },
|
|
||||||
"height": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"valueState": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["raw_text", "normalized_value", "missing_reason"],
|
|
||||||
"properties": {
|
|
||||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
|
||||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"correction": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"correction_id",
|
|
||||||
"corrected_at",
|
|
||||||
"actor",
|
|
||||||
"reason",
|
|
||||||
"before",
|
|
||||||
"after"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"correction_id": { "$ref": "#/$defs/id" },
|
|
||||||
"corrected_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"actor": { "enum": ["local_user", "system_reprocess"] },
|
|
||||||
"reason": { "type": "string", "minLength": 1, "maxLength": 1000 },
|
|
||||||
"before": { "$ref": "#/$defs/valueState" },
|
|
||||||
"after": { "$ref": "#/$defs/valueState" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"evidenceValue": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"evidence_id",
|
|
||||||
"field_path",
|
|
||||||
"raw_text",
|
|
||||||
"normalized_value",
|
|
||||||
"missing_reason",
|
|
||||||
"confidence",
|
|
||||||
"source_engine",
|
|
||||||
"image_artifact_id",
|
|
||||||
"image_sha256",
|
|
||||||
"region",
|
|
||||||
"correction_history"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"evidence_id": { "$ref": "#/$defs/id" },
|
|
||||||
"field_path": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9_.-]{1,256}$"
|
|
||||||
},
|
|
||||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
|
||||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
},
|
|
||||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"source_engine": { "$ref": "#/$defs/sourceEngine" },
|
|
||||||
"image_artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"image_sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"region": { "$ref": "#/$defs/region" },
|
|
||||||
"correction_history": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100,
|
|
||||||
"items": { "$ref": "#/$defs/correction" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fieldClaim": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"path",
|
|
||||||
"value",
|
|
||||||
"missing_reason",
|
|
||||||
"evidence_refs",
|
|
||||||
"uncertainty_note"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"path": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$" },
|
|
||||||
"value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"uncertainty_note": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"classification": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"family",
|
|
||||||
"view",
|
|
||||||
"gear_slot",
|
|
||||||
"record_types",
|
|
||||||
"confidence",
|
|
||||||
"abstention_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"family": { "$ref": "#/$defs/family" },
|
|
||||||
"view": { "type": "string" },
|
|
||||||
"gear_slot": { "type": ["string", "null"] },
|
|
||||||
"record_types": {
|
|
||||||
"type": "array",
|
|
||||||
"uniqueItems": true,
|
|
||||||
"maxItems": 8,
|
|
||||||
"items": { "$ref": "#/$defs/recordType" }
|
|
||||||
},
|
|
||||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"abstention_reason": { "type": ["string", "null"] }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "weapon" },
|
|
||||||
"view": {
|
|
||||||
"enum": [
|
|
||||||
"detail",
|
|
||||||
"blueprint",
|
|
||||||
"crafting",
|
|
||||||
"stat_range",
|
|
||||||
"talent_grid",
|
|
||||||
"augmentation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"weapon_talent",
|
|
||||||
"weapon_augmentation_profile"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "gear" },
|
|
||||||
"view": {
|
|
||||||
"enum": ["detail", "blueprint", "talent", "augmentation"]
|
|
||||||
},
|
|
||||||
"gear_slot": {
|
|
||||||
"enum": [
|
|
||||||
"Mask",
|
|
||||||
"Gloves",
|
|
||||||
"Backpack",
|
|
||||||
"Body Armor",
|
|
||||||
"Holster",
|
|
||||||
"Knee Pads"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "skill_chip" },
|
|
||||||
"view": { "enum": ["detail", "set_bonus", "talent"] },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "os_protocol" },
|
|
||||||
"view": { "enum": ["detail", "secondary_profile", "talent"] },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "specialization" },
|
|
||||||
"view": {
|
|
||||||
"enum": [
|
|
||||||
"overview",
|
|
||||||
"details",
|
|
||||||
"ability",
|
|
||||||
"focus_details",
|
|
||||||
"focus_tree",
|
|
||||||
"rank_details"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "weapon_mod" },
|
|
||||||
"view": { "const": "detail" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1,
|
|
||||||
"items": { "const": "weapon_mod" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "gear_mod" },
|
|
||||||
"view": { "const": "detail" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1,
|
|
||||||
"items": { "const": "gear_mod" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "unknown" },
|
|
||||||
"view": { "const": "unknown" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": { "maxItems": 0 },
|
|
||||||
"abstention_reason": {
|
|
||||||
"enum": [
|
|
||||||
"low_confidence",
|
|
||||||
"ambiguous_family",
|
|
||||||
"ambiguous_view",
|
|
||||||
"unsupported_family",
|
|
||||||
"unsupported_view",
|
|
||||||
"insufficient_visual_evidence",
|
|
||||||
"occluded",
|
|
||||||
"corrupt_image",
|
|
||||||
"resource_limit",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"artifact": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"artifact_id",
|
|
||||||
"sha256",
|
|
||||||
"media_type",
|
|
||||||
"byte_size",
|
|
||||||
"sensitivity",
|
|
||||||
"parent_artifact_id",
|
|
||||||
"video_timestamp_ms"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"media_type": {
|
|
||||||
"enum": [
|
|
||||||
"image/png",
|
|
||||||
"image/webp",
|
|
||||||
"image/avif",
|
|
||||||
"video/mp4",
|
|
||||||
"video/webm",
|
|
||||||
"video/x-matroska"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"byte_size": { "type": "integer", "minimum": 1 },
|
|
||||||
"sensitivity": { "const": "private" },
|
|
||||||
"parent_artifact_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
},
|
|
||||||
"video_timestamp_ms": {
|
|
||||||
"type": ["integer", "null"],
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 86400000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"media_type": { "enum": ["image/png", "image/webp", "image/avif"] },
|
|
||||||
"byte_size": { "maximum": 67108864 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"media_type": {
|
|
||||||
"enum": ["video/mp4", "video/webm", "video/x-matroska"]
|
|
||||||
},
|
|
||||||
"byte_size": { "maximum": 17179869184 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"capture": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"capture_id",
|
|
||||||
"artifact_id",
|
|
||||||
"state",
|
|
||||||
"received_at",
|
|
||||||
"failure_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"capture_id": { "$ref": "#/$defs/id" },
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"received",
|
|
||||||
"spooling",
|
|
||||||
"stored",
|
|
||||||
"deduplicated",
|
|
||||||
"queued",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"received_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"failure_reason": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"screen": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"screen_id",
|
|
||||||
"artifact_id",
|
|
||||||
"classified_at",
|
|
||||||
"classification"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"screen_id": { "$ref": "#/$defs/id" },
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"classified_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"classification": { "$ref": "#/$defs/classification" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"job": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"job_id",
|
|
||||||
"kind",
|
|
||||||
"state",
|
|
||||||
"attempt",
|
|
||||||
"max_attempts",
|
|
||||||
"last_error_id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"job_id": { "$ref": "#/$defs/id" },
|
|
||||||
"kind": {
|
|
||||||
"enum": [
|
|
||||||
"classify",
|
|
||||||
"ocr",
|
|
||||||
"vision_fallback",
|
|
||||||
"frame_extract",
|
|
||||||
"export"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"queued",
|
|
||||||
"running",
|
|
||||||
"retry_wait",
|
|
||||||
"succeeded",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"attempt": { "type": "integer", "minimum": 0, "maximum": 20 },
|
|
||||||
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 20 },
|
|
||||||
"last_error_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"extraction": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"extraction_id",
|
|
||||||
"screen_id",
|
|
||||||
"state",
|
|
||||||
"evidence_refs",
|
|
||||||
"abstention_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"extraction_id": { "$ref": "#/$defs/id" },
|
|
||||||
"screen_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"pending",
|
|
||||||
"local_processing",
|
|
||||||
"fallback_pending",
|
|
||||||
"fallback_processing",
|
|
||||||
"candidate_ready",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "type": ["string", "null"], "maxLength": 100 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"group": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["group_id", "state", "screen_ids", "supersedes_group_ids"],
|
|
||||||
"properties": {
|
|
||||||
"group_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"ungrouped",
|
|
||||||
"proposed",
|
|
||||||
"grouped",
|
|
||||||
"split",
|
|
||||||
"superseded",
|
|
||||||
"rejected",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"screen_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"supersedes_group_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["review_id", "candidate_id", "state", "updated_at", "note"],
|
|
||||||
"properties": {
|
|
||||||
"review_id": { "$ref": "#/$defs/id" },
|
|
||||||
"candidate_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"pending",
|
|
||||||
"in_review",
|
|
||||||
"changes_requested",
|
|
||||||
"approved",
|
|
||||||
"rejected",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"updated_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"note": { "type": ["string", "null"], "maxLength": 4000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["export_id", "state", "artifact_id", "failure_reason"],
|
|
||||||
"properties": {
|
|
||||||
"export_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"not_requested",
|
|
||||||
"queued",
|
|
||||||
"building",
|
|
||||||
"ready",
|
|
||||||
"failed",
|
|
||||||
"cancelled",
|
|
||||||
"expired"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"artifact_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
},
|
|
||||||
"failure_reason": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["error_id", "state", "code", "message", "retry_after"],
|
|
||||||
"properties": {
|
|
||||||
"error_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"open",
|
|
||||||
"retryable",
|
|
||||||
"retrying",
|
|
||||||
"recovered",
|
|
||||||
"terminal",
|
|
||||||
"dismissed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"cancelled",
|
|
||||||
"resource_limit",
|
|
||||||
"timeout",
|
|
||||||
"invalid_input",
|
|
||||||
"engine_failure",
|
|
||||||
"storage_failure",
|
|
||||||
"network_failure",
|
|
||||||
"schema_rejected",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": { "type": "string", "minLength": 1, "maxLength": 2000 },
|
|
||||||
"retry_after": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/timestamp" }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"observedContext": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["level", "quality", "tier", "upgrade_state"],
|
|
||||||
"properties": {
|
|
||||||
"level": { "$ref": "#/$defs/fieldClaim" },
|
|
||||||
"quality": { "$ref": "#/$defs/fieldClaim" },
|
|
||||||
"tier": { "$ref": "#/$defs/fieldClaim" },
|
|
||||||
"upgrade_state": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"observedInstance": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"instance_id",
|
|
||||||
"family",
|
|
||||||
"source_screen_ids",
|
|
||||||
"observed_context",
|
|
||||||
"fields"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"instance_id": { "$ref": "#/$defs/id" },
|
|
||||||
"family": { "$ref": "#/$defs/family" },
|
|
||||||
"source_screen_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"observed_context": { "$ref": "#/$defs/observedContext" },
|
|
||||||
"fields": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"canonicalCandidate": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"candidate_id",
|
|
||||||
"record_type",
|
|
||||||
"source_instance_ids",
|
|
||||||
"evidence_refs",
|
|
||||||
"fields",
|
|
||||||
"completeness",
|
|
||||||
"relationship_status",
|
|
||||||
"mechanic_status"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"candidate_id": { "$ref": "#/$defs/id" },
|
|
||||||
"record_type": { "$ref": "#/$defs/recordType" },
|
|
||||||
"source_instance_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1000,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
},
|
|
||||||
"completeness": { "enum": ["complete", "incomplete", "unsupported"] },
|
|
||||||
"relationship_status": {
|
|
||||||
"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
},
|
|
||||||
"mechanic_status": {
|
|
||||||
"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 2,
|
|
||||||
"archive_id": "future-archive"
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"archive_id": "archive-001",
|
|
||||||
"created_at": "2026-07-31T12:00:00Z",
|
|
||||||
"generator": {
|
|
||||||
"name": "game-data-capture",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"policy_version": "1"
|
|
||||||
},
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"media_type": "image/png",
|
|
||||||
"byte_size": 2048,
|
|
||||||
"sensitivity": "private",
|
|
||||||
"parent_artifact_id": null,
|
|
||||||
"video_timestamp_ms": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"captures": [
|
|
||||||
{
|
|
||||||
"capture_id": "capture-001",
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"state": "queued",
|
|
||||||
"received_at": "2026-07-31T11:59:55Z",
|
|
||||||
"failure_reason": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"screens": [
|
|
||||||
{
|
|
||||||
"screen_id": "screen-001",
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"classified_at": "2026-07-31T11:59:57Z",
|
|
||||||
"classification": {
|
|
||||||
"family": "weapon",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon", "weapon_talent"],
|
|
||||||
"confidence": 0.97,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"jobs": [],
|
|
||||||
"extractions": [],
|
|
||||||
"groups": [],
|
|
||||||
"reviews": [
|
|
||||||
{
|
|
||||||
"review_id": "review-001",
|
|
||||||
"candidate_id": "candidate-001",
|
|
||||||
"state": "approved",
|
|
||||||
"updated_at": "2026-07-31T12:00:00Z",
|
|
||||||
"note": "Name verified against the image."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"exports": [],
|
|
||||||
"errors": [],
|
|
||||||
"evidence_values": [
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-name",
|
|
||||||
"field_path": "weapon.name",
|
|
||||||
"raw_text": "LVOA-C",
|
|
||||||
"normalized_value": "LVOA-C",
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.99,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "normalized",
|
|
||||||
"x": 0.1,
|
|
||||||
"y": 0.1,
|
|
||||||
"width": 0.3,
|
|
||||||
"height": 0.08
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-level",
|
|
||||||
"field_path": "observed.level",
|
|
||||||
"raw_text": "20",
|
|
||||||
"normalized_value": 20,
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.98,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 50,
|
|
||||||
"y": 50,
|
|
||||||
"width": 80,
|
|
||||||
"height": 30
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-quality",
|
|
||||||
"field_path": "observed.quality",
|
|
||||||
"raw_text": "High-end",
|
|
||||||
"normalized_value": "High-end",
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.96,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 50,
|
|
||||||
"y": 85,
|
|
||||||
"width": 140,
|
|
||||||
"height": 30
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-tier",
|
|
||||||
"field_path": "observed.tier",
|
|
||||||
"raw_text": "T1",
|
|
||||||
"normalized_value": 1,
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.97,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 200,
|
|
||||||
"y": 85,
|
|
||||||
"width": 60,
|
|
||||||
"height": 30
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-upgrade",
|
|
||||||
"field_path": "observed.upgrade_state",
|
|
||||||
"raw_text": null,
|
|
||||||
"normalized_value": null,
|
|
||||||
"missing_reason": "not_visible",
|
|
||||||
"confidence": 0,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "manual",
|
|
||||||
"engine_id": "review",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "normalized",
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"width": 1,
|
|
||||||
"height": 1
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-damage",
|
|
||||||
"field_path": "observed.damage",
|
|
||||||
"raw_text": "12,400",
|
|
||||||
"normalized_value": 12400,
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 1,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "manual",
|
|
||||||
"engine_id": "review",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 300,
|
|
||||||
"y": 200,
|
|
||||||
"width": 160,
|
|
||||||
"height": 40
|
|
||||||
},
|
|
||||||
"correction_history": [
|
|
||||||
{
|
|
||||||
"correction_id": "correction-001",
|
|
||||||
"corrected_at": "2026-07-31T12:00:00Z",
|
|
||||||
"actor": "local_user",
|
|
||||||
"reason": "Corrected a missing zero.",
|
|
||||||
"before": {
|
|
||||||
"raw_text": "1,240",
|
|
||||||
"normalized_value": 1240,
|
|
||||||
"missing_reason": null
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"raw_text": "12,400",
|
|
||||||
"normalized_value": 12400,
|
|
||||||
"missing_reason": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"observed_instances": [
|
|
||||||
{
|
|
||||||
"instance_id": "instance-001",
|
|
||||||
"family": "weapon",
|
|
||||||
"source_screen_ids": ["screen-001"],
|
|
||||||
"observed_context": {
|
|
||||||
"level": {
|
|
||||||
"path": "observed.level",
|
|
||||||
"value": 20,
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-level"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
},
|
|
||||||
"quality": {
|
|
||||||
"path": "observed.quality",
|
|
||||||
"value": "High-end",
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-quality"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
},
|
|
||||||
"tier": {
|
|
||||||
"path": "observed.tier",
|
|
||||||
"value": 1,
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-tier"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
},
|
|
||||||
"upgrade_state": {
|
|
||||||
"path": "observed.upgrade_state",
|
|
||||||
"value": null,
|
|
||||||
"missing_reason": "not_visible",
|
|
||||||
"evidence_refs": ["evidence-upgrade"],
|
|
||||||
"uncertainty_note": "The screen does not show an upgrade indicator."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"path": "observed.damage",
|
|
||||||
"value": 12400,
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-damage"],
|
|
||||||
"uncertainty_note": "This is the configured level-20 instance value, not a canonical range endpoint."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canonical_candidates": [
|
|
||||||
{
|
|
||||||
"candidate_id": "candidate-001",
|
|
||||||
"record_type": "weapon",
|
|
||||||
"source_instance_ids": ["instance-001"],
|
|
||||||
"evidence_refs": ["evidence-name"],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"path": "weapon.name",
|
|
||||||
"value": "LVOA-C",
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-name"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"completeness": "incomplete",
|
|
||||||
"relationship_status": "unresolved",
|
|
||||||
"mechanic_status": "not_applicable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": "Vest",
|
|
||||||
"record_types": ["gear"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear",
|
|
||||||
"view": "talent",
|
|
||||||
"gear_slot": "Body Armor",
|
|
||||||
"record_types": ["gear_talent"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear_mod",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": "Mask",
|
|
||||||
"record_types": ["gear_mod"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear_mod",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["gear_mod"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "os_protocol",
|
|
||||||
"view": "secondary_profile",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["os_protocol_secondary_profile"],
|
|
||||||
"confidence": 1.3,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "os_protocol",
|
|
||||||
"view": "secondary_profile",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["os_protocol_secondary_profile"],
|
|
||||||
"confidence": 0.93,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "skill_chip",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon"],
|
|
||||||
"confidence": 0.94,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "skill_chip",
|
|
||||||
"view": "set_bonus",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["skill_chip_set_bonus"],
|
|
||||||
"confidence": 0.94,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "specialization",
|
|
||||||
"view": "focus_tree",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["specialization_tree_node"],
|
|
||||||
"confidence": 0.91,
|
|
||||||
"abstention_reason": "ambiguous_view"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "specialization",
|
|
||||||
"view": "focus_tree",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["specialization_focus", "specialization_tree_node"],
|
|
||||||
"confidence": 0.91,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "unknown",
|
|
||||||
"view": "unknown",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon"],
|
|
||||||
"confidence": 0.22,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "unknown",
|
|
||||||
"view": "unknown",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": [],
|
|
||||||
"confidence": 0.22,
|
|
||||||
"abstention_reason": "low_confidence"
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user